Compare commits

..

10 Commits

Author SHA1 Message Date
waleed
248b513fa4 chore(deps): upgrade better-auth from 1.3.12 to 1.4.18 2026-02-18 16:42:56 -08:00
Waleed
ab48787422 chore(deps): upgrade next.js from 16.1.0-canary.21 to 16.1.6 (#3254) 2026-02-18 16:25:28 -08:00
Waleed
91aa1f9a52 feat(tools): added vercel block & tools (#3252)
* feat(vercel): add complete Vercel integration with 42 API tools

Add Vercel platform management integration covering deployments, projects,
environment variables, domains, DNS records, aliases, edge configs, and
team/user management. All tools use API key authentication with Bearer tokens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(vercel): add webhook and deployment check tools

Add 8 new Vercel API tools:
- Webhooks: list, create, delete
- Deployment Checks: create, get, list, update, rerequest

Brings total Vercel tools to 50.

* fix(vercel): expand all object and array output definitions

Expand unexpanded output types:
- get_deployment: meta and gitSource objects now have properties
- list_deployment_files: children array now has items definition
- get_team: teamRoles and teamPermissions arrays now have items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update icon size, update docs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:54:49 -08:00
Waleed
2979269ac3 fix(sidebar): unify workflow and folder insertion ordering (#3250)
* fix(sidebar): unify workflow and folder insertion ordering

* ack comments

* ack comments

* ack

* ack comment

* upgrade turbo

* fix build
2026-02-18 14:41:55 -08:00
Waleed
cf28822a1c fix(shortlink): remove isHosted guard from redirects, not available at build time on ECS (#3251)
* fix(shortlink): remove isHosted guard from redirects, not available at build time on ECS

* fix(shortlink): use rewrite instead of redirect for Beluga tracking
2026-02-18 14:00:25 -08:00
Waleed
86ca984926 fix(normalization): update allowed integrations checks to be fully lowercase (#3248) 2026-02-18 12:08:03 -08:00
Emir Karabeg
e3964624ac feat(sub): hide usage limits and seats info from enterprise members (non-admin) (#3243)
- Add isEnterpriseMember and canViewUsageInfo flags to subscription permissions
- Hide UsageHeader, CreditBalance, billing date, and usage notifications from enterprise members
- Show only plan name in subscription tab for enterprise members (non-admin)
- Hide usage indicator details (amount, progress pills) from enterprise members
- Team tab already hidden via requiresTeam check in settings modal

Closes #6882

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Emir Karabeg <emir-karabeg@users.noreply.github.com>
2026-02-18 12:01:47 -08:00
Waleed
7c7c0fd955 feat(audit-log): add audit events for templates, billing, credentials, env, deployments, passwords (#3246)
* feat(audit-log): add audit events for templates, billing, credentials, env, deployments, passwords

* improvement(audit-log): add actorName/actorEmail to all recordAudit calls

* fix(audit-log): resolve user for password reset, add CREDENTIAL_SET_INVITATION_RESENT action

* fix(audit-log): add workspaceId to deployment activation audit

* improvement(audit-log): use better-auth callback for password reset audit, remove cast

- Move password reset audit to onPasswordReset callback in auth config
  instead of coupling to better-auth's verification table internals
- Remove ugly double-cast on workflowData.workspaceId in deployment activation

* fix(audit-log): add missing actorName/actorEmail to workflow duplicate

* improvement(audit-log): add resourceName to credential set invitation accept
2026-02-18 11:53:08 -08:00
Waleed
e37b4a926d feat(audit-log): add persistent audit log system with comprehensive route instrumentation (#3242)
* feat(audit-log): add persistent audit log system with comprehensive route instrumentation

* fix(audit-log): address PR review — nullable workspaceId, enum usage, remove redundant queries

- Make audit_log.workspace_id nullable with ON DELETE SET NULL (logs survive workspace/user deletion)
- Make audit_log.actor_id nullable with ON DELETE SET NULL
- Replace all 53 routes' string literal action/resourceType with AuditAction.X and AuditResourceType.X enums
- Fix empty workspaceId ('') → null for OAuth, form, and org routes to avoid FK violations
- Remove redundant DB queries in chat manage route (use checkChatAccess return data)
- Fix organization routes to pass workspaceId: null instead of organizationId

* fix(audit-log): replace remaining workspaceId '' fallbacks with null

* fix(audit-log): credential-set org IDs, workspace deletion FK, actorId fallback, string literal action

* reran migrations

* fix(mcp,audit): tighten env var domain bypass, add post-resolution check, form workspaceId

- Only bypass MCP domain check when env var is in hostname/authority, not path/query
- Add post-resolution validateMcpDomain call in test-connection endpoint
- Match client-side isDomainAllowed to same hostname-only bypass logic
- Return workspaceId from checkFormAccess, use in form audit logs
- Add 49 comprehensive domain-check tests covering all edge cases

* fix(mcp): stateful regex lastIndex bug, RFC 3986 authority parsing

- Remove /g flag from module-level ENV_VAR_PATTERN to avoid lastIndex state
- Create fresh regex instances per call in server-side hasEnvVarInHostname
- Fix authority extraction to terminate at /, ?, or # per RFC 3986
- Prevents bypass via https://evil.com?token={{SECRET}} (no path)
- Add test cases for query-only and fragment-only env var URLs (53 total)

* fix(audit-log): try/catch for never-throw contract, accept null actorName/Email, fix misleading action

- Wrap recordAudit body in try/catch so nanoid() or header extraction can't throw
- Accept string | null for actorName and actorEmail (session.user.name can be null)
- Normalize null -> undefined before insert to match DB column types
- Fix org members route: ORG_MEMBER_ADDED -> ORG_INVITATION_CREATED (sends invite, not adds member)

* improvement(audit-log): add resource names and specific invitation actions

* fix(audit-log): use validated chat record, add mock sync tests
2026-02-18 00:54:52 -08:00
Waleed
11f3a14c02 fix(lock): prevent socket crash when locking agent blocks (#3245) 2026-02-18 00:32:09 -08:00
121 changed files with 10004 additions and 540 deletions

View File

@@ -5532,3 +5532,18 @@ export function OnePasswordIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function VercelIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 256 222'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid'
>
<g transform='translate(19.2 16.63) scale(0.85)'>
<polygon fill='#fafafa' points='128 0 256 221.705007 0 221.705007' />
</g>
</svg>
)
}

View File

@@ -125,6 +125,7 @@ import {
TTSIcon,
TwilioIcon,
TypeformIcon,
VercelIcon,
VideoIcon,
WealthboxIcon,
WebflowIcon,
@@ -262,6 +263,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
twilio_sms: TwilioIcon,
twilio_voice: TwilioIcon,
typeform: TypeformIcon,
vercel: VercelIcon,
video_generator_v2: VideoIcon,
vision_v2: EyeIcon,
wealthbox: WealthboxIcon,

View File

@@ -122,6 +122,7 @@
"twilio_sms",
"twilio_voice",
"typeform",
"vercel",
"video_generator",
"vision",
"wealthbox",

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@
"fumadocs-mdx": "14.1.0",
"fumadocs-ui": "16.2.3",
"lucide-react": "^0.511.0",
"next": "16.1.0-canary.21",
"next": "16.1.6",
"next-themes": "^0.4.6",
"postgres": "^3.4.5",
"react": "19.2.1",

View File

@@ -21,7 +21,7 @@ vi.mock('@/lib/core/utils/urls', () => ({
function setupAuthApiMocks(
options: {
operations?: {
forgetPassword?: { success?: boolean; error?: string }
requestPasswordReset?: { success?: boolean; error?: string }
resetPassword?: { success?: boolean; error?: string }
}
} = {}
@@ -34,7 +34,11 @@ function setupAuthApiMocks(
const { operations = {} } = options
const defaultOperations = {
forgetPassword: { success: true, error: 'Forget password error', ...operations.forgetPassword },
requestPasswordReset: {
success: true,
error: 'Forget password error',
...operations.requestPasswordReset,
},
resetPassword: { success: true, error: 'Reset password error', ...operations.resetPassword },
}
@@ -50,7 +54,7 @@ function setupAuthApiMocks(
vi.doMock('@/lib/auth', () => ({
auth: {
api: {
forgetPassword: createAuthMethod(defaultOperations.forgetPassword),
requestPasswordReset: createAuthMethod(defaultOperations.requestPasswordReset),
resetPassword: createAuthMethod(defaultOperations.resetPassword),
},
},
@@ -69,7 +73,7 @@ describe('Forget Password API Route', () => {
it('should send password reset email successfully with same-origin redirectTo', async () => {
setupAuthApiMocks({
operations: {
forgetPassword: { success: true },
requestPasswordReset: { success: true },
},
})
@@ -87,7 +91,7 @@ describe('Forget Password API Route', () => {
expect(data.success).toBe(true)
const auth = await import('@/lib/auth')
expect(auth.auth.api.forgetPassword).toHaveBeenCalledWith({
expect(auth.auth.api.requestPasswordReset).toHaveBeenCalledWith({
body: {
email: 'test@example.com',
redirectTo: 'https://app.example.com/reset',
@@ -99,7 +103,7 @@ describe('Forget Password API Route', () => {
it('should reject external redirectTo URL', async () => {
setupAuthApiMocks({
operations: {
forgetPassword: { success: true },
requestPasswordReset: { success: true },
},
})
@@ -117,13 +121,13 @@ describe('Forget Password API Route', () => {
expect(data.message).toBe('Redirect URL must be a valid same-origin URL')
const auth = await import('@/lib/auth')
expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled()
expect(auth.auth.api.requestPasswordReset).not.toHaveBeenCalled()
})
it('should send password reset email without redirectTo', async () => {
setupAuthApiMocks({
operations: {
forgetPassword: { success: true },
requestPasswordReset: { success: true },
},
})
@@ -140,7 +144,7 @@ describe('Forget Password API Route', () => {
expect(data.success).toBe(true)
const auth = await import('@/lib/auth')
expect(auth.auth.api.forgetPassword).toHaveBeenCalledWith({
expect(auth.auth.api.requestPasswordReset).toHaveBeenCalledWith({
body: {
email: 'test@example.com',
redirectTo: undefined,
@@ -163,7 +167,7 @@ describe('Forget Password API Route', () => {
expect(data.message).toBe('Email is required')
const auth = await import('@/lib/auth')
expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled()
expect(auth.auth.api.requestPasswordReset).not.toHaveBeenCalled()
})
it('should handle empty email', async () => {
@@ -182,7 +186,7 @@ describe('Forget Password API Route', () => {
expect(data.message).toBe('Please provide a valid email address')
const auth = await import('@/lib/auth')
expect(auth.auth.api.forgetPassword).not.toHaveBeenCalled()
expect(auth.auth.api.requestPasswordReset).not.toHaveBeenCalled()
})
it('should handle auth service error with message', async () => {
@@ -190,7 +194,7 @@ describe('Forget Password API Route', () => {
setupAuthApiMocks({
operations: {
forgetPassword: {
requestPasswordReset: {
success: false,
error: errorMessage,
},
@@ -222,7 +226,7 @@ describe('Forget Password API Route', () => {
vi.doMock('@/lib/auth', () => ({
auth: {
api: {
forgetPassword: vi.fn().mockRejectedValue('Unknown error'),
requestPasswordReset: vi.fn().mockRejectedValue('Unknown error'),
},
},
}))

View File

@@ -43,7 +43,7 @@ export async function POST(request: NextRequest) {
const { email, redirectTo } = validationResult.data
await auth.api.forgetPassword({
await auth.api.requestPasswordReset({
body: {
email,
redirectTo,

View File

@@ -17,7 +17,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
function setupAuthApiMocks(
options: {
operations?: {
forgetPassword?: { success?: boolean; error?: string }
requestPasswordReset?: { success?: boolean; error?: string }
resetPassword?: { success?: boolean; error?: string }
}
} = {}
@@ -30,7 +30,11 @@ function setupAuthApiMocks(
const { operations = {} } = options
const defaultOperations = {
forgetPassword: { success: true, error: 'Forget password error', ...operations.forgetPassword },
requestPasswordReset: {
success: true,
error: 'Forget password error',
...operations.requestPasswordReset,
},
resetPassword: { success: true, error: 'Reset password error', ...operations.resetPassword },
}
@@ -46,7 +50,7 @@ function setupAuthApiMocks(
vi.doMock('@/lib/auth', () => ({
auth: {
api: {
forgetPassword: createAuthMethod(defaultOperations.forgetPassword),
requestPasswordReset: createAuthMethod(defaultOperations.requestPasswordReset),
resetPassword: createAuthMethod(defaultOperations.resetPassword),
},
},

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { getCreditBalance } from '@/lib/billing/credits/balance'
import { purchaseCredits } from '@/lib/billing/credits/purchase'
@@ -57,6 +58,17 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDIT_PURCHASED,
resourceType: AuditResourceType.BILLING,
description: `Purchased $${validation.data.amount} in credits`,
metadata: { amount: validation.data.amount, requestId: validation.data.requestId },
request,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to purchase credits', { error, userId: session.user.id })

View File

@@ -292,8 +292,8 @@ export async function DELETE(
action: AuditAction.CHAT_DELETED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: chatRecord?.title,
description: `Deleted chat deployment "${chatRecord?.title}"`,
resourceName: chatRecord?.title || chatId,
description: `Deleted chat deployment "${chatRecord?.title || chatId}"`,
request: _request,
})

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -148,6 +149,19 @@ export async function POST(
userId: session.user.id,
})
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDENTIAL_SET_INVITATION_RESENT,
resourceType: AuditResourceType.CREDENTIAL_SET,
resourceId: id,
resourceName: result.set.name,
description: `Resent credential set invitation to ${invitation.email}`,
metadata: { invitationId, email: invitation.email },
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error resending invitation', error)

View File

@@ -258,7 +258,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: result.set.name,
description: `Revoked an invitation for credential set "${result.set.name}"`,
description: `Revoked invitation "${invitationId}" for credential set "${result.set.name}"`,
request: req,
})

View File

@@ -8,6 +8,7 @@ import {
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
@@ -78,6 +79,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
status: credentialSetInvitation.status,
expiresAt: credentialSetInvitation.expiresAt,
invitedBy: credentialSetInvitation.invitedBy,
credentialSetName: credentialSet.name,
providerId: credentialSet.providerId,
})
.from(credentialSetInvitation)
@@ -125,7 +127,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
const now = new Date()
const requestId = crypto.randomUUID().slice(0, 8)
// Use transaction to ensure membership + invitation update + webhook sync are atomic
await db.transaction(async (tx) => {
await tx.insert(credentialSetMember).values({
id: crypto.randomUUID(),
@@ -147,8 +148,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
})
.where(eq(credentialSetInvitation.id, invitation.id))
// Clean up all other pending invitations for the same credential set and email
// This prevents duplicate invites from showing up after accepting one
if (invitation.email) {
await tx
.update(credentialSetInvitation)
@@ -166,7 +165,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
)
}
// Sync webhooks within the transaction
const syncResult = await syncAllWebhooksForCredentialSet(
invitation.credentialSetId,
requestId,
@@ -184,6 +182,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
userId: session.user.id,
})
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDENTIAL_SET_INVITATION_ACCEPTED,
resourceType: AuditResourceType.CREDENTIAL_SET,
resourceId: invitation.credentialSetId,
resourceName: invitation.credentialSetName,
description: `Accepted credential set invitation`,
metadata: { invitationId: invitation.id },
request: req,
})
return NextResponse.json({
success: true,
credentialSetId: invitation.credentialSetId,

View File

@@ -3,6 +3,7 @@ import { credentialSet, credentialSetMember, organization } from '@sim/db/schema
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
@@ -106,6 +107,17 @@ export async function DELETE(req: NextRequest) {
userId: session.user.id,
})
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDENTIAL_SET_MEMBER_LEFT,
resourceType: AuditResourceType.CREDENTIAL_SET,
resourceId: credentialSetId,
description: `Left credential set`,
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to leave credential set'

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -53,6 +54,17 @@ export async function POST(req: NextRequest) {
},
})
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.ENVIRONMENT_UPDATED,
resourceType: AuditResourceType.ENVIRONMENT,
description: 'Updated global environment variables',
metadata: { variableCount: Object.keys(variables).length },
request: req,
})
return NextResponse.json({ success: true })
} catch (validationError) {
if (validationError instanceof z.ZodError) {

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, eq, isNull, min } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
@@ -37,7 +37,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
// Verify the source folder exists
const sourceFolder = await db
.select()
.from(workflowFolder)
@@ -48,7 +47,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
throw new Error('Source folder not found')
}
// Check if user has permission to access the source folder
const userPermission = await getUserEntityPermissions(
session.user.id,
'workspace',
@@ -61,26 +59,51 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
// Step 1: Duplicate folder structure
const { newFolderId, folderMapping } = await db.transaction(async (tx) => {
const newFolderId = crypto.randomUUID()
const now = new Date()
const targetParentId = parentId ?? sourceFolder.parentId
const folderParentCondition = targetParentId
? eq(workflowFolder.parentId, targetParentId)
: isNull(workflowFolder.parentId)
const workflowParentCondition = targetParentId
? eq(workflow.folderId, targetParentId)
: isNull(workflow.folderId)
const [[folderResult], [workflowResult]] = await Promise.all([
tx
.select({ minSortOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)),
tx
.select({ minSortOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)),
])
const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
// Create the new root folder
await tx.insert(workflowFolder).values({
id: newFolderId,
userId: session.user.id,
workspaceId: targetWorkspaceId,
name,
color: color || sourceFolder.color,
parentId: parentId || sourceFolder.parentId,
sortOrder: sourceFolder.sortOrder,
parentId: targetParentId,
sortOrder,
isExpanded: false,
createdAt: now,
updatedAt: now,
})
// Recursively duplicate child folders
const folderMapping = new Map<string, string>([[sourceFolderId, newFolderId]])
await duplicateFolderStructure(
tx,
@@ -96,7 +119,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
return { newFolderId, folderMapping }
})
// Step 2: Duplicate workflows
const workflowStats = await duplicateWorkflowsInFolderTree(
sourceFolder.workspaceId,
targetWorkspaceId,
@@ -173,7 +195,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
}
}
// Helper to recursively duplicate folder structure
async function duplicateFolderStructure(
tx: any,
sourceFolderId: string,
@@ -184,7 +205,6 @@ async function duplicateFolderStructure(
timestamp: Date,
folderMapping: Map<string, string>
): Promise<void> {
// Get all child folders
const childFolders = await tx
.select()
.from(workflowFolder)
@@ -195,7 +215,6 @@ async function duplicateFolderStructure(
)
)
// Create each child folder and recurse
for (const childFolder of childFolders) {
const newChildFolderId = crypto.randomUUID()
folderMapping.set(childFolder.id, newChildFolderId)
@@ -213,7 +232,6 @@ async function duplicateFolderStructure(
updatedAt: timestamp,
})
// Recurse for this child's children
await duplicateFolderStructure(
tx,
childFolder.id,
@@ -227,7 +245,6 @@ async function duplicateFolderStructure(
}
}
// Helper to duplicate all workflows in a folder tree
async function duplicateWorkflowsInFolderTree(
sourceWorkspaceId: string,
targetWorkspaceId: string,
@@ -237,9 +254,7 @@ async function duplicateWorkflowsInFolderTree(
): Promise<{ total: number; succeeded: number; failed: number }> {
const stats = { total: 0, succeeded: 0, failed: 0 }
// Process each folder in the mapping
for (const [oldFolderId, newFolderId] of folderMapping.entries()) {
// Get workflows in this folder
const workflowsInFolder = await db
.select()
.from(workflow)
@@ -247,7 +262,6 @@ async function duplicateWorkflowsInFolderTree(
stats.total += workflowsInFolder.length
// Duplicate each workflow
for (const sourceWorkflow of workflowsInFolder) {
try {
await duplicateWorkflow({

View File

@@ -10,9 +10,14 @@ import {
mockConsoleLogger,
setupCommonApiMocks,
} from '@sim/testing'
import { drizzleOrmMock } from '@sim/testing/mocks'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('drizzle-orm', () => ({
...drizzleOrmMock,
min: vi.fn((field) => ({ type: 'min', field })),
}))
interface CapturedFolderValues {
name?: string
@@ -24,29 +29,35 @@ interface CapturedFolderValues {
}
function createMockTransaction(mockData: {
selectData?: Array<{ id: string; [key: string]: unknown }>
selectResults?: Array<Array<{ [key: string]: unknown }>>
insertResult?: Array<{ id: string; [key: string]: unknown }>
onInsertValues?: (values: CapturedFolderValues) => void
}) {
const { selectData = [], insertResult = [] } = mockData
return vi.fn().mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => {
const { selectResults = [[], []], insertResult = [], onInsertValues } = mockData
return async (callback: (tx: unknown) => Promise<unknown>) => {
const where = vi.fn()
for (const result of selectResults) {
where.mockReturnValueOnce(result)
}
where.mockReturnValue([])
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue(selectData),
}),
}),
where,
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue(insertResult),
values: vi.fn().mockImplementation((values: CapturedFolderValues) => {
onInsertValues?.(values)
return {
returning: vi.fn().mockReturnValue(insertResult),
}
}),
}),
}
return await callback(tx)
})
}
}
describe('Folders API Route', () => {
@@ -257,25 +268,12 @@ describe('Folders API Route', () => {
it('should create a new folder successfully', async () => {
mockAuthenticatedUser()
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]), // No existing folders
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}),
}),
}
return await callback(tx)
})
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectResults: [[], []],
insertResult: [mockFolders[0]],
})
)
const req = createMockRequest('POST', {
name: 'New Test Folder',
@@ -285,12 +283,11 @@ describe('Folders API Route', () => {
const { POST } = await import('@/app/api/folders/route')
const response = await POST(req)
const responseBody = await response.json()
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('folder')
expect(data.folder).toMatchObject({
expect(responseBody).toHaveProperty('folder')
expect(responseBody.folder).toMatchObject({
id: 'folder-1',
name: 'Test Folder 1',
workspaceId: 'workspace-123',
@@ -299,26 +296,17 @@ describe('Folders API Route', () => {
it('should create folder with correct sort order', async () => {
mockAuthenticatedUser()
let capturedValues: CapturedFolderValues | null = null
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([{ sortOrder: 5 }]), // Existing folder with sort order 5
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([{ ...mockFolders[0], sortOrder: 6 }]),
}),
}),
}
return await callback(tx)
})
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectResults: [[{ minSortOrder: 5 }], [{ minSortOrder: 2 }]],
insertResult: [{ ...mockFolders[0], sortOrder: 1 }],
onInsertValues: (values) => {
capturedValues = values
},
})
)
const req = createMockRequest('POST', {
name: 'New Test Folder',
@@ -332,8 +320,10 @@ describe('Folders API Route', () => {
const data = await response.json()
expect(data.folder).toMatchObject({
sortOrder: 6,
sortOrder: 1,
})
expect(capturedValues).not.toBeNull()
expect(capturedValues!.sortOrder).toBe(1)
})
it('should create subfolder with parent reference', async () => {
@@ -341,7 +331,7 @@ describe('Folders API Route', () => {
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectData: [], // No existing folders
selectResults: [[], []],
insertResult: [{ ...mockFolders[1] }],
})
)
@@ -402,25 +392,12 @@ describe('Folders API Route', () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('write') // Write permissions
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]), // No existing folders
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}),
}),
}
return await callback(tx)
})
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectResults: [[], []],
insertResult: [mockFolders[0]],
})
)
const req = createMockRequest('POST', {
name: 'Test Folder',
@@ -440,25 +417,12 @@ describe('Folders API Route', () => {
mockAuthenticatedUser()
mockGetUserEntityPermissions.mockResolvedValue('admin') // Admin permissions
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]), // No existing folders
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}),
}),
}
return await callback(tx)
})
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectResults: [[], []],
insertResult: [mockFolders[0]],
})
)
const req = createMockRequest('POST', {
name: 'Test Folder',
@@ -527,28 +491,15 @@ describe('Folders API Route', () => {
let capturedValues: CapturedFolderValues | null = null
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockImplementation((values) => {
capturedValues = values
return {
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}
}),
}),
}
return await callback(tx)
})
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectResults: [[], []],
insertResult: [mockFolders[0]],
onInsertValues: (values) => {
capturedValues = values
},
})
)
const req = createMockRequest('POST', {
name: ' Test Folder With Spaces ',
@@ -567,28 +518,15 @@ describe('Folders API Route', () => {
let capturedValues: CapturedFolderValues | null = null
mockTransaction.mockImplementationOnce(async (callback: any) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([]),
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockImplementation((values) => {
capturedValues = values
return {
returning: vi.fn().mockReturnValue([mockFolders[0]]),
}
}),
}),
}
return await callback(tx)
})
mockTransaction.mockImplementationOnce(
createMockTransaction({
selectResults: [[], []],
insertResult: [mockFolders[0]],
onInsertValues: (values) => {
capturedValues = values
},
})
)
const req = createMockRequest('POST', {
name: 'Test Folder',

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { workflowFolder } from '@sim/db/schema'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, desc, eq, isNull } from 'drizzle-orm'
import { and, asc, eq, isNull, min } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
@@ -87,19 +87,33 @@ export async function POST(request: NextRequest) {
if (providedSortOrder !== undefined) {
sortOrder = providedSortOrder
} else {
const existingFolders = await tx
.select({ sortOrder: workflowFolder.sortOrder })
.from(workflowFolder)
.where(
and(
eq(workflowFolder.workspaceId, workspaceId),
parentId ? eq(workflowFolder.parentId, parentId) : isNull(workflowFolder.parentId)
)
)
.orderBy(desc(workflowFolder.sortOrder))
.limit(1)
const folderParentCondition = parentId
? eq(workflowFolder.parentId, parentId)
: isNull(workflowFolder.parentId)
const workflowParentCondition = parentId
? eq(workflow.folderId, parentId)
: isNull(workflow.folderId)
sortOrder = existingFolders.length > 0 ? existingFolders[0].sortOrder + 1 : 0
const [[folderResult], [workflowResult]] = await Promise.all([
tx
.select({ minSortOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)),
tx
.select({ minSortOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)),
])
const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
}
const [folder] = await tx

View File

@@ -205,7 +205,7 @@ export async function POST(request: NextRequest) {
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: title,
description: `Created form "${title}" for workflow "${workflowRecord.name}"`,
description: `Created form "${title}" for workflow ${workflowId}`,
request,
})

View File

@@ -207,7 +207,7 @@ export async function PUT(
resourceType: AuditResourceType.DOCUMENT,
resourceId: documentId,
resourceName: validatedData.filename ?? accessCheck.document?.filename,
description: `Updated document "${validatedData.filename ?? accessCheck.document?.filename}" in knowledge base "${accessCheck.knowledgeBase?.name}"`,
description: `Updated document "${documentId}" in knowledge base "${knowledgeBaseId}"`,
request: req,
})
@@ -280,7 +280,7 @@ export async function DELETE(
resourceType: AuditResourceType.DOCUMENT,
resourceId: documentId,
resourceName: accessCheck.document?.filename,
description: `Deleted document "${accessCheck.document?.filename}" from knowledge base "${accessCheck.knowledgeBase?.name}"`,
description: `Deleted document "${documentId}" from knowledge base "${knowledgeBaseId}"`,
request: req,
})

View File

@@ -254,7 +254,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
resourceType: AuditResourceType.DOCUMENT,
resourceId: knowledgeBaseId,
resourceName: `${createdDocuments.length} document(s)`,
description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${accessCheck.knowledgeBase?.name}"`,
description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`,
request: req,
})
@@ -315,7 +315,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
resourceType: AuditResourceType.DOCUMENT,
resourceId: knowledgeBaseId,
resourceName: validatedData.filename,
description: `Uploaded document "${validatedData.filename}" to knowledge base "${accessCheck.knowledgeBase?.name}"`,
description: `Uploaded document "${validatedData.filename}" to knowledge base "${knowledgeBaseId}"`,
request: req,
})

View File

@@ -220,7 +220,7 @@ export async function DELETE(
resourceType: AuditResourceType.KNOWLEDGE_BASE,
resourceId: id,
resourceName: accessCheck.knowledgeBase.name,
description: `Deleted knowledge base "${accessCheck.knowledgeBase.name}"`,
description: `Deleted knowledge base "${accessCheck.knowledgeBase.name || id}"`,
request: _request,
})

View File

@@ -99,8 +99,8 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: updatedServer.name,
description: `Updated MCP server "${updatedServer.name}"`,
resourceName: updatedServer.name || serverId,
description: `Updated MCP server "${updatedServer.name || serverId}"`,
request,
})

View File

@@ -131,7 +131,6 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: updatedTool.toolName,
description: `Updated tool "${updatedTool.toolName}" in MCP server`,
metadata: { toolId, toolName: updatedTool.toolName },
request,
@@ -196,7 +195,6 @@ export const DELETE = withMcpAuth<RouteParams>('write')(
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: deletedTool.toolName,
description: `Removed tool "${deletedTool.toolName}" from MCP server`,
metadata: { toolId, toolName: deletedTool.toolName },
request,

View File

@@ -210,7 +210,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: toolName,
description: `Added tool "${toolName}" to MCP server`,
metadata: { toolId, toolName, workflowId: body.workflowId },
request,

View File

@@ -567,7 +567,6 @@ export async function PUT(
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: orgInvitation.email,
description: `Organization invitation ${status} for ${orgInvitation.email}`,
metadata: { invitationId, email: orgInvitation.email, status },
request: req,

View File

@@ -557,7 +557,6 @@ export async function DELETE(
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: result[0].email,
description: `Revoked organization invitation for ${result[0].email}`,
metadata: { invitationId, email: result[0].email },
request,

View File

@@ -222,7 +222,7 @@ export async function PUT(
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Changed member role to ${role}`,
description: `Changed role for member ${memberId} to ${role}`,
metadata: { targetUserId: memberId, newRole: role },
request,
})
@@ -330,7 +330,7 @@ export async function DELETE(
description:
session.user.id === targetUserId
? 'Left the organization'
: 'Removed a member from the organization',
: `Removed member ${targetUserId} from organization`,
metadata: { targetUserId, wasSelfRemoval: session.user.id === targetUserId },
request,
})

View File

@@ -294,7 +294,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: normalizedEmail,
description: `Invited ${normalizedEmail} to organization as ${role}`,
metadata: { invitationId, email: normalizedEmail, role },
request,

View File

@@ -162,7 +162,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
resourceName: result.group.name,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Added a member to permission group "${result.group.name}"`,
description: `Added member ${userId} to permission group "${result.group.name}"`,
metadata: { targetUserId: userId, permissionGroupId: id },
request: req,
})
@@ -246,7 +246,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
resourceName: result.group.name,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Removed a member from permission group "${result.group.name}"`,
description: `Removed member ${memberToRemove.userId} from permission group "${result.group.name}"`,
metadata: { targetUserId: memberToRemove.userId, memberId, permissionGroupId: id },
request: req,
})

View File

@@ -115,8 +115,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
resourceId: scheduleId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: authorization.workflow?.name,
description: `Reactivated schedule for workflow "${authorization.workflow?.name}"`,
description: `Reactivated schedule for workflow ${schedule.workflowId}`,
request,
})

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import {
@@ -247,6 +248,18 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Successfully updated template: ${id}`)
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.TEMPLATE_UPDATED,
resourceType: AuditResourceType.TEMPLATE,
resourceId: id,
resourceName: name ?? template.name,
description: `Updated template "${name ?? template.name}"`,
request,
})
return NextResponse.json({
data: updatedTemplate[0],
message: 'Template updated successfully',
@@ -300,6 +313,19 @@ export async function DELETE(
await db.delete(templates).where(eq(templates.id, id))
logger.info(`[${requestId}] Deleted template: ${id}`)
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.TEMPLATE_DELETED,
resourceType: AuditResourceType.TEMPLATE,
resourceId: id,
resourceName: template.name,
description: `Deleted template "${template.name}"`,
request,
})
return NextResponse.json({ success: true })
} catch (error: any) {
logger.error(`[${requestId}] Error deleting template: ${id}`, error)

View File

@@ -11,6 +11,7 @@ import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
@@ -285,6 +286,18 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Successfully created template: ${templateId}`)
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.TEMPLATE_CREATED,
resourceType: AuditResourceType.TEMPLATE,
resourceId: templateId,
resourceName: data.name,
description: `Created template "${data.name}"`,
request,
})
return NextResponse.json(
{
id: templateId,

View File

@@ -684,8 +684,8 @@ export async function POST(request: NextRequest) {
recordAudit({
workspaceId: workflowRecord.workspaceId || null,
actorId: userId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
actorName: session?.user?.name ?? undefined,
actorEmail: session?.user?.email ?? undefined,
action: AuditAction.WEBHOOK_CREATED,
resourceType: AuditResourceType.WEBHOOK,
resourceId: savedWebhook.id,

View File

@@ -268,7 +268,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
resourceName: workflowData?.name,
description: `Deployed workflow "${workflowData.name}"`,
description: `Deployed workflow "${workflowData?.name || id}"`,
request,
})
@@ -348,7 +348,7 @@ export async function DELETE(
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
resourceName: workflowData?.name,
description: `Undeployed workflow "${workflowData.name}"`,
description: `Undeployed workflow "${workflowData?.name || id}"`,
request,
})

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
@@ -297,6 +298,19 @@ export async function PATCH(
}
}
recordAudit({
workspaceId: workflowData?.workspaceId,
actorId: actorUserId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
description: `Activated deployment version ${versionNum}`,
metadata: { version: versionNum },
request,
})
return createSuccessResponse({
success: true,
deployedAt: result.deployedAt,

View File

@@ -71,7 +71,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
resourceType: AuditResourceType.WORKFLOW,
resourceId: result.id,
resourceName: result.name,
description: `Duplicated workflow as "${result.name}"`,
description: `Duplicated workflow from ${sourceWorkflowId}`,
metadata: { sourceWorkflowId },
request: req,
})

View File

@@ -423,20 +423,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
updates: updateData,
})
recordAudit({
workspaceId: workflowData.workspaceId || null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WORKFLOW_UPDATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: updatedWorkflow?.name ?? workflowData.name,
description: `Updated workflow "${updatedWorkflow?.name ?? workflowData.name}"`,
metadata: updates,
request,
})
return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 })
} catch (error: any) {
const elapsed = Date.now() - startTime

View File

@@ -0,0 +1,137 @@
/**
* @vitest-environment node
*/
import { auditMock, createMockRequest, mockConsoleLogger, setupCommonApiMocks } from '@sim/testing'
import { drizzleOrmMock } from '@sim/testing/mocks'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockCheckSessionOrInternalAuth = vi.fn()
const mockGetUserEntityPermissions = vi.fn()
const mockDbSelect = vi.fn()
const mockDbInsert = vi.fn()
const mockWorkflowCreated = vi.fn()
vi.mock('drizzle-orm', () => ({
...drizzleOrmMock,
min: vi.fn((field) => ({ type: 'min', field })),
}))
vi.mock('@/lib/audit/log', () => auditMock)
describe('Workflows API Route - POST ordering', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
setupCommonApiMocks()
mockConsoleLogger()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue('workflow-new-id'),
})
mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-123',
userName: 'Test User',
userEmail: 'test@example.com',
})
mockGetUserEntityPermissions.mockResolvedValue('write')
vi.doMock('@sim/db', () => ({
db: {
select: (...args: unknown[]) => mockDbSelect(...args),
insert: (...args: unknown[]) => mockDbInsert(...args),
},
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: (...args: unknown[]) => mockCheckSessionOrInternalAuth(...args),
}))
vi.doMock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args),
workspaceExists: vi.fn(),
}))
vi.doMock('@/app/api/workflows/utils', () => ({
verifyWorkspaceMembership: vi.fn(),
}))
vi.doMock('@/lib/core/telemetry', () => ({
PlatformEvents: {
workflowCreated: (...args: unknown[]) => mockWorkflowCreated(...args),
},
}))
})
it('uses top insertion against mixed siblings (folders + workflows)', async () => {
const minResultsQueue: Array<Array<{ minOrder: number }>> = [
[{ minOrder: 5 }],
[{ minOrder: 2 }],
]
mockDbSelect.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])),
}),
}))
let insertedValues: Record<string, unknown> | null = null
mockDbInsert.mockReturnValue({
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
insertedValues = values
return Promise.resolve(undefined)
}),
})
const req = createMockRequest('POST', {
name: 'New Workflow',
description: 'desc',
color: '#3972F6',
workspaceId: 'workspace-123',
folderId: null,
})
const { POST } = await import('@/app/api/workflows/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.sortOrder).toBe(1)
expect(insertedValues).not.toBeNull()
expect(insertedValues?.sortOrder).toBe(1)
})
it('defaults to sortOrder 0 when there are no siblings', async () => {
const minResultsQueue: Array<Array<{ minOrder: number }>> = [[], []]
mockDbSelect.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])),
}),
}))
let insertedValues: Record<string, unknown> | null = null
mockDbInsert.mockReturnValue({
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
insertedValues = values
return Promise.resolve(undefined)
}),
})
const req = createMockRequest('POST', {
name: 'New Workflow',
description: 'desc',
color: '#3972F6',
workspaceId: 'workspace-123',
folderId: null,
})
const { POST } = await import('@/app/api/workflows/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.sortOrder).toBe(0)
expect(insertedValues?.sortOrder).toBe(0)
})
})

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { permissions, workflow } from '@sim/db/schema'
import { permissions, workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -162,12 +162,33 @@ export async function POST(req: NextRequest) {
if (providedSortOrder !== undefined) {
sortOrder = providedSortOrder
} else {
const folderCondition = folderId ? eq(workflow.folderId, folderId) : isNull(workflow.folderId)
const [minResult] = await db
.select({ minOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), folderCondition))
sortOrder = (minResult?.minOrder ?? 1) - 1
const workflowParentCondition = folderId
? eq(workflow.folderId, folderId)
: isNull(workflow.folderId)
const folderParentCondition = folderId
? eq(workflowFolder.parentId, folderId)
: isNull(workflowFolder.parentId)
const [[workflowMinResult], [folderMinResult]] = await Promise.all([
db
.select({ minOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)),
db
.select({ minOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)),
])
const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
}
await db.insert(workflow).values({

View File

@@ -264,7 +264,6 @@ export async function DELETE(
actorEmail: session?.user?.email,
action: AuditAction.BYOK_KEY_DELETED,
resourceType: AuditResourceType.BYOK_KEY,
resourceId: providerId,
resourceName: providerId,
description: `Removed BYOK key for ${providerId}`,
metadata: { providerId },

View File

@@ -165,7 +165,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
action: AuditAction.ENVIRONMENT_UPDATED,
resourceType: AuditResourceType.ENVIRONMENT,
resourceId: workspaceId,
resourceName: 'Environment Variables',
description: `Updated environment variables`,
metadata: { keysUpdated: Object.keys(variables) },
request,

View File

@@ -166,7 +166,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
resourceId: workspaceId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Changed workspace permissions to ${update.permissions}`,
description: `Changed permissions for user ${update.userId} to ${update.permissions}`,
metadata: { targetUserId: update.userId, newPermissions: update.permissions },
request,
})

View File

@@ -298,7 +298,7 @@ export async function DELETE(
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
resourceName: workspaceRecord?.name,
description: `Deleted workspace "${workspaceRecord?.name}"`,
description: `Deleted workspace "${workspaceRecord?.name || workspaceId}"`,
request,
})

View File

@@ -238,7 +238,6 @@ export async function DELETE(
resourceId: invitation.workspaceId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: invitation.email,
description: `Revoked workspace invitation for ${invitation.email}`,
metadata: { invitationId, email: invitation.email },
request: _request,

View File

@@ -618,6 +618,15 @@ export function Editor() {
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
</div>
)}
{hasAdvancedOnlyFields && !canEditBlock && displayAdvancedOptions && (
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
<span className='whitespace-nowrap font-medium text-[13px] text-[var(--text-secondary)]'>
Additional fields
</span>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
</div>
)}
{advancedOnlySubBlocks.map((subBlock, index) => {
const stableKey = getSubBlockStableKey(

View File

@@ -227,7 +227,7 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
(acc, service) => {
if (
permissionConfig.allowedIntegrations !== null &&
!permissionConfig.allowedIntegrations.includes(service.id.replace(/-/g, '_'))
!permissionConfig.allowedIntegrations.includes(service.id.replace(/-/g, '_').toLowerCase())
) {
return acc
}

View File

@@ -7,6 +7,8 @@ export interface SubscriptionPermissions {
canCancelSubscription: boolean
showTeamMemberView: boolean
showUpgradePlans: boolean
isEnterpriseMember: boolean
canViewUsageInfo: boolean
}
export interface SubscriptionState {
@@ -31,6 +33,9 @@ export function getSubscriptionPermissions(
const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription
const { isTeamAdmin } = userRole
const isEnterpriseMember = isEnterprise && !isTeamAdmin
const canViewUsageInfo = !isEnterpriseMember
return {
canUpgradeToPro: isFree,
canUpgradeToTeam: isFree || (isPro && !isTeam),
@@ -40,6 +45,8 @@ export function getSubscriptionPermissions(
canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel
showTeamMemberView: isTeam && !isTeamAdmin,
showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans
isEnterpriseMember,
canViewUsageInfo,
}
}

View File

@@ -300,12 +300,16 @@ export function Subscription() {
)
const showBadge =
(permissions.canEditUsageLimit && !permissions.showTeamMemberView) ||
permissions.showTeamMemberView ||
subscription.isEnterprise ||
isBlocked
!permissions.isEnterpriseMember &&
((permissions.canEditUsageLimit && !permissions.showTeamMemberView) ||
permissions.showTeamMemberView ||
subscription.isEnterprise ||
isBlocked)
const getBadgeConfig = (): { text: string; variant: 'blue-secondary' | 'red' } => {
if (permissions.isEnterpriseMember) {
return { text: '', variant: 'blue-secondary' }
}
if (permissions.showTeamMemberView || subscription.isEnterprise) {
return { text: `${subscription.seats} seats`, variant: 'blue-secondary' }
}
@@ -443,67 +447,75 @@ export function Subscription() {
return (
<div className='flex h-full flex-col gap-[20px]'>
{/* Current Plan & Usage Overview */}
<UsageHeader
title={formatPlanName(subscription.plan)}
showBadge={showBadge}
badgeText={badgeConfig.text}
badgeVariant={badgeConfig.variant}
onBadgeClick={permissions.showTeamMemberView ? undefined : handleBadgeClick}
seatsText={
permissions.canManageTeam || subscription.isEnterprise
? `${subscription.seats} seats`
: undefined
}
current={usage.current}
limit={
subscription.isEnterprise || subscription.isTeam
? organizationBillingData?.data?.totalUsageLimit
: !subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
? usage.current // placeholder; rightContent will render UsageLimit
: usage.limit
}
isBlocked={isBlocked}
progressValue={Math.min(usage.percentUsed, 100)}
rightContent={
!subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView) ? (
<UsageLimit
ref={usageLimitRef}
currentLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
? organizationBillingData.data.totalUsageLimit
: usageLimitData.currentLimit || usage.limit
}
currentUsage={usage.current}
canEdit={permissions.canEditUsageLimit}
minimumLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
? organizationBillingData.data.minimumBillingAmount
: usageLimitData.minimumLimit || (subscription.isPro ? 20 : 40)
}
context={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? 'organization'
: 'user'
}
organizationId={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? activeOrgId
: undefined
}
onLimitUpdated={() => {
logger.info('Usage limit updated')
}}
/>
) : undefined
}
/>
{/* Current Plan & Usage Overview - hidden from enterprise members (non-admin) */}
{permissions.canViewUsageInfo ? (
<UsageHeader
title={formatPlanName(subscription.plan)}
showBadge={showBadge}
badgeText={badgeConfig.text}
badgeVariant={badgeConfig.variant}
onBadgeClick={permissions.showTeamMemberView ? undefined : handleBadgeClick}
seatsText={
permissions.canManageTeam || subscription.isEnterprise
? `${subscription.seats} seats`
: undefined
}
current={usage.current}
limit={
subscription.isEnterprise || subscription.isTeam
? organizationBillingData?.data?.totalUsageLimit
: !subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
? usage.current // placeholder; rightContent will render UsageLimit
: usage.limit
}
isBlocked={isBlocked}
progressValue={Math.min(usage.percentUsed, 100)}
rightContent={
!subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView) ? (
<UsageLimit
ref={usageLimitRef}
currentLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
? organizationBillingData.data.totalUsageLimit
: usageLimitData.currentLimit || usage.limit
}
currentUsage={usage.current}
canEdit={permissions.canEditUsageLimit}
minimumLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
? organizationBillingData.data.minimumBillingAmount
: usageLimitData.minimumLimit || (subscription.isPro ? 20 : 40)
}
context={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? 'organization'
: 'user'
}
organizationId={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? activeOrgId
: undefined
}
onLimitUpdated={() => {
logger.info('Usage limit updated')
}}
/>
) : undefined
}
/>
) : (
<div className='flex items-center'>
<span className='font-medium text-[14px] text-[var(--text-primary)]'>
{formatPlanName(subscription.plan)}
</span>
</div>
)}
{/* Upgrade Plans */}
{permissions.showUpgradePlans && (
@@ -539,8 +551,8 @@ export function Subscription() {
</div>
)}
{/* Credit Balance */}
{subscription.isPaid && (
{/* Credit Balance - hidden from enterprise members (non-admin) */}
{subscription.isPaid && permissions.canViewUsageInfo && (
<CreditBalance
balance={subscriptionData?.data?.creditBalance ?? 0}
canPurchase={permissions.canEditUsageLimit}
@@ -554,10 +566,11 @@ export function Subscription() {
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
)}
{/* Next Billing Date - hidden from team members */}
{/* Next Billing Date - hidden from team members and enterprise members (non-admin) */}
{subscription.isPaid &&
subscriptionData?.data?.periodEnd &&
!permissions.showTeamMemberView && (
!permissions.showTeamMemberView &&
!permissions.isEnterpriseMember && (
<div className='flex items-center justify-between'>
<Label>Next Billing Date</Label>
<span className='text-[12px] text-[var(--text-secondary)]'>
@@ -566,8 +579,8 @@ export function Subscription() {
</div>
)}
{/* Usage notifications */}
{subscription.isPaid && <BillingUsageNotificationsToggle />}
{/* Usage notifications - hidden from enterprise members (non-admin) */}
{subscription.isPaid && permissions.canViewUsageInfo && <BillingUsageNotificationsToggle />}
{/* Cancel Subscription */}
{permissions.canCancelSubscription && (

View File

@@ -285,6 +285,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const isPro = planType === 'pro'
const isTeam = planType === 'team'
const isEnterprise = planType === 'enterprise'
const isEnterpriseMember = isEnterprise && !userCanManageBilling
const handleUpgradeToPro = useCallback(async () => {
try {
@@ -463,6 +464,18 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
}
}
if (isEnterpriseMember) {
return (
<div className='flex flex-shrink-0 flex-col border-t px-[13.5px] pt-[8px] pb-[10px]'>
<div className='flex h-[18px] items-center'>
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
{PLAN_NAMES[planType]}
</span>
</div>
</div>
)
}
return (
<>
<div

File diff suppressed because it is too large Load Diff

View File

@@ -146,6 +146,7 @@ import { TwilioSMSBlock } from '@/blocks/blocks/twilio'
import { TwilioVoiceBlock } from '@/blocks/blocks/twilio_voice'
import { TypeformBlock } from '@/blocks/blocks/typeform'
import { VariablesBlock } from '@/blocks/blocks/variables'
import { VercelBlock } from '@/blocks/blocks/vercel'
import { VideoGeneratorBlock, VideoGeneratorV2Block } from '@/blocks/blocks/video_generator'
import { VisionBlock, VisionV2Block } from '@/blocks/blocks/vision'
import { WaitBlock } from '@/blocks/blocks/wait'
@@ -330,6 +331,7 @@ export const registry: Record<string, BlockConfig> = {
twilio_sms: TwilioSMSBlock,
twilio_voice: TwilioVoiceBlock,
typeform: TypeformBlock,
vercel: VercelBlock,
variables: VariablesBlock,
video_generator: VideoGeneratorBlock,
video_generator_v2: VideoGeneratorV2Block,

View File

@@ -5532,3 +5532,18 @@ export function OnePasswordIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function VercelIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 256 222'
xmlns='http://www.w3.org/2000/svg'
preserveAspectRatio='xMidYMid'
>
<g transform='translate(19.2 16.63) scale(0.85)'>
<polygon fill='#fafafa' points='128 0 256 221.705007 0 221.705007' />
</g>
</svg>
)
}

View File

@@ -0,0 +1,177 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockLogger, queryClient, useFolderStoreMock, useWorkflowRegistryMock } = vi.hoisted(() => ({
mockLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
queryClient: {
cancelQueries: vi.fn().mockResolvedValue(undefined),
invalidateQueries: vi.fn().mockResolvedValue(undefined),
},
useFolderStoreMock: Object.assign(vi.fn(), {
getState: vi.fn(),
setState: vi.fn(),
}),
useWorkflowRegistryMock: Object.assign(vi.fn(), {
getState: vi.fn(),
setState: vi.fn(),
}),
}))
let folderState: {
folders: Record<string, any>
}
let workflowRegistryState: {
workflows: Record<string, any>
}
vi.mock('@sim/logger', () => ({
createLogger: vi.fn(() => mockLogger),
}))
vi.mock('@tanstack/react-query', () => ({
keepPreviousData: {},
useQuery: vi.fn(),
useQueryClient: vi.fn(() => queryClient),
useMutation: vi.fn((options) => options),
}))
vi.mock('@/stores/folders/store', () => ({
useFolderStore: useFolderStoreMock,
}))
vi.mock('@/stores/workflows/registry/store', () => ({
useWorkflowRegistry: useWorkflowRegistryMock,
}))
vi.mock('@/hooks/queries/workflows', () => ({
workflowKeys: {
list: (workspaceId: string | undefined) => ['workflows', 'list', workspaceId ?? ''],
},
}))
import { useCreateFolder, useDuplicateFolderMutation } from '@/hooks/queries/folders'
function getOptimisticFolderByName(name: string) {
return Object.values(folderState.folders).find((folder: any) => folder.name === name) as
| { sortOrder: number }
| undefined
}
describe('folder optimistic top insertion ordering', () => {
beforeEach(() => {
vi.clearAllMocks()
useFolderStoreMock.getState.mockImplementation(() => folderState)
useFolderStoreMock.setState.mockImplementation((updater: any) => {
if (typeof updater === 'function') {
const next = updater(folderState)
if (next) {
folderState = { ...folderState, ...next }
}
return
}
folderState = { ...folderState, ...updater }
})
useWorkflowRegistryMock.getState.mockImplementation(() => workflowRegistryState)
folderState = {
folders: {
'folder-parent-match': {
id: 'folder-parent-match',
name: 'Existing sibling folder',
userId: 'user-1',
workspaceId: 'ws-1',
parentId: 'parent-1',
color: '#808080',
isExpanded: false,
sortOrder: 5,
createdAt: new Date(),
updatedAt: new Date(),
},
'folder-other-parent': {
id: 'folder-other-parent',
name: 'Other parent folder',
userId: 'user-1',
workspaceId: 'ws-1',
parentId: 'parent-2',
color: '#808080',
isExpanded: false,
sortOrder: -100,
createdAt: new Date(),
updatedAt: new Date(),
},
},
}
workflowRegistryState = {
workflows: {
'workflow-parent-match': {
id: 'workflow-parent-match',
name: 'Existing sibling workflow',
workspaceId: 'ws-1',
folderId: 'parent-1',
sortOrder: 2,
},
'workflow-other-parent': {
id: 'workflow-other-parent',
name: 'Other parent workflow',
workspaceId: 'ws-1',
folderId: 'parent-2',
sortOrder: -50,
},
},
}
})
it('creates folders at top of mixed non-root siblings', async () => {
const mutation = useCreateFolder()
await mutation.onMutate({
workspaceId: 'ws-1',
name: 'New child folder',
parentId: 'parent-1',
})
const optimisticFolder = getOptimisticFolderByName('New child folder')
expect(optimisticFolder).toBeDefined()
expect(optimisticFolder?.sortOrder).toBe(1)
})
it('duplicates folders at top of mixed non-root siblings', async () => {
const mutation = useDuplicateFolderMutation()
await mutation.onMutate({
workspaceId: 'ws-1',
id: 'folder-parent-match',
name: 'Duplicated child folder',
parentId: 'parent-1',
})
const optimisticFolder = getOptimisticFolderByName('Duplicated child folder')
expect(optimisticFolder).toBeDefined()
expect(optimisticFolder?.sortOrder).toBe(1)
})
it('uses source parent scope when duplicate parentId is undefined', async () => {
const mutation = useDuplicateFolderMutation()
await mutation.onMutate({
workspaceId: 'ws-1',
id: 'folder-parent-match',
name: 'Duplicated with inherited parent',
// parentId intentionally omitted to mirror duplicate fallback behavior
})
const optimisticFolder = getOptimisticFolderByName('Duplicated with inherited parent') as
| { parentId: string | null; sortOrder: number }
| undefined
expect(optimisticFolder).toBeDefined()
expect(optimisticFolder?.parentId).toBe('parent-1')
expect(optimisticFolder?.sortOrder).toBe(1)
})
})

View File

@@ -5,9 +5,11 @@ import {
createOptimisticMutationHandlers,
generateTempId,
} from '@/hooks/queries/utils/optimistic-mutation'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { workflowKeys } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('FolderQueries')
@@ -133,40 +135,35 @@ function createFolderMutationHandlers<TVariables extends { workspaceId: string }
})
}
/**
* Calculates the next sort order for a folder in a given parent
*/
function getNextSortOrder(
folders: Record<string, WorkflowFolder>,
workspaceId: string,
parentId: string | null | undefined
): number {
const siblingFolders = Object.values(folders).filter(
(f) => f.workspaceId === workspaceId && f.parentId === (parentId || null)
)
return siblingFolders.reduce((max, f) => Math.max(max, f.sortOrder), -1) + 1
}
export function useCreateFolder() {
const queryClient = useQueryClient()
const handlers = createFolderMutationHandlers<CreateFolderVariables>(
queryClient,
'CreateFolder',
(variables, tempId, previousFolders) => ({
id: tempId,
name: variables.name,
userId: '',
workspaceId: variables.workspaceId,
parentId: variables.parentId || null,
color: variables.color || '#808080',
isExpanded: false,
sortOrder:
variables.sortOrder ??
getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
createdAt: new Date(),
updatedAt: new Date(),
})
(variables, tempId, previousFolders) => {
const currentWorkflows = useWorkflowRegistry.getState().workflows
return {
id: tempId,
name: variables.name,
userId: '',
workspaceId: variables.workspaceId,
parentId: variables.parentId || null,
color: variables.color || '#808080',
isExpanded: false,
sortOrder:
variables.sortOrder ??
getTopInsertionSortOrder(
currentWorkflows,
previousFolders,
variables.workspaceId,
variables.parentId
),
createdAt: new Date(),
updatedAt: new Date(),
}
}
)
return useMutation({
@@ -242,17 +239,25 @@ export function useDuplicateFolderMutation() {
queryClient,
'DuplicateFolder',
(variables, tempId, previousFolders) => {
const currentWorkflows = useWorkflowRegistry.getState().workflows
// Get source folder info if available
const sourceFolder = previousFolders[variables.id]
const targetParentId = variables.parentId ?? sourceFolder?.parentId ?? null
return {
id: tempId,
name: variables.name,
userId: sourceFolder?.userId || '',
workspaceId: variables.workspaceId,
parentId: variables.parentId ?? sourceFolder?.parentId ?? null,
parentId: targetParentId,
color: variables.color || sourceFolder?.color || '#808080',
isExpanded: false,
sortOrder: getNextSortOrder(previousFolders, variables.workspaceId, variables.parentId),
sortOrder: getTopInsertionSortOrder(
currentWorkflows,
previousFolders,
variables.workspaceId,
targetParentId
),
createdAt: new Date(),
updatedAt: new Date(),
}

View File

@@ -0,0 +1,44 @@
interface SortableWorkflow {
workspaceId?: string
folderId?: string | null
sortOrder?: number
}
interface SortableFolder {
workspaceId?: string
parentId?: string | null
sortOrder: number
}
/**
* Calculates the insertion sort order that places a new item at the top of a
* mixed list of folders and workflows within the same parent scope.
*/
export function getTopInsertionSortOrder(
workflows: Record<string, SortableWorkflow>,
folders: Record<string, SortableFolder>,
workspaceId: string,
parentId: string | null | undefined
): number {
const normalizedParentId = parentId ?? null
const siblingWorkflows = Object.values(workflows).filter(
(workflow) =>
workflow.workspaceId === workspaceId && (workflow.folderId ?? null) === normalizedParentId
)
const siblingFolders = Object.values(folders).filter(
(folder) =>
folder.workspaceId === workspaceId && (folder.parentId ?? null) === normalizedParentId
)
const siblingOrders = [
...siblingWorkflows.map((workflow) => workflow.sortOrder ?? 0),
...siblingFolders.map((folder) => folder.sortOrder),
]
if (siblingOrders.length === 0) {
return 0
}
return Math.min(...siblingOrders) - 1
}

View File

@@ -8,6 +8,8 @@ import {
createOptimisticMutationHandlers,
generateTempId,
} from '@/hooks/queries/utils/optimistic-mutation'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
@@ -223,11 +225,13 @@ export function useCreateWorkflow() {
sortOrder = variables.sortOrder
} else {
const currentWorkflows = useWorkflowRegistry.getState().workflows
const targetFolderId = variables.folderId || null
const workflowsInFolder = Object.values(currentWorkflows).filter(
(w) => w.folderId === targetFolderId
const currentFolders = useFolderStore.getState().folders
sortOrder = getTopInsertionSortOrder(
currentWorkflows,
currentFolders,
variables.workspaceId,
variables.folderId
)
sortOrder = workflowsInFolder.reduce((min, w) => Math.min(min, w.sortOrder ?? 0), 1) - 1
}
return {
@@ -323,11 +327,8 @@ export function useDuplicateWorkflowMutation() {
'DuplicateWorkflow',
(variables, tempId) => {
const currentWorkflows = useWorkflowRegistry.getState().workflows
const targetFolderId = variables.folderId || null
const workflowsInFolder = Object.values(currentWorkflows).filter(
(w) => w.folderId === targetFolderId
)
const minSortOrder = workflowsInFolder.reduce((min, w) => Math.min(min, w.sortOrder ?? 0), 1)
const currentFolders = useFolderStore.getState().folders
const targetFolderId = variables.folderId ?? null
return {
id: tempId,
@@ -338,7 +339,12 @@ export function useDuplicateWorkflowMutation() {
color: variables.color,
workspaceId: variables.workspaceId,
folderId: targetFolderId,
sortOrder: minSortOrder - 1,
sortOrder: getTopInsertionSortOrder(
currentWorkflows,
currentFolders,
variables.workspaceId,
targetFolderId
),
}
}
)

View File

@@ -44,7 +44,7 @@ function useAllowedIntegrationsFromEnv() {
*/
function intersectAllowlists(a: string[] | null, b: string[] | null): string[] | null {
if (a === null) return b
if (b === null) return a
if (b === null) return a.map((i) => i.toLowerCase())
return a.map((i) => i.toLowerCase()).filter((i) => b.includes(i))
}

View File

@@ -24,12 +24,18 @@ export const AuditAction = {
CHAT_UPDATED: 'chat.updated',
CHAT_DELETED: 'chat.deleted',
// Billing
CREDIT_PURCHASED: 'credit.purchased',
// Credential Sets
CREDENTIAL_SET_CREATED: 'credential_set.created',
CREDENTIAL_SET_UPDATED: 'credential_set.updated',
CREDENTIAL_SET_DELETED: 'credential_set.deleted',
CREDENTIAL_SET_MEMBER_REMOVED: 'credential_set_member.removed',
CREDENTIAL_SET_MEMBER_LEFT: 'credential_set_member.left',
CREDENTIAL_SET_INVITATION_CREATED: 'credential_set_invitation.created',
CREDENTIAL_SET_INVITATION_ACCEPTED: 'credential_set_invitation.accepted',
CREDENTIAL_SET_INVITATION_RESENT: 'credential_set_invitation.resent',
CREDENTIAL_SET_INVITATION_REVOKED: 'credential_set_invitation.revoked',
// Documents
@@ -81,6 +87,9 @@ export const AuditAction = {
// OAuth
OAUTH_DISCONNECTED: 'oauth.disconnected',
// Password
PASSWORD_RESET: 'password.reset',
// Organizations
ORGANIZATION_CREATED: 'organization.created',
ORGANIZATION_UPDATED: 'organization.updated',
@@ -103,17 +112,22 @@ export const AuditAction = {
// Schedules
SCHEDULE_UPDATED: 'schedule.updated',
// Templates
TEMPLATE_CREATED: 'template.created',
TEMPLATE_UPDATED: 'template.updated',
TEMPLATE_DELETED: 'template.deleted',
// Webhooks
WEBHOOK_CREATED: 'webhook.created',
WEBHOOK_DELETED: 'webhook.deleted',
// Workflows
WORKFLOW_CREATED: 'workflow.created',
WORKFLOW_UPDATED: 'workflow.updated',
WORKFLOW_DELETED: 'workflow.deleted',
WORKFLOW_DEPLOYED: 'workflow.deployed',
WORKFLOW_UNDEPLOYED: 'workflow.undeployed',
WORKFLOW_DUPLICATED: 'workflow.duplicated',
WORKFLOW_DEPLOYMENT_ACTIVATED: 'workflow.deployment_activated',
WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted',
WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated',
@@ -130,6 +144,7 @@ export type AuditActionType = (typeof AuditAction)[keyof typeof AuditAction]
*/
export const AuditResourceType = {
API_KEY: 'api_key',
BILLING: 'billing',
BYOK_KEY: 'byok_key',
CHAT: 'chat',
CREDENTIAL_SET: 'credential_set',
@@ -143,8 +158,10 @@ export const AuditResourceType = {
NOTIFICATION: 'notification',
OAUTH: 'oauth',
ORGANIZATION: 'organization',
PASSWORD: 'password',
PERMISSION_GROUP: 'permission_group',
SCHEDULE: 'schedule',
TEMPLATE: 'template',
WEBHOOK: 'webhook',
WORKFLOW: 'workflow',
WORKSPACE: 'workspace',

View File

@@ -466,7 +466,7 @@ export const auth = betterAuth({
sendVerificationOnSignUp: isEmailVerificationEnabled, // Auto-send verification OTP on signup when verification is required
throwOnMissingCredentials: true,
throwOnInvalidCredentials: true,
sendResetPassword: async ({ user, url, token }, request) => {
sendResetPassword: async ({ user, url, token }, ctx) => {
const username = user.name || ''
const html = await renderPasswordResetEmail(username, url)
@@ -483,6 +483,17 @@ export const auth = betterAuth({
throw new Error(`Failed to send reset password email: ${result.message}`)
}
},
onPasswordReset: async ({ user: resetUser }) => {
const { AuditAction, AuditResourceType, recordAudit } = await import('@/lib/audit/log')
recordAudit({
actorId: resetUser.id,
actorName: resetUser.name,
actorEmail: resetUser.email,
action: AuditAction.PASSWORD_RESET,
resourceType: AuditResourceType.PASSWORD,
description: 'Password reset completed',
})
},
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
@@ -531,7 +542,7 @@ export const auth = betterAuth({
plugins: [
nextCookies(),
oneTimeToken({
expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats
expiresIn: 24 * 60, // 24 hours in minutes - Socket.IO handles connection persistence with heartbeats
}),
customSession(async ({ user, session }) => ({
user,
@@ -2865,9 +2876,9 @@ export const auth = betterAuth({
return hasTeamPlan
},
organizationCreation: {
afterCreate: async ({ organization, user }) => {
logger.info('[organizationCreation.afterCreate] Organization created', {
organizationHooks: {
afterCreateOrganization: async ({ organization, member, user }) => {
logger.info('[organizationHooks.afterCreateOrganization] Organization created', {
organizationId: organization.id,
creatorId: user.id,
})

View File

@@ -0,0 +1,197 @@
/**
* @vitest-environment node
*/
import { mockConsoleLogger, setupCommonApiMocks } from '@sim/testing'
import { drizzleOrmMock } from '@sim/testing/mocks'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
const mockGetUserEntityPermissions = vi.fn()
const { mockDb } = vi.hoisted(() => ({
mockDb: {
transaction: vi.fn(),
},
}))
vi.mock('drizzle-orm', () => ({
...drizzleOrmMock,
min: vi.fn((field) => ({ type: 'min', field })),
}))
vi.mock('@/lib/workflows/utils', () => ({
authorizeWorkflowByWorkspacePermission: (...args: unknown[]) =>
mockAuthorizeWorkflowByWorkspacePermission(...args),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: (...args: unknown[]) => mockGetUserEntityPermissions(...args),
}))
vi.mock('@sim/db/schema', () => ({
workflow: {
id: 'id',
workspaceId: 'workspaceId',
folderId: 'folderId',
sortOrder: 'sortOrder',
variables: 'variables',
},
workflowFolder: {
workspaceId: 'workspaceId',
parentId: 'parentId',
sortOrder: 'sortOrder',
},
workflowBlocks: {
workflowId: 'workflowId',
},
workflowEdges: {
workflowId: 'workflowId',
},
workflowSubflows: {
workflowId: 'workflowId',
},
}))
vi.mock('@sim/db', () => ({
db: mockDb,
}))
import { duplicateWorkflow } from './duplicate'
function createMockTx(
selectResults: unknown[],
onWorkflowInsert?: (values: Record<string, unknown>) => void
) {
let selectCallCount = 0
const select = vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockImplementation(() => {
const result = selectResults[selectCallCount++] ?? []
if (selectCallCount === 1) {
return {
limit: vi.fn().mockResolvedValue(result),
}
}
return Promise.resolve(result)
}),
}),
}))
const insert = vi.fn().mockReturnValue({
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
onWorkflowInsert?.(values)
return Promise.resolve(undefined)
}),
})
const update = vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
})
return {
select,
insert,
update,
}
}
describe('duplicateWorkflow ordering', () => {
beforeEach(() => {
setupCommonApiMocks()
mockConsoleLogger()
vi.clearAllMocks()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue('new-workflow-id'),
})
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true })
mockGetUserEntityPermissions.mockResolvedValue('write')
})
it('uses mixed-sibling top insertion sort order', async () => {
let insertedWorkflowValues: Record<string, unknown> | null = null
const tx = createMockTx(
[
[
{
id: 'source-workflow-id',
workspaceId: 'workspace-123',
folderId: null,
description: 'source',
color: '#000000',
variables: {},
},
],
[{ minOrder: 5 }],
[{ minOrder: 2 }],
[],
[],
[],
],
(values) => {
insertedWorkflowValues = values
}
)
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
callback(tx)
)
const result = await duplicateWorkflow({
sourceWorkflowId: 'source-workflow-id',
userId: 'user-123',
name: 'Duplicated',
workspaceId: 'workspace-123',
folderId: null,
requestId: 'req-1',
})
expect(result.sortOrder).toBe(1)
expect(insertedWorkflowValues?.sortOrder).toBe(1)
})
it('defaults to sortOrder 0 when target has no siblings', async () => {
let insertedWorkflowValues: Record<string, unknown> | null = null
const tx = createMockTx(
[
[
{
id: 'source-workflow-id',
workspaceId: 'workspace-123',
folderId: null,
description: 'source',
color: '#000000',
variables: {},
},
],
[],
[],
[],
[],
[],
],
(values) => {
insertedWorkflowValues = values
}
)
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
callback(tx)
)
const result = await duplicateWorkflow({
sourceWorkflowId: 'source-workflow-id',
userId: 'user-123',
name: 'Duplicated',
workspaceId: 'workspace-123',
folderId: null,
requestId: 'req-2',
})
expect(result.sortOrder).toBe(0)
expect(insertedWorkflowValues?.sortOrder).toBe(0)
})
})

View File

@@ -1,5 +1,11 @@
import { db } from '@sim/db'
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db/schema'
import {
workflow,
workflowBlocks,
workflowEdges,
workflowFolder,
workflowSubflows,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, min } from 'drizzle-orm'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
@@ -132,15 +138,31 @@ export async function duplicateWorkflow(
throw new Error('Write or admin access required for target workspace')
}
const targetFolderId = folderId !== undefined ? folderId : source.folderId
const folderCondition = targetFolderId
const workflowParentCondition = targetFolderId
? eq(workflow.folderId, targetFolderId)
: isNull(workflow.folderId)
const folderParentCondition = targetFolderId
? eq(workflowFolder.parentId, targetFolderId)
: isNull(workflowFolder.parentId)
const [minResult] = await tx
.select({ minOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, targetWorkspaceId), folderCondition))
const sortOrder = (minResult?.minOrder ?? 1) - 1
const [[workflowMinResult], [folderMinResult]] = await Promise.all([
tx
.select({ minOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)),
tx
.select({ minOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)),
])
const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
// Mapping from old variable IDs to new variable IDs (populated during variable duplication)
const varIdMapping = new Map<string, string>()

View File

@@ -1,6 +1,6 @@
import type { NextConfig } from 'next'
import { env, getEnv, isTruthy } from './lib/core/config/env'
import { isDev, isHosted } from './lib/core/config/feature-flags'
import { isDev } from './lib/core/config/feature-flags'
import {
getFormEmbedCSPPolicy,
getMainCSPPolicy,
@@ -306,34 +306,15 @@ const nextConfig: NextConfig = {
}
)
// Only enable domain redirects for the hosted version
if (isHosted) {
redirects.push(
{
source: '/((?!api|_next|_vercel|favicon|static|ingest|.*\\..*).*)',
destination: 'https://www.sim.ai/$1',
permanent: true,
has: [{ type: 'host' as const, value: 'simstudio.ai' }],
},
{
source: '/((?!api|_next|_vercel|favicon|static|ingest|.*\\..*).*)',
destination: 'https://www.sim.ai/$1',
permanent: true,
has: [{ type: 'host' as const, value: 'www.simstudio.ai' }],
}
)
}
// Beluga campaign short link tracking
if (isHosted) {
redirects.push({
return redirects
},
async rewrites() {
return [
{
source: '/r/:shortCode',
destination: 'https://go.trybeluga.ai/:shortCode',
permanent: false,
})
}
return redirects
},
]
},
}

View File

@@ -35,8 +35,8 @@
"@aws-sdk/s3-request-presigner": "^3.779.0",
"@azure/communication-email": "1.0.0",
"@azure/storage-blob": "12.27.0",
"@better-auth/sso": "1.3.12",
"@better-auth/stripe": "1.3.12",
"@better-auth/sso": "1.4.18",
"@better-auth/stripe": "1.4.18",
"@browserbasehq/stagehand": "^3.0.5",
"@cerebras/cerebras_cloud_sdk": "^1.23.0",
"@e2b/code-interpreter": "^2.0.0",
@@ -82,7 +82,7 @@
"@trigger.dev/sdk": "4.1.2",
"@types/react-window": "2.0.0",
"@types/three": "0.177.0",
"better-auth": "1.3.12",
"better-auth": "1.4.18",
"binary-extensions": "^2.0.0",
"browser-image-compression": "^2.0.2",
"chalk": "5.6.2",
@@ -125,7 +125,7 @@
"mysql2": "3.14.3",
"nanoid": "^3.3.7",
"neo4j-driver": "6.0.1",
"next": "16.1.0-canary.21",
"next": "16.1.6",
"next-mdx-remote": "^5.0.0",
"next-runtime-env": "3.3.0",
"next-themes": "^0.4.6",
@@ -208,8 +208,8 @@
"sharp"
],
"overrides": {
"next": "16.1.0-canary.21",
"@next/env": "16.1.0-canary.21",
"next": "16.1.6",
"@next/env": "16.1.6",
"drizzle-orm": "^0.44.5",
"postgres": "^3.4.5"
}

View File

@@ -232,6 +232,7 @@ async function flushSubblockUpdate(
}
let updateSuccessful = false
let blockLocked = false
await db.transaction(async (tx) => {
const [block] = await tx
.select({
@@ -250,6 +251,7 @@ async function flushSubblockUpdate(
// Check if block is locked directly
if (block.locked) {
logger.info(`Skipping subblock update - block ${blockId} is locked`)
blockLocked = true
return
}
@@ -266,6 +268,7 @@ async function flushSubblockUpdate(
if (parentBlock?.locked) {
logger.info(`Skipping subblock update - parent ${parentId} is locked`)
blockLocked = true
return
}
}
@@ -308,6 +311,13 @@ async function flushSubblockUpdate(
serverTimestamp: Date.now(),
})
})
} else if (blockLocked) {
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-confirmed', {
operationId: opId,
serverTimestamp: Date.now(),
})
})
} else {
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-failed', {

View File

@@ -1693,6 +1693,58 @@ import {
typeformUpdateFormTool,
} from '@/tools/typeform'
import type { ToolConfig } from '@/tools/types'
import {
vercelAddDomainTool,
vercelAddProjectDomainTool,
vercelCancelDeploymentTool,
vercelCreateAliasTool,
vercelCreateCheckTool,
vercelCreateDeploymentTool,
vercelCreateDnsRecordTool,
vercelCreateEdgeConfigTool,
vercelCreateEnvVarTool,
vercelCreateProjectTool,
vercelCreateWebhookTool,
vercelDeleteAliasTool,
vercelDeleteDeploymentTool,
vercelDeleteDnsRecordTool,
vercelDeleteDomainTool,
vercelDeleteEnvVarTool,
vercelDeleteProjectTool,
vercelDeleteWebhookTool,
vercelGetAliasTool,
vercelGetCheckTool,
vercelGetDeploymentEventsTool,
vercelGetDeploymentTool,
vercelGetDomainConfigTool,
vercelGetDomainTool,
vercelGetEdgeConfigItemsTool,
vercelGetEdgeConfigTool,
vercelGetEnvVarsTool,
vercelGetProjectTool,
vercelGetTeamTool,
vercelGetUserTool,
vercelListAliasesTool,
vercelListChecksTool,
vercelListDeploymentFilesTool,
vercelListDeploymentsTool,
vercelListDnsRecordsTool,
vercelListDomainsTool,
vercelListEdgeConfigsTool,
vercelListProjectDomainsTool,
vercelListProjectsTool,
vercelListTeamMembersTool,
vercelListTeamsTool,
vercelListWebhooksTool,
vercelPauseProjectTool,
vercelRemoveProjectDomainTool,
vercelRerequestCheckTool,
vercelUnpauseProjectTool,
vercelUpdateCheckTool,
vercelUpdateEdgeConfigItemsTool,
vercelUpdateEnvVarTool,
vercelUpdateProjectTool,
} from '@/tools/vercel'
import {
falaiVideoTool,
lumaVideoTool,
@@ -2700,6 +2752,66 @@ export const tools: Record<string, ToolConfig> = {
trello_update_card: trelloUpdateCardTool,
trello_get_actions: trelloGetActionsTool,
trello_add_comment: trelloAddCommentTool,
// Vercel - Deployments
vercel_list_deployments: vercelListDeploymentsTool,
vercel_get_deployment: vercelGetDeploymentTool,
vercel_create_deployment: vercelCreateDeploymentTool,
vercel_cancel_deployment: vercelCancelDeploymentTool,
vercel_delete_deployment: vercelDeleteDeploymentTool,
vercel_get_deployment_events: vercelGetDeploymentEventsTool,
vercel_list_deployment_files: vercelListDeploymentFilesTool,
// Vercel - Projects
vercel_list_projects: vercelListProjectsTool,
vercel_get_project: vercelGetProjectTool,
vercel_create_project: vercelCreateProjectTool,
vercel_update_project: vercelUpdateProjectTool,
vercel_delete_project: vercelDeleteProjectTool,
vercel_pause_project: vercelPauseProjectTool,
vercel_unpause_project: vercelUnpauseProjectTool,
vercel_list_project_domains: vercelListProjectDomainsTool,
vercel_add_project_domain: vercelAddProjectDomainTool,
vercel_remove_project_domain: vercelRemoveProjectDomainTool,
// Vercel - Environment Variables
vercel_get_env_vars: vercelGetEnvVarsTool,
vercel_create_env_var: vercelCreateEnvVarTool,
vercel_update_env_var: vercelUpdateEnvVarTool,
vercel_delete_env_var: vercelDeleteEnvVarTool,
// Vercel - Domains
vercel_list_domains: vercelListDomainsTool,
vercel_get_domain: vercelGetDomainTool,
vercel_add_domain: vercelAddDomainTool,
vercel_delete_domain: vercelDeleteDomainTool,
vercel_get_domain_config: vercelGetDomainConfigTool,
// Vercel - DNS
vercel_list_dns_records: vercelListDnsRecordsTool,
vercel_create_dns_record: vercelCreateDnsRecordTool,
vercel_delete_dns_record: vercelDeleteDnsRecordTool,
// Vercel - Aliases
vercel_list_aliases: vercelListAliasesTool,
vercel_get_alias: vercelGetAliasTool,
vercel_create_alias: vercelCreateAliasTool,
vercel_delete_alias: vercelDeleteAliasTool,
// Vercel - Edge Config
vercel_list_edge_configs: vercelListEdgeConfigsTool,
vercel_get_edge_config: vercelGetEdgeConfigTool,
vercel_create_edge_config: vercelCreateEdgeConfigTool,
vercel_get_edge_config_items: vercelGetEdgeConfigItemsTool,
vercel_update_edge_config_items: vercelUpdateEdgeConfigItemsTool,
// Vercel - Teams & User
vercel_list_teams: vercelListTeamsTool,
vercel_get_team: vercelGetTeamTool,
vercel_list_team_members: vercelListTeamMembersTool,
vercel_get_user: vercelGetUserTool,
// Webhooks
vercel_list_webhooks: vercelListWebhooksTool,
vercel_create_webhook: vercelCreateWebhookTool,
vercel_delete_webhook: vercelDeleteWebhookTool,
// Checks
vercel_create_check: vercelCreateCheckTool,
vercel_get_check: vercelGetCheckTool,
vercel_list_checks: vercelListChecksTool,
vercel_update_check: vercelUpdateCheckTool,
vercel_rerequest_check: vercelRerequestCheckTool,
twilio_send_sms: sendSMSTool,
twilio_voice_make_call: makeCallTool,
twilio_voice_list_calls: listCallsTool,

View File

@@ -0,0 +1,84 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelAddDomainParams, VercelAddDomainResponse } from '@/tools/vercel/types'
export const vercelAddDomainTool: ToolConfig<VercelAddDomainParams, VercelAddDomainResponse> = {
id: 'vercel_add_domain',
name: 'Vercel Add Domain',
description: 'Add a new domain to a Vercel account or team',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The domain name to add',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelAddDomainParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v7/domains${qs ? `?${qs}` : ''}`
},
method: 'POST',
headers: (params: VercelAddDomainParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: VercelAddDomainParams) => ({
method: 'add',
name: params.name.trim(),
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const d = data.domain ?? data
return {
success: true,
output: {
id: d.id ?? null,
name: d.name ?? null,
verified: d.verified ?? false,
createdAt: d.createdAt ?? null,
serviceType: d.serviceType ?? null,
nameservers: d.nameservers ?? [],
intendedNameservers: d.intendedNameservers ?? [],
},
}
},
outputs: {
id: { type: 'string', description: 'Domain ID' },
name: { type: 'string', description: 'Domain name' },
verified: { type: 'boolean', description: 'Whether domain is verified' },
createdAt: { type: 'number', description: 'Creation timestamp' },
serviceType: { type: 'string', description: 'Service type (zeit.world, external, na)' },
nameservers: {
type: 'array',
description: 'Current nameservers',
items: { type: 'string' },
},
intendedNameservers: {
type: 'array',
description: 'Intended nameservers',
items: { type: 'string' },
},
},
}

View File

@@ -0,0 +1,113 @@
import type { ToolConfig } from '@/tools/types'
import type {
VercelAddProjectDomainParams,
VercelAddProjectDomainResponse,
} from '@/tools/vercel/types'
export const vercelAddProjectDomainTool: ToolConfig<
VercelAddProjectDomainParams,
VercelAddProjectDomainResponse
> = {
id: 'vercel_add_project_domain',
name: 'Vercel Add Project Domain',
description: 'Add a domain to a Vercel project',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
projectId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or name',
},
domain: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Domain name to add',
},
redirect: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Target domain for redirect',
},
redirectStatusCode: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'HTTP status code for redirect (301, 302, 307, 308)',
},
gitBranch: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Git branch to link the domain to',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelAddProjectDomainParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/domains${qs ? `?${qs}` : ''}`
},
method: 'POST',
headers: (params: VercelAddProjectDomainParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: VercelAddProjectDomainParams) => {
const body: Record<string, unknown> = { name: params.domain.trim() }
if (params.redirect) body.redirect = params.redirect.trim()
if (params.redirectStatusCode) body.redirectStatusCode = params.redirectStatusCode
if (params.gitBranch) body.gitBranch = params.gitBranch.trim()
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
name: data.name,
apexName: data.apexName,
verified: data.verified,
gitBranch: data.gitBranch ?? null,
redirect: data.redirect ?? null,
redirectStatusCode: data.redirectStatusCode ?? null,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
},
}
},
outputs: {
name: { type: 'string', description: 'Domain name' },
apexName: { type: 'string', description: 'Apex domain name' },
verified: { type: 'boolean', description: 'Whether the domain is verified' },
gitBranch: { type: 'string', description: 'Git branch for the domain', optional: true },
redirect: { type: 'string', description: 'Redirect target domain', optional: true },
redirectStatusCode: {
type: 'number',
description: 'HTTP status code for redirect (301, 302, 307, 308)',
optional: true,
},
createdAt: { type: 'number', description: 'Creation timestamp' },
updatedAt: { type: 'number', description: 'Last updated timestamp' },
},
}

View File

@@ -0,0 +1,83 @@
import type { ToolConfig } from '@/tools/types'
import type {
VercelCancelDeploymentParams,
VercelCancelDeploymentResponse,
} from '@/tools/vercel/types'
export const vercelCancelDeploymentTool: ToolConfig<
VercelCancelDeploymentParams,
VercelCancelDeploymentResponse
> = {
id: 'vercel_cancel_deployment',
name: 'Vercel Cancel Deployment',
description: 'Cancel a running Vercel deployment',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
deploymentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The deployment ID to cancel',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelCancelDeploymentParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v12/deployments/${params.deploymentId.trim()}/cancel${qs ? `?${qs}` : ''}`
},
method: 'PATCH',
headers: (params: VercelCancelDeploymentParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
id: data.id ?? data.uid,
name: data.name ?? null,
state: data.readyState ?? data.state ?? 'CANCELED',
url: data.url ?? null,
},
}
},
outputs: {
id: {
type: 'string',
description: 'Deployment ID',
},
name: {
type: 'string',
description: 'Deployment name',
},
state: {
type: 'string',
description: 'Deployment state after cancellation',
},
url: {
type: 'string',
description: 'Deployment URL',
},
},
}

View File

@@ -0,0 +1,87 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelCreateAliasParams, VercelCreateAliasResponse } from '@/tools/vercel/types'
export const vercelCreateAliasTool: ToolConfig<VercelCreateAliasParams, VercelCreateAliasResponse> =
{
id: 'vercel_create_alias',
name: 'Vercel Create Alias',
description: 'Assign an alias (domain/subdomain) to a deployment',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
deploymentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Deployment ID to assign the alias to',
},
alias: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The domain or subdomain to assign as an alias',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelCreateAliasParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v2/deployments/${params.deploymentId.trim()}/aliases${qs ? `?${qs}` : ''}`
},
method: 'POST',
headers: (params: VercelCreateAliasParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: VercelCreateAliasParams) => ({
alias: params.alias.trim(),
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
uid: data.uid ?? null,
alias: data.alias ?? null,
created: data.created ?? null,
oldDeploymentId: data.oldDeploymentId ?? null,
},
}
},
outputs: {
uid: {
type: 'string',
description: 'Alias ID',
},
alias: {
type: 'string',
description: 'Alias hostname',
},
created: {
type: 'string',
description: 'Creation timestamp as ISO 8601 date-time string',
},
oldDeploymentId: {
type: 'string',
description: 'ID of the previously aliased deployment, if the alias was reassigned',
},
},
}

View File

@@ -0,0 +1,141 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelCheckResponse, VercelCreateCheckParams } from '@/tools/vercel/types'
export const vercelCreateCheckTool: ToolConfig<VercelCreateCheckParams, VercelCheckResponse> = {
id: 'vercel_create_check',
name: 'Vercel Create Check',
description: 'Create a new deployment check',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
deploymentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Deployment ID to create the check for',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Name of the check (max 100 characters)',
},
blocking: {
type: 'boolean',
required: true,
visibility: 'user-or-llm',
description: 'Whether the check blocks the deployment',
},
path: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Page path being checked',
},
detailsUrl: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'URL with details about the check',
},
externalId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'External identifier for the check',
},
rerequestable: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether the check can be rerequested',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelCreateCheckParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks${qs ? `?${qs}` : ''}`
},
method: 'POST',
headers: (params: VercelCreateCheckParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: VercelCreateCheckParams) => {
const body: Record<string, unknown> = {
name: params.name.trim(),
blocking: params.blocking,
}
if (params.path) body.path = params.path
if (params.detailsUrl) body.detailsUrl = params.detailsUrl
if (params.externalId) body.externalId = params.externalId
if (params.rerequestable !== undefined) body.rerequestable = params.rerequestable
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
id: data.id,
name: data.name,
status: data.status ?? 'registered',
conclusion: data.conclusion ?? null,
blocking: data.blocking ?? false,
deploymentId: data.deploymentId,
integrationId: data.integrationId ?? null,
externalId: data.externalId ?? null,
detailsUrl: data.detailsUrl ?? null,
path: data.path ?? null,
rerequestable: data.rerequestable ?? false,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
startedAt: data.startedAt ?? null,
completedAt: data.completedAt ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Check ID' },
name: { type: 'string', description: 'Check name' },
status: { type: 'string', description: 'Check status: registered, running, or completed' },
conclusion: {
type: 'string',
description: 'Check conclusion: canceled, failed, neutral, succeeded, skipped, or stale',
optional: true,
},
blocking: { type: 'boolean', description: 'Whether the check blocks the deployment' },
deploymentId: { type: 'string', description: 'Associated deployment ID' },
integrationId: { type: 'string', description: 'Associated integration ID', optional: true },
externalId: { type: 'string', description: 'External identifier', optional: true },
detailsUrl: { type: 'string', description: 'URL with details about the check', optional: true },
path: { type: 'string', description: 'Page path being checked', optional: true },
rerequestable: { type: 'boolean', description: 'Whether the check can be rerequested' },
createdAt: { type: 'number', description: 'Creation timestamp in milliseconds' },
updatedAt: { type: 'number', description: 'Last update timestamp in milliseconds' },
startedAt: { type: 'number', description: 'Start timestamp in milliseconds', optional: true },
completedAt: {
type: 'number',
description: 'Completion timestamp in milliseconds',
optional: true,
},
},
}

View File

@@ -0,0 +1,136 @@
import type { ToolConfig } from '@/tools/types'
import type {
VercelCreateDeploymentParams,
VercelCreateDeploymentResponse,
} from '@/tools/vercel/types'
export const vercelCreateDeploymentTool: ToolConfig<
VercelCreateDeploymentParams,
VercelCreateDeploymentResponse
> = {
id: 'vercel_create_deployment',
name: 'Vercel Create Deployment',
description: 'Create a new deployment or redeploy an existing one',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project name for the deployment',
},
project: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Project ID (overrides name for project lookup)',
},
deploymentId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Existing deployment ID to redeploy',
},
target: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Target environment: production, staging, or a custom environment identifier',
},
gitSource: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'JSON string defining the Git Repository source to deploy (e.g. {"type":"github","repo":"owner/repo","ref":"main"})',
},
forceNew: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Forces a new deployment even if there is a previous similar deployment (0 or 1)',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelCreateDeploymentParams) => {
const query = new URLSearchParams()
if (params.forceNew) query.set('forceNew', params.forceNew)
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v13/deployments${qs ? `?${qs}` : ''}`
},
method: 'POST',
headers: (params: VercelCreateDeploymentParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: VercelCreateDeploymentParams) => {
const body: Record<string, any> = {
name: params.name.trim(),
}
if (params.project) body.project = params.project.trim()
if (params.deploymentId) body.deploymentId = params.deploymentId.trim()
if (params.target) body.target = params.target
if (params.gitSource) {
try {
body.gitSource = JSON.parse(params.gitSource)
} catch {
body.gitSource = params.gitSource
}
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
id: data.id,
name: data.name,
url: data.url ?? '',
readyState: data.readyState ?? 'QUEUED',
projectId: data.projectId ?? '',
createdAt: data.createdAt ?? data.created,
alias: data.alias ?? [],
target: data.target ?? null,
inspectorUrl: data.inspectorUrl ?? '',
},
}
},
outputs: {
id: { type: 'string', description: 'Deployment ID' },
name: { type: 'string', description: 'Deployment name' },
url: { type: 'string', description: 'Unique deployment URL' },
readyState: {
type: 'string',
description: 'Deployment ready state: QUEUED, BUILDING, ERROR, INITIALIZING, READY, CANCELED',
},
projectId: { type: 'string', description: 'Associated project ID' },
createdAt: { type: 'number', description: 'Creation timestamp in milliseconds' },
alias: {
type: 'array',
description: 'Assigned aliases',
items: { type: 'string', description: 'Alias domain' },
},
target: { type: 'string', description: 'Target environment', optional: true },
inspectorUrl: { type: 'string', description: 'Vercel inspector URL' },
},
}

View File

@@ -0,0 +1,107 @@
import type { ToolConfig } from '@/tools/types'
import type {
VercelCreateDnsRecordParams,
VercelCreateDnsRecordResponse,
} from '@/tools/vercel/types'
export const vercelCreateDnsRecordTool: ToolConfig<
VercelCreateDnsRecordParams,
VercelCreateDnsRecordResponse
> = {
id: 'vercel_create_dns_record',
name: 'Vercel Create DNS Record',
description: 'Create a DNS record for a domain in a Vercel account',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
domain: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The domain name to create the record for',
},
recordName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The subdomain or record name',
},
recordType: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'DNS record type (A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, SRV, TXT, NS)',
},
value: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The value of the DNS record',
},
ttl: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Time to live in seconds',
},
mxPriority: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Priority for MX records',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelCreateDnsRecordParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v2/domains/${params.domain.trim()}/records${qs ? `?${qs}` : ''}`
},
method: 'POST',
headers: (params: VercelCreateDnsRecordParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: VercelCreateDnsRecordParams) => {
const body: Record<string, unknown> = {
name: params.recordName.trim(),
type: params.recordType.trim(),
value: params.value.trim(),
}
if (params.ttl != null) body.ttl = params.ttl
if (params.mxPriority != null) body.mxPriority = params.mxPriority
return body
},
},
transformResponse: async (response: Response) => {
const d = await response.json()
return {
success: true,
output: {
uid: d.uid ?? null,
updated: d.updated ?? null,
},
}
},
outputs: {
uid: { type: 'string', description: 'The DNS record ID' },
updated: { type: 'number', description: 'Timestamp of the update' },
},
}

View File

@@ -0,0 +1,106 @@
import type { ToolConfig } from '@/tools/types'
import type {
VercelCreateEdgeConfigParams,
VercelCreateEdgeConfigResponse,
} from '@/tools/vercel/types'
export const vercelCreateEdgeConfigTool: ToolConfig<
VercelCreateEdgeConfigParams,
VercelCreateEdgeConfigResponse
> = {
id: 'vercel_create_edge_config',
name: 'Vercel Create Edge Config',
description: 'Create a new Edge Config store',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
slug: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The name/slug for the new Edge Config',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelCreateEdgeConfigParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/edge-config${qs ? `?${qs}` : ''}`
},
method: 'POST',
headers: (params: VercelCreateEdgeConfigParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: VercelCreateEdgeConfigParams) => ({
slug: params.slug.trim(),
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
id: data.id ?? null,
slug: data.slug ?? null,
ownerId: data.ownerId ?? null,
digest: data.digest ?? null,
createdAt: data.createdAt ?? null,
updatedAt: data.updatedAt ?? null,
itemCount: data.itemCount ?? 0,
sizeInBytes: data.sizeInBytes ?? 0,
},
}
},
outputs: {
id: {
type: 'string',
description: 'Edge Config ID',
},
slug: {
type: 'string',
description: 'Edge Config slug',
},
ownerId: {
type: 'string',
description: 'Owner ID',
},
digest: {
type: 'string',
description: 'Content digest hash',
},
createdAt: {
type: 'number',
description: 'Creation timestamp',
},
updatedAt: {
type: 'number',
description: 'Last update timestamp',
},
itemCount: {
type: 'number',
description: 'Number of items',
},
sizeInBytes: {
type: 'number',
description: 'Size in bytes',
},
},
}

View File

@@ -0,0 +1,145 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelCreateEnvVarParams, VercelCreateEnvVarResponse } from '@/tools/vercel/types'
export const vercelCreateEnvVarTool: ToolConfig<
VercelCreateEnvVarParams,
VercelCreateEnvVarResponse
> = {
id: 'vercel_create_env_var',
name: 'Vercel Create Environment Variable',
description: 'Create an environment variable for a Vercel project',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
projectId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or name',
},
key: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Environment variable name',
},
value: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Environment variable value',
},
target: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Comma-separated list of target environments (production, preview, development)',
},
type: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Variable type: system, secret, encrypted, plain, or sensitive (default: plain)',
},
gitBranch: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Git branch to associate with the variable (requires target to include preview)',
},
comment: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Comment to add context to the variable (max 500 characters)',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelCreateEnvVarParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/env${qs ? `?${qs}` : ''}`
},
method: 'POST',
headers: (params: VercelCreateEnvVarParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: VercelCreateEnvVarParams) => {
const body: Record<string, unknown> = {
key: params.key,
value: params.value,
target: params.target.split(',').map((t) => t.trim()),
type: params.type || 'plain',
}
if (params.gitBranch) body.gitBranch = params.gitBranch
if (params.comment) body.comment = params.comment
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const env = data.created ?? data
return {
success: true,
output: {
id: env.id,
key: env.key,
value: env.value ?? '',
type: env.type ?? 'plain',
target: env.target ?? [],
gitBranch: env.gitBranch ?? null,
comment: env.comment ?? null,
},
}
},
outputs: {
id: {
type: 'string',
description: 'Environment variable ID',
},
key: {
type: 'string',
description: 'Variable name',
},
value: {
type: 'string',
description: 'Variable value',
},
type: {
type: 'string',
description: 'Variable type (secret, system, encrypted, plain, sensitive)',
},
target: {
type: 'array',
description: 'Target environments',
items: { type: 'string', description: 'Environment name' },
},
gitBranch: {
type: 'string',
description: 'Git branch filter',
optional: true,
},
comment: {
type: 'string',
description: 'Comment providing context for the variable',
optional: true,
},
},
}

View File

@@ -0,0 +1,108 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelCreateProjectParams, VercelCreateProjectResponse } from '@/tools/vercel/types'
export const vercelCreateProjectTool: ToolConfig<
VercelCreateProjectParams,
VercelCreateProjectResponse
> = {
id: 'vercel_create_project',
name: 'Vercel Create Project',
description: 'Create a new Vercel project',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project name',
},
framework: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Project framework (e.g. nextjs, remix, vite)',
},
gitRepository: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Git repository connection object with type and repo',
},
buildCommand: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Custom build command',
},
outputDirectory: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Custom output directory',
},
installCommand: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Custom install command',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelCreateProjectParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v11/projects${qs ? `?${qs}` : ''}`
},
method: 'POST',
headers: (params: VercelCreateProjectParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: VercelCreateProjectParams) => {
const body: Record<string, unknown> = { name: params.name.trim() }
if (params.framework) body.framework = params.framework.trim()
if (params.gitRepository) body.gitRepository = params.gitRepository
if (params.buildCommand) body.buildCommand = params.buildCommand.trim()
if (params.outputDirectory) body.outputDirectory = params.outputDirectory.trim()
if (params.installCommand) body.installCommand = params.installCommand.trim()
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
id: data.id,
name: data.name,
framework: data.framework ?? null,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
},
}
},
outputs: {
id: { type: 'string', description: 'Project ID' },
name: { type: 'string', description: 'Project name' },
framework: { type: 'string', description: 'Project framework', optional: true },
createdAt: { type: 'number', description: 'Creation timestamp' },
updatedAt: { type: 'number', description: 'Last updated timestamp' },
},
}

View File

@@ -0,0 +1,105 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelCreateWebhookParams, VercelCreateWebhookResponse } from '@/tools/vercel/types'
export const vercelCreateWebhookTool: ToolConfig<
VercelCreateWebhookParams,
VercelCreateWebhookResponse
> = {
id: 'vercel_create_webhook',
name: 'Vercel Create Webhook',
description: 'Create a new webhook for a Vercel team',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
url: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Webhook URL (must be https)',
},
events: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Comma-separated event names to subscribe to',
},
projectIds: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Comma-separated project IDs to scope the webhook to',
},
teamId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Team ID to create the webhook for',
},
},
request: {
url: (params: VercelCreateWebhookParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/webhooks${qs ? `?${qs}` : ''}`
},
method: 'POST',
headers: (params: VercelCreateWebhookParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: VercelCreateWebhookParams) => {
const body: Record<string, any> = {
url: params.url.trim(),
events: params.events.split(',').map((e) => e.trim()),
}
if (params.projectIds) {
body.projectIds = params.projectIds.split(',').map((p) => p.trim())
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
id: data.id ?? null,
url: data.url ?? null,
secret: data.secret ?? null,
events: data.events ?? [],
ownerId: data.ownerId ?? null,
projectIds: data.projectIds ?? [],
createdAt: data.createdAt ?? null,
updatedAt: data.updatedAt ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Webhook ID' },
url: { type: 'string', description: 'Webhook URL' },
secret: { type: 'string', description: 'Webhook signing secret' },
events: {
type: 'array',
description: 'Events the webhook listens to',
items: { type: 'string', description: 'Event name' },
},
ownerId: { type: 'string', description: 'Owner ID' },
projectIds: {
type: 'array',
description: 'Associated project IDs',
items: { type: 'string', description: 'Project ID' },
},
createdAt: { type: 'number', description: 'Creation timestamp' },
updatedAt: { type: 'number', description: 'Last updated timestamp' },
},
}

View File

@@ -0,0 +1,62 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelDeleteAliasParams, VercelDeleteAliasResponse } from '@/tools/vercel/types'
export const vercelDeleteAliasTool: ToolConfig<VercelDeleteAliasParams, VercelDeleteAliasResponse> =
{
id: 'vercel_delete_alias',
name: 'Vercel Delete Alias',
description: 'Delete an alias by its ID',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
aliasId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Alias ID to delete',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelDeleteAliasParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v2/aliases/${params.aliasId.trim()}${qs ? `?${qs}` : ''}`
},
method: 'DELETE',
headers: (params: VercelDeleteAliasParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
status: data.status ?? 'SUCCESS',
},
}
},
outputs: {
status: {
type: 'string',
description: 'Deletion status (SUCCESS)',
},
},
}

View File

@@ -0,0 +1,77 @@
import type { ToolConfig } from '@/tools/types'
import type {
VercelDeleteDeploymentParams,
VercelDeleteDeploymentResponse,
} from '@/tools/vercel/types'
export const vercelDeleteDeploymentTool: ToolConfig<
VercelDeleteDeploymentParams,
VercelDeleteDeploymentResponse
> = {
id: 'vercel_delete_deployment',
name: 'Vercel Delete Deployment',
description: 'Delete a Vercel deployment',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
deploymentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The deployment ID or URL to delete',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelDeleteDeploymentParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const id = params.deploymentId.trim()
if (id.includes('.')) {
query.set('url', id)
}
const qs = query.toString()
return `https://api.vercel.com/v13/deployments/${id}${qs ? `?${qs}` : ''}`
},
method: 'DELETE',
headers: (params: VercelDeleteDeploymentParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
uid: data.uid ?? data.id ?? null,
state: data.state ?? 'DELETED',
},
}
},
outputs: {
uid: {
type: 'string',
description: 'The removed deployment ID',
},
state: {
type: 'string',
description: 'Deployment state after deletion (DELETED)',
},
},
}

View File

@@ -0,0 +1,69 @@
import type { ToolConfig } from '@/tools/types'
import type {
VercelDeleteDnsRecordParams,
VercelDeleteDnsRecordResponse,
} from '@/tools/vercel/types'
export const vercelDeleteDnsRecordTool: ToolConfig<
VercelDeleteDnsRecordParams,
VercelDeleteDnsRecordResponse
> = {
id: 'vercel_delete_dns_record',
name: 'Vercel Delete DNS Record',
description: 'Delete a DNS record for a domain in a Vercel account',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
domain: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The domain name the record belongs to',
},
recordId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the DNS record to delete',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelDeleteDnsRecordParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v2/domains/${params.domain.trim()}/records/${params.recordId.trim()}${qs ? `?${qs}` : ''}`
},
method: 'DELETE',
headers: (params: VercelDeleteDnsRecordParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async () => {
return {
success: true,
output: {
deleted: true,
},
}
},
outputs: {
deleted: { type: 'boolean', description: 'Whether the record was deleted' },
},
}

View File

@@ -0,0 +1,64 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelDeleteDomainParams, VercelDeleteDomainResponse } from '@/tools/vercel/types'
export const vercelDeleteDomainTool: ToolConfig<
VercelDeleteDomainParams,
VercelDeleteDomainResponse
> = {
id: 'vercel_delete_domain',
name: 'Vercel Delete Domain',
description: 'Delete a domain from a Vercel account or team',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
domain: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The domain name to delete',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelDeleteDomainParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v6/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}`
},
method: 'DELETE',
headers: (params: VercelDeleteDomainParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const d = await response.json()
return {
success: true,
output: {
uid: d.uid ?? null,
deleted: true,
},
}
},
outputs: {
uid: { type: 'string', description: 'The ID of the deleted domain' },
deleted: { type: 'boolean', description: 'Whether the domain was deleted' },
},
}

View File

@@ -0,0 +1,69 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelDeleteEnvVarParams, VercelDeleteEnvVarResponse } from '@/tools/vercel/types'
export const vercelDeleteEnvVarTool: ToolConfig<
VercelDeleteEnvVarParams,
VercelDeleteEnvVarResponse
> = {
id: 'vercel_delete_env_var',
name: 'Vercel Delete Environment Variable',
description: 'Delete an environment variable from a Vercel project',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
projectId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or name',
},
envId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Environment variable ID to delete',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelDeleteEnvVarParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/env/${params.envId.trim()}${qs ? `?${qs}` : ''}`
},
method: 'DELETE',
headers: (params: VercelDeleteEnvVarParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async () => {
return {
success: true,
output: {
deleted: true,
},
}
},
outputs: {
deleted: {
type: 'boolean',
description: 'Whether the environment variable was successfully deleted',
},
},
}

View File

@@ -0,0 +1,60 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelDeleteProjectParams, VercelDeleteProjectResponse } from '@/tools/vercel/types'
export const vercelDeleteProjectTool: ToolConfig<
VercelDeleteProjectParams,
VercelDeleteProjectResponse
> = {
id: 'vercel_delete_project',
name: 'Vercel Delete Project',
description: 'Delete a Vercel project',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
projectId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or name',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelDeleteProjectParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}${qs ? `?${qs}` : ''}`
},
method: 'DELETE',
headers: (params: VercelDeleteProjectParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async () => {
return {
success: true,
output: {
deleted: true,
},
}
},
outputs: {
deleted: { type: 'boolean', description: 'Whether the project was successfully deleted' },
},
}

View File

@@ -0,0 +1,63 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelDeleteWebhookParams, VercelDeleteWebhookResponse } from '@/tools/vercel/types'
export const vercelDeleteWebhookTool: ToolConfig<
VercelDeleteWebhookParams,
VercelDeleteWebhookResponse
> = {
id: 'vercel_delete_webhook',
name: 'Vercel Delete Webhook',
description: 'Delete a webhook from a Vercel team',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
webhookId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The webhook ID to delete',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelDeleteWebhookParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/webhooks/${params.webhookId.trim()}${qs ? `?${qs}` : ''}`
},
method: 'DELETE',
headers: (params: VercelDeleteWebhookParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async () => {
return {
success: true,
output: {
deleted: true,
},
}
},
outputs: {
deleted: {
type: 'boolean',
description: 'Whether the webhook was successfully deleted',
},
},
}

View File

@@ -0,0 +1,97 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelGetAliasParams, VercelGetAliasResponse } from '@/tools/vercel/types'
export const vercelGetAliasTool: ToolConfig<VercelGetAliasParams, VercelGetAliasResponse> = {
id: 'vercel_get_alias',
name: 'Vercel Get Alias',
description: 'Get details about a specific alias by ID or hostname',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
aliasId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Alias ID or hostname to look up',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelGetAliasParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v4/aliases/${params.aliasId.trim()}${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: VercelGetAliasParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
uid: data.uid ?? null,
alias: data.alias ?? null,
deploymentId: data.deploymentId ?? null,
projectId: data.projectId ?? null,
createdAt: data.createdAt ?? null,
updatedAt: data.updatedAt ?? null,
redirect: data.redirect ?? null,
redirectStatusCode: data.redirectStatusCode ?? null,
},
}
},
outputs: {
uid: {
type: 'string',
description: 'Alias ID',
},
alias: {
type: 'string',
description: 'Alias hostname',
},
deploymentId: {
type: 'string',
description: 'Associated deployment ID',
},
projectId: {
type: 'string',
description: 'Associated project ID',
},
createdAt: {
type: 'number',
description: 'Creation timestamp in milliseconds',
},
updatedAt: {
type: 'number',
description: 'Last update timestamp in milliseconds',
},
redirect: {
type: 'string',
description: 'Target domain for redirect aliases',
},
redirectStatusCode: {
type: 'number',
description: 'HTTP status code for redirect (301, 302, 307, or 308)',
},
},
}

View File

@@ -0,0 +1,99 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelCheckResponse, VercelGetCheckParams } from '@/tools/vercel/types'
export const vercelGetCheckTool: ToolConfig<VercelGetCheckParams, VercelCheckResponse> = {
id: 'vercel_get_check',
name: 'Vercel Get Check',
description: 'Get details of a specific deployment check',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
deploymentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Deployment ID the check belongs to',
},
checkId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Check ID to retrieve',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelGetCheckParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks/${params.checkId.trim()}${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: VercelGetCheckParams) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
id: data.id,
name: data.name,
status: data.status ?? 'registered',
conclusion: data.conclusion ?? null,
blocking: data.blocking ?? false,
deploymentId: data.deploymentId,
integrationId: data.integrationId ?? null,
externalId: data.externalId ?? null,
detailsUrl: data.detailsUrl ?? null,
path: data.path ?? null,
rerequestable: data.rerequestable ?? false,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
startedAt: data.startedAt ?? null,
completedAt: data.completedAt ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Check ID' },
name: { type: 'string', description: 'Check name' },
status: { type: 'string', description: 'Check status: registered, running, or completed' },
conclusion: {
type: 'string',
description: 'Check conclusion: canceled, failed, neutral, succeeded, skipped, or stale',
optional: true,
},
blocking: { type: 'boolean', description: 'Whether the check blocks the deployment' },
deploymentId: { type: 'string', description: 'Associated deployment ID' },
integrationId: { type: 'string', description: 'Associated integration ID', optional: true },
externalId: { type: 'string', description: 'External identifier', optional: true },
detailsUrl: { type: 'string', description: 'URL with details about the check', optional: true },
path: { type: 'string', description: 'Page path being checked', optional: true },
rerequestable: { type: 'boolean', description: 'Whether the check can be rerequested' },
createdAt: { type: 'number', description: 'Creation timestamp in milliseconds' },
updatedAt: { type: 'number', description: 'Last update timestamp in milliseconds' },
startedAt: { type: 'number', description: 'Start timestamp in milliseconds', optional: true },
completedAt: {
type: 'number',
description: 'Completion timestamp in milliseconds',
optional: true,
},
},
}

View File

@@ -0,0 +1,176 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelGetDeploymentParams, VercelGetDeploymentResponse } from '@/tools/vercel/types'
export const vercelGetDeploymentTool: ToolConfig<
VercelGetDeploymentParams,
VercelGetDeploymentResponse
> = {
id: 'vercel_get_deployment',
name: 'Vercel Get Deployment',
description: 'Get details of a specific Vercel deployment',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
deploymentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The unique deployment identifier or hostname',
},
withGitRepoInfo: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Whether to add in gitRepo information (true/false)',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelGetDeploymentParams) => {
const query = new URLSearchParams()
if (params.withGitRepoInfo) query.set('withGitRepoInfo', params.withGitRepoInfo)
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v13/deployments/${params.deploymentId.trim()}${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: VercelGetDeploymentParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
id: data.id,
name: data.name,
url: data.url ?? '',
readyState: data.readyState ?? 'UNKNOWN',
status: data.status ?? data.readyState ?? 'UNKNOWN',
target: data.target ?? null,
createdAt: data.createdAt ?? data.created,
buildingAt: data.buildingAt ?? null,
ready: data.ready ?? null,
source: data.source ?? '',
alias: data.alias ?? [],
regions: data.regions ?? [],
inspectorUrl: data.inspectorUrl ?? '',
projectId: data.projectId ?? '',
creator: {
uid: data.creator?.uid ?? '',
username: data.creator?.username ?? '',
},
project: data.project
? {
id: data.project.id,
name: data.project.name,
framework: data.project.framework ?? null,
}
: null,
meta: data.meta ?? {},
gitSource: data.gitSource ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Deployment ID' },
name: { type: 'string', description: 'Deployment name' },
url: { type: 'string', description: 'Unique deployment URL' },
readyState: {
type: 'string',
description: 'Deployment ready state: QUEUED, BUILDING, ERROR, INITIALIZING, READY, CANCELED',
},
status: {
type: 'string',
description: 'Deployment status',
},
target: { type: 'string', description: 'Target environment', optional: true },
createdAt: { type: 'number', description: 'Creation timestamp in milliseconds' },
buildingAt: { type: 'number', description: 'Build start timestamp', optional: true },
ready: { type: 'number', description: 'Ready timestamp', optional: true },
source: {
type: 'string',
description: 'Deployment source: cli, git, redeploy, import, v0-web, etc.',
},
alias: {
type: 'array',
description: 'Assigned aliases',
items: { type: 'string', description: 'Alias domain' },
},
regions: {
type: 'array',
description: 'Deployment regions',
items: { type: 'string', description: 'Region code' },
},
inspectorUrl: { type: 'string', description: 'Vercel inspector URL' },
projectId: { type: 'string', description: 'Associated project ID' },
creator: {
type: 'object',
description: 'Creator information',
properties: {
uid: { type: 'string', description: 'Creator user ID' },
username: { type: 'string', description: 'Creator username' },
},
},
project: {
type: 'object',
description: 'Associated project',
optional: true,
properties: {
id: { type: 'string', description: 'Project ID' },
name: { type: 'string', description: 'Project name' },
framework: { type: 'string', description: 'Project framework', optional: true },
},
},
meta: {
type: 'object',
description: 'Deployment metadata (key-value strings)',
properties: {
githubCommitSha: { type: 'string', description: 'GitHub commit SHA', optional: true },
githubCommitMessage: {
type: 'string',
description: 'GitHub commit message',
optional: true,
},
githubCommitRef: { type: 'string', description: 'GitHub branch/ref', optional: true },
githubRepo: { type: 'string', description: 'GitHub repository', optional: true },
githubOrg: { type: 'string', description: 'GitHub organization', optional: true },
githubCommitAuthorName: {
type: 'string',
description: 'Commit author name',
optional: true,
},
},
},
gitSource: {
type: 'object',
description: 'Git source information',
optional: true,
properties: {
type: {
type: 'string',
description: 'Git provider type (e.g., github, gitlab, bitbucket)',
},
ref: { type: 'string', description: 'Git ref (branch or tag)' },
sha: { type: 'string', description: 'Git commit SHA' },
repoId: { type: 'string', description: 'Repository ID', optional: true },
},
},
},
}

View File

@@ -0,0 +1,135 @@
import type { ToolConfig } from '@/tools/types'
import type {
VercelGetDeploymentEventsParams,
VercelGetDeploymentEventsResponse,
} from '@/tools/vercel/types'
export const vercelGetDeploymentEventsTool: ToolConfig<
VercelGetDeploymentEventsParams,
VercelGetDeploymentEventsResponse
> = {
id: 'vercel_get_deployment_events',
name: 'Vercel Get Deployment Events',
description: 'Get build and runtime events for a Vercel deployment',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
deploymentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The unique deployment identifier or hostname',
},
direction: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Order of events by timestamp: backward or forward (default: forward)',
},
follow: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'When set to 1, returns live events as they happen',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of events to return (-1 for all)',
},
since: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Timestamp to start pulling build logs from',
},
until: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Timestamp to stop pulling build logs at',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelGetDeploymentEventsParams) => {
const query = new URLSearchParams()
if (params.direction) query.set('direction', params.direction)
if (params.follow !== undefined) query.set('follow', String(params.follow))
if (params.limit !== undefined) query.set('limit', String(params.limit))
if (params.since !== undefined) query.set('since', String(params.since))
if (params.until !== undefined) query.set('until', String(params.until))
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v3/deployments/${params.deploymentId.trim()}/events${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: VercelGetDeploymentEventsParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const events = (Array.isArray(data) ? data : (data.events ?? [])).map((e: any) => ({
type: e.type ?? null,
created: e.created ?? null,
date: e.date ?? null,
text: e.text ?? e.payload?.text ?? null,
serial: e.serial ?? null,
deploymentId: e.deploymentId ?? e.payload?.deploymentId ?? null,
id: e.id ?? null,
level: e.level ?? null,
}))
return {
success: true,
output: {
events,
count: events.length,
},
}
},
outputs: {
events: {
type: 'array',
description: 'List of deployment events',
items: {
type: 'object',
properties: {
type: {
type: 'string',
description:
'Event type: delimiter, command, stdout, stderr, exit, deployment-state, middleware, middleware-invocation, edge-function-invocation, metric, report, fatal',
},
created: { type: 'number', description: 'Event creation timestamp' },
date: { type: 'number', description: 'Event date timestamp' },
text: { type: 'string', description: 'Event text content' },
serial: { type: 'string', description: 'Event serial identifier' },
deploymentId: { type: 'string', description: 'Associated deployment ID' },
id: { type: 'string', description: 'Event unique identifier' },
level: { type: 'string', description: 'Event level: error or warning' },
},
},
},
count: {
type: 'number',
description: 'Number of events returned',
},
},
}

View File

@@ -0,0 +1,94 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelGetDomainParams, VercelGetDomainResponse } from '@/tools/vercel/types'
export const vercelGetDomainTool: ToolConfig<VercelGetDomainParams, VercelGetDomainResponse> = {
id: 'vercel_get_domain',
name: 'Vercel Get Domain',
description: 'Get information about a specific domain in a Vercel account',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
domain: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The domain name to retrieve',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelGetDomainParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v5/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: VercelGetDomainParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const d = data.domain ?? data
return {
success: true,
output: {
id: d.id ?? null,
name: d.name ?? null,
verified: d.verified ?? false,
createdAt: d.createdAt ?? null,
expiresAt: d.expiresAt ?? null,
serviceType: d.serviceType ?? null,
nameservers: d.nameservers ?? [],
intendedNameservers: d.intendedNameservers ?? [],
customNameservers: d.customNameservers ?? [],
renew: d.renew ?? false,
boughtAt: d.boughtAt ?? null,
transferredAt: d.transferredAt ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'Domain ID' },
name: { type: 'string', description: 'Domain name' },
verified: { type: 'boolean', description: 'Whether domain is verified' },
createdAt: { type: 'number', description: 'Creation timestamp' },
expiresAt: { type: 'number', description: 'Expiration timestamp' },
serviceType: { type: 'string', description: 'Service type (zeit.world, external, na)' },
nameservers: {
type: 'array',
description: 'Current nameservers',
items: { type: 'string' },
},
intendedNameservers: {
type: 'array',
description: 'Intended nameservers',
items: { type: 'string' },
},
customNameservers: {
type: 'array',
description: 'Custom nameservers',
items: { type: 'string' },
},
renew: { type: 'boolean', description: 'Whether auto-renewal is enabled' },
boughtAt: { type: 'number', description: 'Purchase timestamp' },
transferredAt: { type: 'number', description: 'Transfer completion timestamp' },
},
}

View File

@@ -0,0 +1,107 @@
import type { ToolConfig } from '@/tools/types'
import type {
VercelGetDomainConfigParams,
VercelGetDomainConfigResponse,
} from '@/tools/vercel/types'
export const vercelGetDomainConfigTool: ToolConfig<
VercelGetDomainConfigParams,
VercelGetDomainConfigResponse
> = {
id: 'vercel_get_domain_config',
name: 'Vercel Get Domain Config',
description: 'Get the configuration for a domain in a Vercel account',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
domain: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The domain name to get configuration for',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelGetDomainConfigParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v6/domains/${params.domain.trim()}/config${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: VercelGetDomainConfigParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const d = await response.json()
return {
success: true,
output: {
configuredBy: d.configuredBy ?? null,
acceptedChallenges: d.acceptedChallenges ?? [],
misconfigured: d.misconfigured ?? false,
recommendedIPv4: d.recommendedIPv4 ?? [],
recommendedCNAME: d.recommendedCNAME ?? [],
},
}
},
outputs: {
configuredBy: {
type: 'string',
description: 'How the domain is configured (CNAME, A, http, dns-01, or null)',
},
acceptedChallenges: {
type: 'array',
description: 'Accepted challenge types for certificate issuance (dns-01, http-01)',
items: { type: 'string' },
},
misconfigured: {
type: 'boolean',
description: 'Whether the domain is misconfigured for TLS certificate generation',
},
recommendedIPv4: {
type: 'array',
description: 'Recommended IPv4 addresses with rank values',
items: {
type: 'object',
properties: {
rank: { type: 'number', description: 'Priority rank (1 is preferred)' },
value: {
type: 'array',
description: 'IPv4 addresses',
items: { type: 'string' },
},
},
},
},
recommendedCNAME: {
type: 'array',
description: 'Recommended CNAME records with rank values',
items: {
type: 'object',
properties: {
rank: { type: 'number', description: 'Priority rank (1 is preferred)' },
value: { type: 'string', description: 'CNAME value' },
},
},
},
},
}

View File

@@ -0,0 +1,100 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelGetEdgeConfigParams, VercelGetEdgeConfigResponse } from '@/tools/vercel/types'
export const vercelGetEdgeConfigTool: ToolConfig<
VercelGetEdgeConfigParams,
VercelGetEdgeConfigResponse
> = {
id: 'vercel_get_edge_config',
name: 'Vercel Get Edge Config',
description: 'Get details about a specific Edge Config store',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
edgeConfigId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Edge Config ID to look up',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelGetEdgeConfigParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/edge-config/${params.edgeConfigId.trim()}${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: VercelGetEdgeConfigParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
id: data.id ?? null,
slug: data.slug ?? null,
ownerId: data.ownerId ?? null,
digest: data.digest ?? null,
createdAt: data.createdAt ?? null,
updatedAt: data.updatedAt ?? null,
itemCount: data.itemCount ?? 0,
sizeInBytes: data.sizeInBytes ?? 0,
},
}
},
outputs: {
id: {
type: 'string',
description: 'Edge Config ID',
},
slug: {
type: 'string',
description: 'Edge Config slug',
},
ownerId: {
type: 'string',
description: 'Owner ID',
},
digest: {
type: 'string',
description: 'Content digest hash',
},
createdAt: {
type: 'number',
description: 'Creation timestamp',
},
updatedAt: {
type: 'number',
description: 'Last update timestamp',
},
itemCount: {
type: 'number',
description: 'Number of items',
},
sizeInBytes: {
type: 'number',
description: 'Size in bytes',
},
},
}

View File

@@ -0,0 +1,93 @@
import type { ToolConfig } from '@/tools/types'
import type {
VercelGetEdgeConfigItemsParams,
VercelGetEdgeConfigItemsResponse,
} from '@/tools/vercel/types'
export const vercelGetEdgeConfigItemsTool: ToolConfig<
VercelGetEdgeConfigItemsParams,
VercelGetEdgeConfigItemsResponse
> = {
id: 'vercel_get_edge_config_items',
name: 'Vercel Get Edge Config Items',
description: 'Get all items in an Edge Config store',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
edgeConfigId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Edge Config ID to get items from',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelGetEdgeConfigItemsParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/edge-config/${params.edgeConfigId.trim()}/items${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: VercelGetEdgeConfigItemsParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const rawItems = Array.isArray(data) ? data : (data.items ?? [])
const items = rawItems.map((item: any) => ({
key: item.key ?? null,
value: item.value ?? null,
description: item.description ?? null,
edgeConfigId: item.edgeConfigId ?? null,
createdAt: item.createdAt ?? null,
updatedAt: item.updatedAt ?? null,
}))
return {
success: true,
output: {
items,
count: items.length,
},
}
},
outputs: {
items: {
type: 'array',
description: 'List of Edge Config items',
items: {
type: 'object',
properties: {
key: { type: 'string', description: 'Item key' },
value: { type: 'json', description: 'Item value' },
description: { type: 'string', description: 'Item description' },
edgeConfigId: { type: 'string', description: 'Parent Edge Config ID' },
createdAt: { type: 'number', description: 'Creation timestamp' },
updatedAt: { type: 'number', description: 'Last update timestamp' },
},
},
},
count: {
type: 'number',
description: 'Number of items returned',
},
},
}

View File

@@ -0,0 +1,99 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelGetEnvVarsParams, VercelGetEnvVarsResponse } from '@/tools/vercel/types'
export const vercelGetEnvVarsTool: ToolConfig<VercelGetEnvVarsParams, VercelGetEnvVarsResponse> = {
id: 'vercel_get_env_vars',
name: 'Vercel Get Environment Variables',
description: 'Retrieve environment variables for a Vercel project',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
projectId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or name',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelGetEnvVarsParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/env${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: VercelGetEnvVarsParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const envs = (data.envs ?? []).map((e: any) => ({
id: e.id,
key: e.key,
value: e.value ?? '',
type: e.type ?? 'plain',
target: e.target ?? [],
gitBranch: e.gitBranch ?? null,
comment: e.comment ?? null,
}))
return {
success: true,
output: {
envs,
count: envs.length,
},
}
},
outputs: {
envs: {
type: 'array',
description: 'List of environment variables',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Environment variable ID' },
key: { type: 'string', description: 'Variable name' },
value: { type: 'string', description: 'Variable value' },
type: {
type: 'string',
description: 'Variable type (secret, system, encrypted, plain, sensitive)',
},
target: {
type: 'array',
description: 'Target environments',
items: { type: 'string', description: 'Environment name' },
},
gitBranch: { type: 'string', description: 'Git branch filter', optional: true },
comment: {
type: 'string',
description: 'Comment providing context for the variable',
optional: true,
},
},
},
},
count: {
type: 'number',
description: 'Number of environment variables returned',
},
},
}

View File

@@ -0,0 +1,89 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelGetProjectParams, VercelGetProjectResponse } from '@/tools/vercel/types'
export const vercelGetProjectTool: ToolConfig<VercelGetProjectParams, VercelGetProjectResponse> = {
id: 'vercel_get_project',
name: 'Vercel Get Project',
description: 'Get details of a specific Vercel project',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
projectId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Project ID or name',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelGetProjectParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: VercelGetProjectParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
return {
success: true,
output: {
id: data.id,
name: data.name,
framework: data.framework ?? null,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
domains: data.domains ?? [],
link: data.link
? {
type: data.link.type,
repo: data.link.repo,
org: data.link.org,
}
: null,
},
}
},
outputs: {
id: { type: 'string', description: 'Project ID' },
name: { type: 'string', description: 'Project name' },
framework: { type: 'string', description: 'Project framework', optional: true },
createdAt: { type: 'number', description: 'Creation timestamp' },
updatedAt: { type: 'number', description: 'Last updated timestamp' },
domains: {
type: 'array',
description: 'Project domains',
items: { type: 'string', description: 'Domain' },
},
link: {
type: 'object',
description: 'Git repository connection',
optional: true,
properties: {
type: { type: 'string', description: 'Repository type (github, gitlab, bitbucket)' },
repo: { type: 'string', description: 'Repository name' },
org: { type: 'string', description: 'Organization or owner' },
},
},
},
}

View File

@@ -0,0 +1,98 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelGetTeamParams, VercelGetTeamResponse } from '@/tools/vercel/types'
export const vercelGetTeamTool: ToolConfig<VercelGetTeamParams, VercelGetTeamResponse> = {
id: 'vercel_get_team',
name: 'Vercel Get Team',
description: 'Get information about a specific Vercel team',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
teamId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The team ID to retrieve',
},
},
request: {
url: (params: VercelGetTeamParams) => `https://api.vercel.com/v2/teams/${params.teamId.trim()}`,
method: 'GET',
headers: (params: VercelGetTeamParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const d = await response.json()
return {
success: true,
output: {
id: d.id ?? null,
slug: d.slug ?? null,
name: d.name ?? null,
avatar: d.avatar ?? null,
description: d.description ?? null,
createdAt: d.createdAt ?? null,
updatedAt: d.updatedAt ?? null,
creatorId: d.creatorId ?? null,
membership: d.membership
? {
uid: d.membership.uid ?? null,
teamId: d.membership.teamId ?? null,
role: d.membership.role ?? null,
confirmed: d.membership.confirmed ?? false,
created: d.membership.created ?? null,
createdAt: d.membership.createdAt ?? null,
accessRequestedAt: d.membership.accessRequestedAt ?? null,
teamRoles: d.membership.teamRoles ?? [],
teamPermissions: d.membership.teamPermissions ?? [],
}
: null,
},
}
},
outputs: {
id: { type: 'string', description: 'Team ID' },
slug: { type: 'string', description: 'Team slug' },
name: { type: 'string', description: 'Team name' },
avatar: { type: 'string', description: 'Avatar file ID' },
description: { type: 'string', description: 'Short team description' },
createdAt: { type: 'number', description: 'Creation timestamp in milliseconds' },
updatedAt: { type: 'number', description: 'Last update timestamp in milliseconds' },
creatorId: { type: 'string', description: 'User ID of team creator' },
membership: {
type: 'object',
description: 'Current user membership details',
properties: {
uid: { type: 'string', description: 'User ID of the member' },
teamId: { type: 'string', description: 'Team ID' },
role: { type: 'string', description: 'Membership role' },
confirmed: { type: 'boolean', description: 'Whether membership is confirmed' },
created: { type: 'number', description: 'Membership creation timestamp' },
createdAt: { type: 'number', description: 'Membership creation timestamp (milliseconds)' },
accessRequestedAt: { type: 'number', description: 'When access was requested' },
teamRoles: {
type: 'array',
description: 'Team role assignments',
items: { type: 'string', description: 'Role name' },
},
teamPermissions: {
type: 'array',
description: 'Team permission assignments',
items: { type: 'string', description: 'Permission name' },
},
},
},
},
}

View File

@@ -0,0 +1,73 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelGetUserParams, VercelGetUserResponse } from '@/tools/vercel/types'
export const vercelGetUserTool: ToolConfig<VercelGetUserParams, VercelGetUserResponse> = {
id: 'vercel_get_user',
name: 'Vercel Get User',
description: 'Get information about the authenticated Vercel user',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
},
request: {
url: () => 'https://api.vercel.com/v2/user',
method: 'GET',
headers: (params: VercelGetUserParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const d = data.user ?? data
return {
success: true,
output: {
id: d.id ?? null,
email: d.email ?? null,
username: d.username ?? null,
name: d.name ?? null,
avatar: d.avatar ?? null,
defaultTeamId: d.defaultTeamId ?? null,
createdAt: d.createdAt ?? null,
stagingPrefix: d.stagingPrefix ?? null,
softBlock: d.softBlock
? {
blockedAt: d.softBlock.blockedAt ?? null,
reason: d.softBlock.reason ?? null,
}
: null,
hasTrialAvailable: d.hasTrialAvailable ?? null,
},
}
},
outputs: {
id: { type: 'string', description: 'User ID' },
email: { type: 'string', description: 'User email' },
username: { type: 'string', description: 'Username' },
name: { type: 'string', description: 'Display name' },
avatar: { type: 'string', description: 'SHA1 hash of the avatar' },
defaultTeamId: { type: 'string', description: 'Default team ID' },
createdAt: { type: 'number', description: 'Account creation timestamp in milliseconds' },
stagingPrefix: { type: 'string', description: 'Prefix for preview deployment URLs' },
softBlock: {
type: 'object',
description: 'Account restriction details if blocked',
properties: {
blockedAt: { type: 'number', description: 'When the account was blocked' },
reason: { type: 'string', description: 'Reason for the block' },
},
},
hasTrialAvailable: { type: 'boolean', description: 'Whether a trial is available' },
},
}

View File

@@ -0,0 +1,126 @@
// Deployment tools
// Domain tools
import { vercelAddDomainTool } from '@/tools/vercel/add_domain'
// Project tools
import { vercelAddProjectDomainTool } from '@/tools/vercel/add_project_domain'
import { vercelCancelDeploymentTool } from '@/tools/vercel/cancel_deployment'
// Alias tools
import { vercelCreateAliasTool } from '@/tools/vercel/create_alias'
// Check tools
import { vercelCreateCheckTool } from '@/tools/vercel/create_check'
import { vercelCreateDeploymentTool } from '@/tools/vercel/create_deployment'
// DNS tools
import { vercelCreateDnsRecordTool } from '@/tools/vercel/create_dns_record'
// Edge Config tools
import { vercelCreateEdgeConfigTool } from '@/tools/vercel/create_edge_config'
// Environment variable tools
import { vercelCreateEnvVarTool } from '@/tools/vercel/create_env_var'
import { vercelCreateProjectTool } from '@/tools/vercel/create_project'
// Webhook tools
import { vercelCreateWebhookTool } from '@/tools/vercel/create_webhook'
import { vercelDeleteAliasTool } from '@/tools/vercel/delete_alias'
import { vercelDeleteDeploymentTool } from '@/tools/vercel/delete_deployment'
import { vercelDeleteDnsRecordTool } from '@/tools/vercel/delete_dns_record'
import { vercelDeleteDomainTool } from '@/tools/vercel/delete_domain'
import { vercelDeleteEnvVarTool } from '@/tools/vercel/delete_env_var'
import { vercelDeleteProjectTool } from '@/tools/vercel/delete_project'
import { vercelDeleteWebhookTool } from '@/tools/vercel/delete_webhook'
import { vercelGetAliasTool } from '@/tools/vercel/get_alias'
import { vercelGetCheckTool } from '@/tools/vercel/get_check'
import { vercelGetDeploymentTool } from '@/tools/vercel/get_deployment'
import { vercelGetDeploymentEventsTool } from '@/tools/vercel/get_deployment_events'
import { vercelGetDomainTool } from '@/tools/vercel/get_domain'
import { vercelGetDomainConfigTool } from '@/tools/vercel/get_domain_config'
import { vercelGetEdgeConfigTool } from '@/tools/vercel/get_edge_config'
import { vercelGetEdgeConfigItemsTool } from '@/tools/vercel/get_edge_config_items'
import { vercelGetEnvVarsTool } from '@/tools/vercel/get_env_vars'
import { vercelGetProjectTool } from '@/tools/vercel/get_project'
// Team & User tools
import { vercelGetTeamTool } from '@/tools/vercel/get_team'
import { vercelGetUserTool } from '@/tools/vercel/get_user'
import { vercelListAliasesTool } from '@/tools/vercel/list_aliases'
import { vercelListChecksTool } from '@/tools/vercel/list_checks'
import { vercelListDeploymentFilesTool } from '@/tools/vercel/list_deployment_files'
import { vercelListDeploymentsTool } from '@/tools/vercel/list_deployments'
import { vercelListDnsRecordsTool } from '@/tools/vercel/list_dns_records'
import { vercelListDomainsTool } from '@/tools/vercel/list_domains'
import { vercelListEdgeConfigsTool } from '@/tools/vercel/list_edge_configs'
import { vercelListProjectDomainsTool } from '@/tools/vercel/list_project_domains'
import { vercelListProjectsTool } from '@/tools/vercel/list_projects'
import { vercelListTeamMembersTool } from '@/tools/vercel/list_team_members'
import { vercelListTeamsTool } from '@/tools/vercel/list_teams'
import { vercelListWebhooksTool } from '@/tools/vercel/list_webhooks'
import { vercelPauseProjectTool } from '@/tools/vercel/pause_project'
import { vercelRemoveProjectDomainTool } from '@/tools/vercel/remove_project_domain'
import { vercelRerequestCheckTool } from '@/tools/vercel/rerequest_check'
import { vercelUnpauseProjectTool } from '@/tools/vercel/unpause_project'
import { vercelUpdateCheckTool } from '@/tools/vercel/update_check'
import { vercelUpdateEdgeConfigItemsTool } from '@/tools/vercel/update_edge_config_items'
import { vercelUpdateEnvVarTool } from '@/tools/vercel/update_env_var'
import { vercelUpdateProjectTool } from '@/tools/vercel/update_project'
export {
// Deployments
vercelListDeploymentsTool,
vercelGetDeploymentTool,
vercelCreateDeploymentTool,
vercelCancelDeploymentTool,
vercelDeleteDeploymentTool,
vercelGetDeploymentEventsTool,
vercelListDeploymentFilesTool,
// Projects
vercelListProjectsTool,
vercelGetProjectTool,
vercelCreateProjectTool,
vercelUpdateProjectTool,
vercelDeleteProjectTool,
vercelPauseProjectTool,
vercelUnpauseProjectTool,
vercelListProjectDomainsTool,
vercelAddProjectDomainTool,
vercelRemoveProjectDomainTool,
// Environment Variables
vercelGetEnvVarsTool,
vercelCreateEnvVarTool,
vercelUpdateEnvVarTool,
vercelDeleteEnvVarTool,
// Domains
vercelListDomainsTool,
vercelGetDomainTool,
vercelAddDomainTool,
vercelDeleteDomainTool,
vercelGetDomainConfigTool,
// DNS
vercelListDnsRecordsTool,
vercelCreateDnsRecordTool,
vercelDeleteDnsRecordTool,
// Aliases
vercelListAliasesTool,
vercelGetAliasTool,
vercelCreateAliasTool,
vercelDeleteAliasTool,
// Edge Config
vercelListEdgeConfigsTool,
vercelGetEdgeConfigTool,
vercelCreateEdgeConfigTool,
vercelGetEdgeConfigItemsTool,
vercelUpdateEdgeConfigItemsTool,
// Teams & User
vercelListTeamsTool,
vercelGetTeamTool,
vercelListTeamMembersTool,
vercelGetUserTool,
// Webhooks
vercelListWebhooksTool,
vercelCreateWebhookTool,
vercelDeleteWebhookTool,
// Checks
vercelCreateCheckTool,
vercelGetCheckTool,
vercelListChecksTool,
vercelUpdateCheckTool,
vercelRerequestCheckTool,
}
export * from './types'

View File

@@ -0,0 +1,107 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelListAliasesParams, VercelListAliasesResponse } from '@/tools/vercel/types'
export const vercelListAliasesTool: ToolConfig<VercelListAliasesParams, VercelListAliasesResponse> =
{
id: 'vercel_list_aliases',
name: 'Vercel List Aliases',
description: 'List aliases for a Vercel project or team',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
projectId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter aliases by project ID',
},
domain: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter aliases by domain',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum number of aliases to return',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelListAliasesParams) => {
const query = new URLSearchParams()
if (params.projectId) query.set('projectId', params.projectId.trim())
if (params.domain) query.set('domain', params.domain.trim())
if (params.limit) query.set('limit', String(params.limit))
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v4/aliases${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: VercelListAliasesParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const aliases = (data.aliases ?? []).map((a: any) => ({
uid: a.uid ?? null,
alias: a.alias ?? null,
deploymentId: a.deploymentId ?? null,
projectId: a.projectId ?? null,
createdAt: a.createdAt ?? null,
updatedAt: a.updatedAt ?? null,
}))
return {
success: true,
output: {
aliases,
count: aliases.length,
hasMore: data.pagination?.next != null,
},
}
},
outputs: {
aliases: {
type: 'array',
description: 'List of aliases',
items: {
type: 'object',
properties: {
uid: { type: 'string', description: 'Alias ID' },
alias: { type: 'string', description: 'Alias hostname' },
deploymentId: { type: 'string', description: 'Associated deployment ID' },
projectId: { type: 'string', description: 'Associated project ID' },
createdAt: { type: 'number', description: 'Creation timestamp in milliseconds' },
updatedAt: { type: 'number', description: 'Last update timestamp in milliseconds' },
},
},
},
count: {
type: 'number',
description: 'Number of aliases returned',
},
hasMore: {
type: 'boolean',
description: 'Whether more aliases are available',
},
},
}

View File

@@ -0,0 +1,99 @@
import type { ToolConfig } from '@/tools/types'
import type { VercelListChecksParams, VercelListChecksResponse } from '@/tools/vercel/types'
export const vercelListChecksTool: ToolConfig<VercelListChecksParams, VercelListChecksResponse> = {
id: 'vercel_list_checks',
name: 'Vercel List Checks',
description: 'List all checks for a deployment',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
deploymentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Deployment ID to list checks for',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelListChecksParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: VercelListChecksParams) => ({
Authorization: `Bearer ${params.apiKey}`,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const checks = (data.checks ?? []).map((check: Record<string, unknown>) => ({
id: check.id,
name: check.name,
status: check.status ?? 'registered',
conclusion: check.conclusion ?? null,
blocking: check.blocking ?? false,
deploymentId: check.deploymentId,
integrationId: check.integrationId ?? null,
externalId: check.externalId ?? null,
detailsUrl: check.detailsUrl ?? null,
path: check.path ?? null,
rerequestable: check.rerequestable ?? false,
createdAt: check.createdAt,
updatedAt: check.updatedAt,
startedAt: check.startedAt ?? null,
completedAt: check.completedAt ?? null,
}))
return {
success: true,
output: {
checks,
count: checks.length,
},
}
},
outputs: {
checks: {
type: 'array',
description: 'List of deployment checks',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Check ID' },
name: { type: 'string', description: 'Check name' },
status: { type: 'string', description: 'Check status' },
conclusion: { type: 'string', description: 'Check conclusion' },
blocking: { type: 'boolean', description: 'Whether the check blocks the deployment' },
deploymentId: { type: 'string', description: 'Associated deployment ID' },
integrationId: { type: 'string', description: 'Associated integration ID' },
externalId: { type: 'string', description: 'External identifier' },
detailsUrl: { type: 'string', description: 'URL with details about the check' },
path: { type: 'string', description: 'Page path being checked' },
rerequestable: { type: 'boolean', description: 'Whether the check can be rerequested' },
createdAt: { type: 'number', description: 'Creation timestamp' },
updatedAt: { type: 'number', description: 'Last update timestamp' },
startedAt: { type: 'number', description: 'Start timestamp' },
completedAt: { type: 'number', description: 'Completion timestamp' },
},
},
},
count: { type: 'number', description: 'Total number of checks' },
},
}

View File

@@ -0,0 +1,114 @@
import type { ToolConfig } from '@/tools/types'
import type {
VercelListDeploymentFilesParams,
VercelListDeploymentFilesResponse,
} from '@/tools/vercel/types'
export const vercelListDeploymentFilesTool: ToolConfig<
VercelListDeploymentFilesParams,
VercelListDeploymentFilesResponse
> = {
id: 'vercel_list_deployment_files',
name: 'Vercel List Deployment Files',
description: 'List files in a Vercel deployment',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Vercel Access Token',
},
deploymentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The deployment ID to list files for',
},
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
},
request: {
url: (params: VercelListDeploymentFilesParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
const qs = query.toString()
return `https://api.vercel.com/v6/deployments/${params.deploymentId.trim()}/files${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params: VercelListDeploymentFilesParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
const files = (Array.isArray(data) ? data : (data.files ?? [])).map((f: any) => ({
name: f.name ?? null,
type: f.type ?? null,
uid: f.uid ?? null,
mode: f.mode ?? null,
contentType: f.contentType ?? null,
children: f.children ?? [],
}))
return {
success: true,
output: {
files,
count: files.length,
},
}
},
outputs: {
files: {
type: 'array',
description: 'List of deployment files',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'The name of the file tree entry' },
type: {
type: 'string',
description: 'File type: directory, file, symlink, lambda, middleware, or invalid',
},
uid: {
type: 'string',
description: 'Unique file identifier (only valid for file type)',
optional: true,
},
mode: { type: 'number', description: 'File mode indicating file type and permissions' },
contentType: {
type: 'string',
description: 'Content-type of the file (only valid for file type)',
optional: true,
},
children: {
type: 'array',
description: 'Child files of the directory (only valid for directory type)',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'File name' },
type: { type: 'string', description: 'Entry type' },
uid: { type: 'string', description: 'File identifier', optional: true },
},
},
},
},
},
},
count: {
type: 'number',
description: 'Number of files returned',
},
},
}

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