diff --git a/README.md b/README.md index bed751ac4e..8fa64f1230 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio" Then run the migrations: ```bash +cd apps/sim # Required so drizzle picks correct .env file bunx drizzle-kit migrate --config=./drizzle.config.ts ``` diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 7ca67d7a90..997f4bf3b0 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -81,6 +81,7 @@ "sentry", "serper", "sftp", + "servicenow", "sharepoint", "shopify", "slack", diff --git a/apps/docs/content/docs/en/tools/servicenow.mdx b/apps/docs/content/docs/en/tools/servicenow.mdx new file mode 100644 index 0000000000..933f73c116 --- /dev/null +++ b/apps/docs/content/docs/en/tools/servicenow.mdx @@ -0,0 +1,138 @@ +--- +title: ServiceNow +description: Create, read, update, and delete ServiceNow records +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[ServiceNow](https://www.servicenow.com/) is a leading cloud-based platform that provides IT service management (ITSM), IT operations management (ITOM), and IT business management (ITBM) solutions. ServiceNow helps organizations automate workflows, manage digital operations, and deliver exceptional employee and customer experiences. + +ServiceNow offers a comprehensive platform for managing IT services, incidents, problems, changes, and other business processes. With its flexible table-based architecture, ServiceNow can be customized to manage virtually any type of record or business process across an organization. + +Key features of ServiceNow include: + +- **IT Service Management**: Comprehensive ITSM capabilities including incident, problem, change, and request management +- **Custom Tables**: Flexible table-based architecture allowing organizations to create custom tables for any business process +- **Workflow Automation**: Powerful workflow engine for automating business processes and approvals +- **REST API**: Robust REST API for programmatic access to ServiceNow data and operations + +In Sim, the ServiceNow integration enables your agents to interact directly with ServiceNow records and tables. This allows for powerful automation scenarios such as automated incident creation, ticket updates, user management, and custom table operations. Your agents can create, read, update, and delete records in any ServiceNow table (incidents, tasks, users, custom tables, etc.) programmatically. This integration bridges the gap between your AI workflows and your ServiceNow instance, enabling seamless automation of IT service management tasks and business processes. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate ServiceNow into the workflow. Can create, read, update, and delete records in any ServiceNow table (incidents, tasks, users, etc.). Supports OAuth 2.0 (recommended) or Basic Auth authentication. + + + +## Tools + +### `servicenow_create` + +Create a new record in a ServiceNow table + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) | +| `authMethod` | string | Yes | Authentication method: `oauth` or `basic` | +| `credential` | string | No | ServiceNow OAuth credential ID \(required when authMethod is `oauth`\) | +| `username` | string | No | ServiceNow username \(required when authMethod is `basic`\) | +| `password` | string | No | ServiceNow password \(required when authMethod is `basic`\) | +| `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user\) | +| `fields` | json | Yes | Fields to set on the record \(JSON object\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | json | Created ServiceNow record with sys_id and other fields | +| `metadata` | json | Operation metadata including record count | + +### `servicenow_read` + +Read records from a ServiceNow table + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | ServiceNow instance URL | +| `username` | string | Yes | ServiceNow username | +| `password` | string | Yes | ServiceNow password | +| `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user\) | +| `sysId` | string | No | Specific record sys_id to retrieve | +| `number` | string | No | Record number \(e.g., INC0010001\) | +| `query` | string | No | Encoded query string \(e.g., "active=true^priority=1"\) | +| `limit` | number | No | Maximum number of records to return | +| `fields` | string | No | Comma-separated list of fields to return | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `records` | array | Array of ServiceNow records | +| `metadata` | json | Operation metadata including record count | + +### `servicenow_update` + +Update an existing record in a ServiceNow table + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | ServiceNow instance URL | +| `username` | string | Yes | ServiceNow username | +| `password` | string | Yes | ServiceNow password | +| `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user\) | +| `sysId` | string | Yes | Record sys_id to update | +| `fields` | json | Yes | Fields to update \(JSON object\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | json | Updated ServiceNow record | +| `metadata` | json | Operation metadata including updated fields | + +### `servicenow_delete` + +Delete a record from a ServiceNow table + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | ServiceNow instance URL | +| `username` | string | Yes | ServiceNow username | +| `password` | string | Yes | ServiceNow password | +| `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user\) | +| `sysId` | string | Yes | Record sys_id to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the deletion was successful | +| `metadata` | json | Operation metadata including deleted sys_id | + + + +## Notes + +- Category: `tools` +- Type: `servicenow` +- Authentication: Supports OAuth 2.0 (recommended) via Sim Bot or Basic Auth with username/password +- Table Names: Common tables include `incident`, `task`, `sys_user`, `change_request`, `problem`, etc. +- Query Syntax: Use ServiceNow encoded query syntax (e.g., `active=true^priority=1`) for filtering records +- sys_id: Every ServiceNow record has a unique `sys_id` that is used to identify and reference records + diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx index cb13cbd3f6..412f3040b5 100644 --- a/apps/sim/app/(landing)/components/footer/footer.tsx +++ b/apps/sim/app/(landing)/components/footer/footer.tsx @@ -109,7 +109,7 @@ export default function Footer({ fullWidth = false }: FooterProps) { {FOOTER_BLOCKS.map((block) => ( | null = null + + // Try to get current user info + try { + const whoamiResponse = await fetch(`${instanceUrl}/api/now/ui/user/current_user`, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/json', + }, + }) + + if (whoamiResponse.ok) { + const whoamiData = await whoamiResponse.json() + userInfo = whoamiData.result + if (userInfo?.user_sys_id) { + accountIdentifier = userInfo.user_sys_id as string + } else if (userInfo?.user_name) { + accountIdentifier = userInfo.user_name as string + } + logger.info('Retrieved ServiceNow user info', { accountIdentifier }) + } + } catch (e) { + logger.warn('Could not retrieve ServiceNow user info, using instance URL as identifier') + } + + // Calculate expiration time + const now = new Date() + const expiresIn = expiresInStr ? parseInt(expiresInStr, 10) : 3600 // Default to 1 hour + const accessTokenExpiresAt = new Date(now.getTime() + expiresIn * 1000) + + // Check for existing ServiceNow account for this user + const existing = await db.query.account.findFirst({ + where: and(eq(account.userId, session.user.id), eq(account.providerId, 'servicenow')), + }) + + // ServiceNow always grants 'useraccount' scope but returns empty string + const effectiveScope = scope && scope.trim() ? scope : 'useraccount' + + const accountData = { + accessToken: accessToken, + refreshToken: refreshToken || null, + accountId: accountIdentifier, + scope: effectiveScope, + updatedAt: now, + accessTokenExpiresAt: accessTokenExpiresAt, + idToken: instanceUrl, // Store instance URL in idToken for API calls + } + + if (existing) { + await db.update(account).set(accountData).where(eq(account.id, existing.id)) + logger.info('Updated existing ServiceNow account', { accountId: existing.id }) + } else { + await safeAccountInsert( + { + id: `servicenow_${session.user.id}_${Date.now()}`, + userId: session.user.id, + providerId: 'servicenow', + accountId: accountData.accountId, + accessToken: accountData.accessToken, + refreshToken: accountData.refreshToken || undefined, + scope: accountData.scope, + idToken: accountData.idToken, + createdAt: now, + updatedAt: now, + }, + { provider: 'ServiceNow', identifier: instanceUrl } + ) + } + + // Get return URL from cookie + const returnUrl = request.cookies.get('servicenow_return_url')?.value + + const redirectUrl = returnUrl || `${baseUrl}/workspace` + const finalUrl = new URL(redirectUrl) + finalUrl.searchParams.set('servicenow_connected', 'true') + + const response = NextResponse.redirect(finalUrl.toString()) + + // Clean up all ServiceNow cookies + response.cookies.delete('servicenow_pending_token') + response.cookies.delete('servicenow_pending_refresh_token') + response.cookies.delete('servicenow_pending_instance') + response.cookies.delete('servicenow_pending_scope') + response.cookies.delete('servicenow_pending_expires_in') + response.cookies.delete('servicenow_return_url') + + return response + } catch (error) { + logger.error('Error storing ServiceNow token:', error) + return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_store_error`) + } +} diff --git a/apps/sim/app/api/auth/servicenow/authorize/route.ts b/apps/sim/app/api/auth/servicenow/authorize/route.ts new file mode 100644 index 0000000000..34f08d9d7c --- /dev/null +++ b/apps/sim/app/api/auth/servicenow/authorize/route.ts @@ -0,0 +1,262 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/core/config/env' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('ServiceNowAuthorize') + +export const dynamic = 'force-dynamic' + +/** + * ServiceNow OAuth scopes + * useraccount - Default scope for user account access + * Note: ServiceNow always returns 'useraccount' in OAuth responses regardless of requested scopes. + * Table API permissions are configured at the OAuth application level in ServiceNow. + */ +const SERVICENOW_SCOPES = 'useraccount' + +/** + * Validates a ServiceNow instance URL format + */ +function isValidInstanceUrl(url: string): boolean { + try { + const parsed = new URL(url) + return ( + parsed.protocol === 'https:' && + (parsed.hostname.endsWith('.service-now.com') || + parsed.hostname.endsWith('.servicenow.com')) + ) + } catch { + return false + } +} + +export async function GET(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const clientId = env.SERVICENOW_CLIENT_ID + + if (!clientId) { + logger.error('SERVICENOW_CLIENT_ID not configured') + return NextResponse.json({ error: 'ServiceNow client ID not configured' }, { status: 500 }) + } + + const instanceUrl = request.nextUrl.searchParams.get('instanceUrl') + const returnUrl = request.nextUrl.searchParams.get('returnUrl') + + if (!instanceUrl) { + const returnUrlParam = returnUrl ? encodeURIComponent(returnUrl) : '' + return new NextResponse( + ` + + + Connect ServiceNow Instance + + + + + +
+

Connect Your ServiceNow Instance

+

Enter your ServiceNow instance URL to continue

+
+
+ + +
+

Your instance URL looks like: https://yourcompany.service-now.com

+
+ + + +`, + { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + }, + } + ) + } + + // Validate instance URL + if (!isValidInstanceUrl(instanceUrl)) { + logger.error('Invalid ServiceNow instance URL:', { instanceUrl }) + return NextResponse.json( + { error: 'Invalid ServiceNow instance URL. Must be a valid .service-now.com or .servicenow.com domain.' }, + { status: 400 } + ) + } + + // Clean the instance URL + const parsedUrl = new URL(instanceUrl) + const cleanInstanceUrl = parsedUrl.origin + + const baseUrl = getBaseUrl() + const redirectUri = `${baseUrl}/api/auth/oauth2/callback/servicenow` + + const state = crypto.randomUUID() + + // ServiceNow OAuth authorization URL + const oauthUrl = + `${cleanInstanceUrl}/oauth_auth.do?` + + new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + state: state, + scope: SERVICENOW_SCOPES, + }).toString() + + logger.info('Initiating ServiceNow OAuth:', { + instanceUrl: cleanInstanceUrl, + requestedScopes: SERVICENOW_SCOPES, + redirectUri, + returnUrl: returnUrl || 'not specified', + }) + + const response = NextResponse.redirect(oauthUrl) + + // Store state and instance URL in cookies for validation in callback + response.cookies.set('servicenow_oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 10, // 10 minutes + path: '/', + }) + + response.cookies.set('servicenow_instance_url', cleanInstanceUrl, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 10, + path: '/', + }) + + if (returnUrl) { + response.cookies.set('servicenow_return_url', returnUrl, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 10, + path: '/', + }) + } + + return response + } catch (error) { + logger.error('Error initiating ServiceNow authorization:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/jobs/[jobId]/route.ts b/apps/sim/app/api/jobs/[jobId]/route.ts index faa4f17439..6fb09467b3 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.ts @@ -1,4 +1,3 @@ -import { runs } from '@trigger.dev/sdk' import { type NextRequest, NextResponse } from 'next/server' import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' import { getSession } from '@/lib/auth' diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 5254028d61..632af42d08 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -1,5 +1,4 @@ import { db, workflowSchedule } from '@sim/db' -import { tasks } from '@trigger.dev/sdk' import { and, eq, isNull, lt, lte, not, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 2b9cd8beaf..5d04486176 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -1,4 +1,3 @@ -import { tasks } from '@trigger.dev/sdk' import { type NextRequest, NextResponse } from 'next/server' import { validate as uuidValidate, v4 as uuidv4 } from 'uuid' import { z } from 'zod' @@ -253,6 +252,8 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise = { + type: 'servicenow', + name: 'ServiceNow', + description: 'Create, read, update, delete, and bulk import ServiceNow records', + authMode: AuthMode.OAuth, + longDescription: + 'Integrate ServiceNow into your workflow. Can create, read, update, and delete records in any ServiceNow table (incidents, tasks, users, etc.). Supports bulk import operations for data migration and ETL. Supports OAuth 2.0 (recommended) or Basic Auth.', + docsLink: 'https://docs.sim.ai/tools/servicenow', + category: 'tools', + bgColor: '#81B5A1', + icon: ServiceNowIcon, + subBlocks: [ + // Operation selector + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Create Record', id: 'create' }, + { label: 'Read Records', id: 'read' }, + { label: 'Update Record', id: 'update' }, + { label: 'Delete Record', id: 'delete' }, + //{ label: 'Import Set', id: 'import_set' }, + ], + value: () => 'read', + }, + // Authentication Method + { + id: 'authMethod', + title: 'Authentication Method', + type: 'dropdown', + options: [ + { label: 'Sim Bot (OAuth)', id: 'oauth' }, + { label: 'Basic Auth', id: 'basic' }, + ], + value: () => 'oauth', + required: true, + }, + // Instance URL + { + id: 'instanceUrl', + title: 'Instance URL', + type: 'short-input', + placeholder: 'https://instance.service-now.com', + required: true, + description: 'Your ServiceNow instance URL', + }, + // OAuth Credential (Sim Bot) + { + id: 'credential', + title: 'ServiceNow Account', + type: 'oauth-input', + serviceId: 'servicenow', + requiredScopes: ['useraccount'], + placeholder: 'Select ServiceNow account', + condition: { field: 'authMethod', value: 'oauth' }, + required: true, + }, + // Basic Auth: Username + { + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'Enter ServiceNow username', + condition: { field: 'authMethod', value: 'basic' }, + required: true, + }, + // Basic Auth: Password + { + id: 'password', + title: 'Password', + type: 'short-input', + placeholder: 'Enter ServiceNow password', + password: true, + condition: { field: 'authMethod', value: 'basic' }, + required: true, + }, + // Table Name + { + id: 'tableName', + title: 'Table Name', + type: 'short-input', + placeholder: 'incident, task, sys_user, etc.', + required: true, + description: 'ServiceNow table name', + }, + // Create-specific: Fields + { + id: 'fields', + title: 'Fields (JSON)', + type: 'code', + language: 'json', + placeholder: '{\n "short_description": "Issue description",\n "priority": "1"\n}', + condition: { field: 'operation', value: 'create' }, + required: true, + }, + // Read-specific: Query options + { + id: 'sysId', + title: 'Record sys_id', + type: 'short-input', + placeholder: 'Specific record sys_id (optional)', + condition: { field: 'operation', value: 'read' }, + }, + { + id: 'number', + title: 'Record Number', + type: 'short-input', + placeholder: 'e.g., INC0010001 (optional)', + condition: { field: 'operation', value: 'read' }, + }, + { + id: 'query', + title: 'Query String', + type: 'short-input', + placeholder: 'active=true^priority=1', + condition: { field: 'operation', value: 'read' }, + description: 'ServiceNow encoded query string', + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '10', + condition: { field: 'operation', value: 'read' }, + }, + { + id: 'fields', + title: 'Fields to Return', + type: 'short-input', + placeholder: 'number,short_description,priority', + condition: { field: 'operation', value: 'read' }, + description: 'Comma-separated list of fields', + }, + // Update-specific: sysId and fields + { + id: 'sysId', + title: 'Record sys_id', + type: 'short-input', + placeholder: 'Record sys_id to update', + condition: { field: 'operation', value: 'update' }, + required: true, + }, + { + id: 'fields', + title: 'Fields to Update (JSON)', + type: 'code', + language: 'json', + placeholder: '{\n "state": "2",\n "assigned_to": "user.sys_id"\n}', + condition: { field: 'operation', value: 'update' }, + required: true, + }, + // Delete-specific: sysId + { + id: 'sysId', + title: 'Record sys_id', + type: 'short-input', + placeholder: 'Record sys_id to delete', + condition: { field: 'operation', value: 'delete' }, + required: true, + }, + // Import Set-specific: Records + { + id: 'records', + title: 'Records (JSON Array)', + type: 'code', + language: 'json', + placeholder: '[\n {"short_description": "Issue 1", "priority": "1"},\n {"short_description": "Issue 2", "priority": "2"}\n]', + condition: { field: 'operation', value: 'import_set' }, + required: true, + description: 'Array of records to import', + }, + { + id: 'transformMap', + title: 'Transform Map sys_id', + type: 'short-input', + placeholder: 'Transform map sys_id (optional)', + condition: { field: 'operation', value: 'import_set' }, + description: 'Transform map to use for data transformation', + }, + { + id: 'importSetId', + title: 'Import Set sys_id', + type: 'short-input', + placeholder: 'Existing import set sys_id (optional)', + condition: { field: 'operation', value: 'import_set' }, + description: 'Add records to existing import set', + }, + ], + tools: { + access: [ + 'servicenow_create', + 'servicenow_read', + 'servicenow_update', + 'servicenow_delete', + 'servicenow_import_set', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'create': + return 'servicenow_create' + case 'read': + return 'servicenow_read' + case 'update': + return 'servicenow_update' + case 'delete': + return 'servicenow_delete' + // case 'import_set': + // return 'servicenow_import_set' + default: + throw new Error(`Invalid ServiceNow operation: ${params.operation}`) + } + }, + params: (params) => { + const { + operation, + fields, + records, + authMethod, + credential, + username, + password, + ...rest + } = params + + // Parse JSON fields if provided + let parsedFields: Record | undefined + if (fields && (operation === 'create' || operation === 'update')) { + try { + parsedFields = typeof fields === 'string' ? JSON.parse(fields) : fields + } catch (error) { + throw new Error( + `Invalid JSON in fields: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + // Parse JSON records if provided for import set + let parsedRecords: Array> | undefined + if (records && operation === 'import_set') { + try { + parsedRecords = + typeof records === 'string' ? JSON.parse(records) : Array.isArray(records) ? records : undefined + if (!Array.isArray(parsedRecords)) { + throw new Error('Records must be an array') + } + } catch (error) { + throw new Error( + `Invalid JSON in records: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + // Build params based on operation and auth method + const baseParams: Record = { + ...rest, + authMethod, + } + + // Add authentication params based on method + if (authMethod === 'oauth') { + if (!credential) { + throw new Error('ServiceNow account credential is required when using Sim Bot (OAuth)') + } + baseParams.credential = credential + } else { + // Basic Auth + baseParams.username = username + baseParams.password = password + } + + if (operation === 'create' || operation === 'update') { + return { + ...baseParams, + fields: parsedFields, + } + } + + if (operation === 'import_set') { + if (!parsedRecords || parsedRecords.length === 0) { + throw new Error('Records array is required and must not be empty for import set operation') + } + return { + ...baseParams, + records: parsedRecords, + } + } + + return baseParams + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + authMethod: { type: 'string', description: 'Authentication method (oauth or basic)' }, + instanceUrl: { type: 'string', description: 'ServiceNow instance URL' }, + credential: { type: 'string', description: 'ServiceNow OAuth credential ID' }, + username: { type: 'string', description: 'ServiceNow username (Basic Auth)' }, + password: { type: 'string', description: 'ServiceNow password (Basic Auth)' }, + tableName: { type: 'string', description: 'Table name' }, + sysId: { type: 'string', description: 'Record sys_id' }, + number: { type: 'string', description: 'Record number' }, + query: { type: 'string', description: 'Query string' }, + limit: { type: 'number', description: 'Result limit' }, + fields: { type: 'json', description: 'Fields object or JSON string' }, + records: { type: 'json', description: 'Array of records to import (import_set operation)' }, + transformMap: { type: 'string', description: 'Transform map sys_id (import_set operation)' }, + importSetId: { type: 'string', description: 'Existing import set sys_id (import_set operation)' }, + }, + outputs: { + record: { type: 'json', description: 'Single ServiceNow record' }, + records: { type: 'json', description: 'Array of ServiceNow records' }, + success: { type: 'boolean', description: 'Operation success status' }, + metadata: { type: 'json', description: 'Operation metadata' }, + importSetId: { type: 'string', description: 'Import set sys_id (import_set operation)' }, + }, +} + diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index ca1f30e845..b70e855a99 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -104,6 +104,7 @@ import { SmtpBlock } from '@/blocks/blocks/smtp' import { SpotifyBlock } from '@/blocks/blocks/spotify' import { SSHBlock } from '@/blocks/blocks/ssh' import { StagehandBlock } from '@/blocks/blocks/stagehand' +import { ServiceNowBlock } from '@/blocks/blocks/servicenow' import { StartTriggerBlock } from '@/blocks/blocks/start_trigger' import { StarterBlock } from '@/blocks/blocks/starter' import { StripeBlock } from '@/blocks/blocks/stripe' @@ -234,10 +235,7 @@ export const registry: Record = { router: RouterBlock, s3: S3Block, salesforce: SalesforceBlock, - schedule: ScheduleBlock, - search: SearchBlock, - sendgrid: SendGridBlock, - sentry: SentryBlock, + servicenow: ServiceNowBlock, serper: SerperBlock, sharepoint: SharepointBlock, shopify: ShopifyBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 12ead996f7..e391a2c20b 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -3335,6 +3335,24 @@ export function SalesforceIcon(props: SVGProps) { ) } +export function ServiceNowIcon(props: SVGProps) { + return ( + + + + ) +} + export function ApolloIcon(props: SVGProps) { return ( = { }, defaultService: 'shopify', }, + servicenow: { + id: 'servicenow', + name: 'ServiceNow', + icon: (props) => ServiceNowIcon(props), + services: { + servicenow: { + id: 'servicenow', + name: 'ServiceNow', + description: 'Manage incidents, tasks, and records in your ServiceNow instance.', + providerId: 'servicenow', + icon: (props) => ServiceNowIcon(props), + baseProviderIcon: (props) => ServiceNowIcon(props), + scopes: ['useraccount'], + }, + }, + defaultService: 'servicenow', + }, slack: { id: 'slack', name: 'Slack', @@ -1487,6 +1507,21 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: false, } } + case 'servicenow': { + // ServiceNow OAuth - token endpoint is instance-specific + // This is a placeholder; actual token endpoint is set during authorization + const { clientId, clientSecret } = getCredentials( + env.SERVICENOW_CLIENT_ID, + env.SERVICENOW_CLIENT_SECRET + ) + return { + tokenEndpoint: '', // Instance-specific, set during authorization + clientId, + clientSecret, + useBasicAuth: false, + supportsRefreshTokenRotation: true, + } + } case 'zoom': { const { clientId, clientSecret } = getCredentials(env.ZOOM_CLIENT_ID, env.ZOOM_CLIENT_SECRET) return { @@ -1565,11 +1600,13 @@ function buildAuthRequest( * This is a server-side utility function to refresh OAuth tokens * @param providerId The provider ID (e.g., 'google-drive') * @param refreshToken The refresh token to use + * @param instanceUrl Optional instance URL for providers with instance-specific endpoints (e.g., ServiceNow) * @returns Object containing the new access token and expiration time in seconds, or null if refresh failed */ export async function refreshOAuthToken( providerId: string, - refreshToken: string + refreshToken: string, + instanceUrl?: string ): Promise<{ accessToken: string; expiresIn: number; refreshToken: string } | null> { try { // Get the provider from the providerId (e.g., 'google-drive' -> 'google') @@ -1578,11 +1615,21 @@ export async function refreshOAuthToken( // Get provider configuration const config = getProviderAuthConfig(provider) + // For ServiceNow, the token endpoint is instance-specific + let tokenEndpoint = config.tokenEndpoint + if (provider === 'servicenow') { + if (!instanceUrl) { + logger.error('ServiceNow token refresh requires instance URL') + return null + } + tokenEndpoint = `${instanceUrl.replace(/\/$/, '')}/oauth_token.do` + } + // Build authentication request const { headers, bodyParams } = buildAuthRequest(config, refreshToken) // Refresh the token - const response = await fetch(config.tokenEndpoint, { + const response = await fetch(tokenEndpoint, { method: 'POST', headers, body: new URLSearchParams(bodyParams).toString(), diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index d7cb0f65f6..9182e45f78 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -1,5 +1,4 @@ import { db, webhook, workflow } from '@sim/db' -import { tasks } from '@trigger.dev/sdk' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' diff --git a/apps/sim/stores/undo-redo/store.ts b/apps/sim/stores/undo-redo/store.ts index b5575bc49a..ea2a03a333 100644 --- a/apps/sim/stores/undo-redo/store.ts +++ b/apps/sim/stores/undo-redo/store.ts @@ -45,12 +45,16 @@ function getStackKey(workflowId: string, userId: string): string { /** * Custom storage adapter for Zustand's persist middleware. - * We need this wrapper to gracefully handle 'QuotaExceededError' when localStorage is full. - * Without this, the default storage engine would throw and crash the application. + * We need this wrapper to gracefully handle 'QuotaExceededError' when localStorage is full, + * and to properly handle SSR/Node.js environments. + * + * Note: We use `typeof window === 'undefined'` instead of `typeof localStorage === 'undefined'` + * because Node.js 25.2.0+ defines localStorage as a native object that throws SecurityError + * when accessed without the `--localstorage-file` flag. */ const safeStorageAdapter = { getItem: (name: string): string | null => { - if (typeof localStorage === 'undefined') return null + if (typeof window === 'undefined') return null try { return localStorage.getItem(name) } catch (e) { @@ -59,7 +63,7 @@ const safeStorageAdapter = { } }, setItem: (name: string, value: string): void => { - if (typeof localStorage === 'undefined') return + if (typeof window === 'undefined') return try { localStorage.setItem(name, value) } catch (e) { @@ -68,7 +72,7 @@ const safeStorageAdapter = { } }, removeItem: (name: string): void => { - if (typeof localStorage === 'undefined') return + if (typeof window === 'undefined') return try { localStorage.removeItem(name) } catch (e) { diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index ed28b15e1b..0b20c0a33b 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -966,6 +966,13 @@ import { sftpMkdirTool, sftpUploadTool, } from '@/tools/sftp' +import { + servicenowCreateTool, + servicenowReadTool, + servicenowUpdateTool, + servicenowDeleteTool, + servicenowImportSetTool, +} from '@/tools/servicenow' import { sharepointAddListItemTool, sharepointCreateListTool, @@ -1520,6 +1527,11 @@ export const tools: Record = { github_repo_info: githubRepoInfoTool, github_latest_commit: githubLatestCommitTool, serper_search: serperSearchTool, + servicenow_create: servicenowCreateTool, + servicenow_read: servicenowReadTool, + servicenow_update: servicenowUpdateTool, + servicenow_delete: servicenowDeleteTool, + servicenow_import_set: servicenowImportSetTool, tavily_search: tavilySearchTool, tavily_extract: tavilyExtractTool, tavily_crawl: tavilyCrawlTool, diff --git a/apps/sim/tools/servicenow/create.ts b/apps/sim/tools/servicenow/create.ts new file mode 100644 index 0000000000..12d25a8a7e --- /dev/null +++ b/apps/sim/tools/servicenow/create.ts @@ -0,0 +1,162 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { ServiceNowCreateParams, ServiceNowCreateResponse } from '@/tools/servicenow/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ServiceNowCreateTool') + +/** + * Encode credentials to base64 for Basic Auth + * Works in both Node.js (Buffer) and browser (btoa) environments + */ +function encodeBasicAuth(username: string, password: string): string { + const credentials = `${username}:${password}` + // Check for Buffer in global scope (Node.js) + const BufferGlobal = typeof globalThis !== 'undefined' && (globalThis as any).Buffer + if (BufferGlobal) { + return BufferGlobal.from(credentials).toString('base64') + } + return btoa(credentials) +} + +/** + * Get authorization header based on auth method + * Note: For OAuth, executeTool automatically fetches the token and sets it as accessToken + */ +function getAuthHeader(params: ServiceNowCreateParams & { accessToken?: string }): string { + if (params.authMethod === 'oauth') { + // OAuth: accessToken is set by executeTool when credential is provided + const accessToken = params.accessToken + if (!accessToken) { + throw new Error('OAuth access token not found. Make sure credential is properly configured.') + } + return `Bearer ${accessToken}` + } else { + // Basic Auth + if (!params.username || !params.password) { + throw new Error('Username and password are required for Basic Auth') + } + const credentials = encodeBasicAuth(params.username, params.password) + return `Basic ${credentials}` + } +} + +export const createTool: ToolConfig = { + id: 'servicenow_create', + name: 'Create ServiceNow Record', + description: 'Create a new record in a ServiceNow table', + version: '1.0.0', + + oauth: { + required: false, + provider: 'servicenow', + }, + + params: { + instanceUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ServiceNow instance URL (e.g., https://instance.service-now.com)', + }, + authMethod: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Authentication method (oauth or basic)', + }, + credential: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'ServiceNow OAuth credential ID', + }, + username: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'ServiceNow username (Basic Auth)', + }, + password: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'ServiceNow password (Basic Auth)', + }, + tableName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name (e.g., incident, task, sys_user)', + }, + fields: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Fields to set on the record (JSON object)', + }, + }, + + request: { + url: (params) => { + // Use instanceUrl if provided, otherwise fall back to idToken (stored instance URL from OAuth) + const baseUrl = (params.instanceUrl || params.idToken || '').replace(/\/$/, '') + if (!baseUrl) { + throw new Error('ServiceNow instance URL is required') + } + return `${baseUrl}/api/now/table/${params.tableName}` + }, + method: 'POST', + headers: (params) => { + const authHeader = getAuthHeader(params as ServiceNowCreateParams & { accessToken?: string }) + return { + Authorization: authHeader, + 'Content-Type': 'application/json', + Accept: 'application/json', + } + }, + body: (params) => { + if (!params.fields || typeof params.fields !== 'object') { + throw new Error('Fields must be a JSON object') + } + return params.fields + }, + }, + + transformResponse: async (response: Response) => { + try { + const data = await response.json() + + if (!response.ok) { + const error = data.error || data + throw new Error( + typeof error === 'string' ? error : error.message || JSON.stringify(error) + ) + } + + return { + success: true, + output: { + record: data.result, + metadata: { + recordCount: 1, + }, + }, + } + } catch (error) { + logger.error('ServiceNow create - Error processing response:', { error }) + throw error + } + }, + + outputs: { + record: { + type: 'json', + description: 'Created ServiceNow record with sys_id and other fields', + }, + metadata: { + type: 'json', + description: 'Operation metadata', + }, + }, +} + diff --git a/apps/sim/tools/servicenow/delete.ts b/apps/sim/tools/servicenow/delete.ts new file mode 100644 index 0000000000..4d051dc99e --- /dev/null +++ b/apps/sim/tools/servicenow/delete.ts @@ -0,0 +1,149 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { ServiceNowDeleteParams, ServiceNowDeleteResponse } from '@/tools/servicenow/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ServiceNowDeleteTool') + +/** + * Encode credentials to base64 for Basic Auth + * Works in both Node.js (Buffer) and browser (btoa) environments + */ +function encodeBasicAuth(username: string, password: string): string { + const credentials = `${username}:${password}` + // Check for Buffer in global scope (Node.js) + const BufferGlobal = typeof globalThis !== 'undefined' && (globalThis as any).Buffer + if (BufferGlobal) { + return BufferGlobal.from(credentials).toString('base64') + } + return btoa(credentials) +} + +export const deleteTool: ToolConfig = { + id: 'servicenow_delete', + name: 'Delete ServiceNow Record', + description: 'Delete a record from a ServiceNow table', + version: '1.0.0', + + oauth: { + required: false, + provider: 'servicenow', + }, + + params: { + instanceUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ServiceNow instance URL (auto-detected from OAuth if not provided)', + }, + authMethod: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Authentication method (oauth or basic)', + }, + credential: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'ServiceNow OAuth credential ID', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ServiceNow username (for Basic Auth)', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ServiceNow password (for Basic Auth)', + }, + tableName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name', + }, + sysId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Record sys_id to delete', + }, + }, + + request: { + url: (params) => { + // Use instanceUrl if provided, otherwise fall back to idToken (stored instance URL from OAuth) + const baseUrl = (params.instanceUrl || params.idToken || '').replace(/\/$/, '') + if (!baseUrl) { + throw new Error('ServiceNow instance URL is required') + } + return `${baseUrl}/api/now/table/${params.tableName}/${params.sysId}` + }, + method: 'DELETE', + headers: (params) => { + // Support both OAuth and Basic Auth + if (params.accessToken) { + return { + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + } + } + // Fall back to Basic Auth + if (!params.username || !params.password) { + throw new Error('Either OAuth credential or username/password is required') + } + const credentials = encodeBasicAuth(params.username, params.password) + return { + Authorization: `Basic ${credentials}`, + Accept: 'application/json', + } + }, + }, + + transformResponse: async (response: Response, params?: ServiceNowDeleteParams) => { + try { + if (!response.ok) { + let errorData: any + try { + errorData = await response.json() + } catch { + errorData = { status: response.status, statusText: response.statusText } + } + throw new Error( + typeof errorData === 'string' + ? errorData + : errorData.error?.message || JSON.stringify(errorData) + ) + } + + return { + success: true, + output: { + success: true, + metadata: { + deletedSysId: params?.sysId || '', + }, + }, + } + } catch (error) { + logger.error('ServiceNow delete - Error processing response:', { error }) + throw error + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the deletion was successful', + }, + metadata: { + type: 'json', + description: 'Operation metadata', + }, + }, +} + diff --git a/apps/sim/tools/servicenow/import_set.ts b/apps/sim/tools/servicenow/import_set.ts new file mode 100644 index 0000000000..ae015a0bbe --- /dev/null +++ b/apps/sim/tools/servicenow/import_set.ts @@ -0,0 +1,236 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { + ServiceNowBaseParams, + ServiceNowImportSetParams, + ServiceNowImportSetResponse, +} from '@/tools/servicenow/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ServiceNowImportSetTool') + +/** + * Encode credentials to base64 for Basic Auth + * Works in both Node.js (Buffer) and browser (btoa) environments + */ +function encodeBasicAuth(username: string, password: string): string { + const credentials = `${username}:${password}` + // Check for Buffer in global scope (Node.js) + const BufferGlobal = typeof globalThis !== 'undefined' && (globalThis as any).Buffer + if (BufferGlobal) { + return BufferGlobal.from(credentials).toString('base64') + } + return btoa(credentials) +} + +/** + * Get authorization header based on auth method + * Note: For OAuth, executeTool automatically fetches the token and sets it as accessToken + */ +function getAuthHeader(params: ServiceNowImportSetParams & { accessToken?: string }): string { + if (params.authMethod === 'oauth') { + // OAuth: accessToken is set by executeTool when credential is provided + const accessToken = params.accessToken + if (!accessToken) { + throw new Error('OAuth access token not found. Make sure credential is properly configured.') + } + return `Bearer ${accessToken}` + } else { + // Basic Auth + if (!params.username || !params.password) { + throw new Error('Username and password are required for Basic Auth') + } + const credentials = encodeBasicAuth(params.username, params.password) + return `Basic ${credentials}` + } +} + +export const importSetTool: ToolConfig< + ServiceNowImportSetParams, + ServiceNowImportSetResponse +> = { + id: 'servicenow_import_set', + name: 'ServiceNow Import Set', + description: 'Bulk import data into ServiceNow using Import Set API', + version: '1.0.0', + + oauth: { + required: false, + provider: 'servicenow', + }, + + params: { + instanceUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ServiceNow instance URL (auto-detected from OAuth if not provided)', + }, + authMethod: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Authentication method (oauth or basic)', + }, + credential: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'ServiceNow OAuth credential ID', + }, + username: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'ServiceNow username (Basic Auth)', + }, + password: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'ServiceNow password (Basic Auth)', + }, + tableName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Import set table name (e.g., u_my_import_set) or target table name for direct import', + }, + records: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: 'Array of records to import. Each record should be a JSON object with field values.', + }, + transformMap: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Transform map sys_id to use for data transformation (optional)', + }, + batchSize: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Number of records to import per batch (default: all records in one batch)', + }, + importSetId: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Existing import set sys_id to add records to (optional)', + }, + }, + + request: { + url: (params) => { + // Use instanceUrl if provided, otherwise fall back to idToken (stored instance URL from OAuth) + const baseUrl = (params.instanceUrl || params.idToken || '').replace(/\/$/, '') + if (!baseUrl) { + throw new Error('ServiceNow instance URL is required') + } + let url = `${baseUrl}/api/now/import/${params.tableName}` + + const queryParams = new URLSearchParams() + if (params.transformMap) { + queryParams.append('transform_map', params.transformMap) + } + if (params.importSetId) { + queryParams.append('sysparm_import_set_id', params.importSetId) + } + + const queryString = queryParams.toString() + return queryString ? `${url}?${queryString}` : url + }, + method: 'POST', + headers: (params) => { + const authHeader = getAuthHeader( + params as ServiceNowImportSetParams & { accessToken?: string } + ) + return { + Authorization: authHeader, + 'Content-Type': 'application/json', + Accept: 'application/json', + } + }, + body: (params) => { + if (!Array.isArray(params.records) || params.records.length === 0) { + throw new Error('Records must be a non-empty array') + } + + // If batchSize is specified, we'll import in batches + // For now, we'll send all records in one request + // ServiceNow Import Set API accepts an array of records + return { + records: params.records, + } + }, + }, + + transformResponse: async (response: Response) => { + try { + const data = await response.json() + + if (!response.ok) { + const error = data.error || data + throw new Error( + typeof error === 'string' ? error : error.message || JSON.stringify(error) + ) + } + + // ServiceNow Import Set API returns import results + const result = data.result || data + const importSetId = result.import_set_id || result.sys_id || '' + const records = Array.isArray(result.records) ? result.records : [] + const metadata = result.metadata || { + totalRecords: records.length, + inserted: 0, + updated: 0, + ignored: 0, + errors: 0, + } + + // Count statuses from records if metadata is not provided + if (!result.metadata && records.length > 0) { + metadata.inserted = records.filter((r: any) => r.status === 'inserted').length + metadata.updated = records.filter((r: any) => r.status === 'updated').length + metadata.ignored = records.filter((r: any) => r.status === 'ignored').length + metadata.errors = records.filter((r: any) => r.status === 'error').length + } + + return { + success: true, + output: { + importSetId, + records, + metadata: { + totalRecords: metadata.totalRecords || records.length, + inserted: metadata.inserted || 0, + updated: metadata.updated || 0, + ignored: metadata.ignored || 0, + errors: metadata.errors || 0, + }, + }, + } + } catch (error) { + logger.error('ServiceNow import set - Error processing response:', { error }) + throw error + } + }, + + outputs: { + importSetId: { + type: 'string', + description: 'Import set sys_id that was created or used', + }, + records: { + type: 'array', + description: 'Array of imported records with their status', + }, + metadata: { + type: 'json', + description: 'Import metadata including counts of inserted, updated, ignored, and error records', + }, + }, +} + diff --git a/apps/sim/tools/servicenow/index.ts b/apps/sim/tools/servicenow/index.ts new file mode 100644 index 0000000000..4fbdd24a4b --- /dev/null +++ b/apps/sim/tools/servicenow/index.ts @@ -0,0 +1,14 @@ +import { createTool } from '@/tools/servicenow/create' +import { readTool } from '@/tools/servicenow/read' +import { updateTool } from '@/tools/servicenow/update' +import { deleteTool } from '@/tools/servicenow/delete' +import { importSetTool } from '@/tools/servicenow/import_set' + +export { + createTool as servicenowCreateTool, + readTool as servicenowReadTool, + updateTool as servicenowUpdateTool, + deleteTool as servicenowDeleteTool, + importSetTool as servicenowImportSetTool, +} + diff --git a/apps/sim/tools/servicenow/read.ts b/apps/sim/tools/servicenow/read.ts new file mode 100644 index 0000000000..007e643de6 --- /dev/null +++ b/apps/sim/tools/servicenow/read.ts @@ -0,0 +1,193 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { ServiceNowReadParams, ServiceNowReadResponse } from '@/tools/servicenow/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ServiceNowReadTool') + +/** + * Encode credentials to base64 for Basic Auth + * Works in both Node.js (Buffer) and browser (btoa) environments + */ +function encodeBasicAuth(username: string, password: string): string { + const credentials = `${username}:${password}` + // Check for Buffer in global scope (Node.js) + const BufferGlobal = typeof globalThis !== 'undefined' && (globalThis as any).Buffer + if (BufferGlobal) { + return BufferGlobal.from(credentials).toString('base64') + } + return btoa(credentials) +} + +export const readTool: ToolConfig = { + id: 'servicenow_read', + name: 'Read ServiceNow Records', + description: 'Read records from a ServiceNow table', + version: '1.0.0', + + oauth: { + required: false, + provider: 'servicenow', + }, + + params: { + instanceUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ServiceNow instance URL (auto-detected from OAuth if not provided)', + }, + authMethod: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Authentication method (oauth or basic)', + }, + credential: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'ServiceNow OAuth credential ID', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ServiceNow username (for Basic Auth)', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ServiceNow password (for Basic Auth)', + }, + tableName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name', + }, + sysId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Specific record sys_id', + }, + number: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Record number (e.g., INC0010001)', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Encoded query string (e.g., "active=true^priority=1")', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Maximum number of records to return', + }, + fields: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Comma-separated list of fields to return', + }, + }, + + request: { + url: (params) => { + // Use instanceUrl if provided, otherwise fall back to idToken (stored instance URL from OAuth) + const baseUrl = (params.instanceUrl || params.idToken || '').replace(/\/$/, '') + if (!baseUrl) { + throw new Error('ServiceNow instance URL is required') + } + let url = `${baseUrl}/api/now/table/${params.tableName}` + + const queryParams = new URLSearchParams() + + if (params.sysId) { + url = `${url}/${params.sysId}` + } else if (params.number) { + queryParams.append('number', params.number) + } + + if (params.query) { + queryParams.append('sysparm_query', params.query) + } + + if (params.limit) { + queryParams.append('sysparm_limit', params.limit.toString()) + } + + if (params.fields) { + queryParams.append('sysparm_fields', params.fields) + } + + const queryString = queryParams.toString() + return queryString ? `${url}?${queryString}` : url + }, + method: 'GET', + headers: (params) => { + // Support both OAuth and Basic Auth + if (params.accessToken) { + return { + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + } + } + // Fall back to Basic Auth + if (!params.username || !params.password) { + throw new Error('Either OAuth credential or username/password is required') + } + const credentials = encodeBasicAuth(params.username, params.password) + return { + Authorization: `Basic ${credentials}`, + Accept: 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + try { + const data = await response.json() + + if (!response.ok) { + const error = data.error || data + throw new Error( + typeof error === 'string' ? error : error.message || JSON.stringify(error) + ) + } + + const records = Array.isArray(data.result) ? data.result : [data.result] + + return { + success: true, + output: { + records, + metadata: { + recordCount: records.length, + }, + }, + } + } catch (error) { + logger.error('ServiceNow read - Error processing response:', { error }) + throw error + } + }, + + outputs: { + records: { + type: 'array', + description: 'Array of ServiceNow records', + }, + metadata: { + type: 'json', + description: 'Operation metadata', + }, + }, +} + diff --git a/apps/sim/tools/servicenow/types.ts b/apps/sim/tools/servicenow/types.ts new file mode 100644 index 0000000000..4715d4b223 --- /dev/null +++ b/apps/sim/tools/servicenow/types.ts @@ -0,0 +1,111 @@ +import type { ToolResponse } from '@/tools/types' + +export interface ServiceNowRecord { + sys_id: string + number?: string + [key: string]: any +} + +export interface ServiceNowBaseParams { + instanceUrl?: string + tableName: string + authMethod?: 'oauth' | 'basic' + // OAuth fields (injected by the system when using OAuth) + credential?: string + accessToken?: string + idToken?: string // Stores the instance URL from OAuth + // Basic Auth fields + username?: string + password?: string +} + +export interface ServiceNowCreateParams extends ServiceNowBaseParams { + fields: Record +} + +export interface ServiceNowCreateResponse extends ToolResponse { + output: { + record: ServiceNowRecord + metadata: { + recordCount: 1 + } + } +} + +export interface ServiceNowReadParams extends ServiceNowBaseParams { + sysId?: string + number?: string + query?: string + limit?: number + fields?: string +} + +export interface ServiceNowReadResponse extends ToolResponse { + output: { + records: ServiceNowRecord[] + metadata: { + recordCount: number + } + } +} + +export interface ServiceNowUpdateParams extends ServiceNowBaseParams { + sysId: string + fields: Record +} + +export interface ServiceNowUpdateResponse extends ToolResponse { + output: { + record: ServiceNowRecord + metadata: { + recordCount: 1 + updatedFields: string[] + } + } +} + +export interface ServiceNowDeleteParams extends ServiceNowBaseParams { + sysId: string +} + +export interface ServiceNowDeleteResponse extends ToolResponse { + output: { + success: boolean + metadata: { + deletedSysId: string + } + } +} + +export interface ServiceNowImportSetParams extends ServiceNowBaseParams { + records: Array> + transformMap?: string + batchSize?: number + importSetId?: string +} + +export interface ServiceNowImportSetResponse extends ToolResponse { + output: { + importSetId: string + records: Array<{ + sys_id: string + status: string + [key: string]: any + }> + metadata: { + totalRecords: number + inserted: number + updated: number + ignored: number + errors: number + } + } +} + +export type ServiceNowResponse = + | ServiceNowCreateResponse + | ServiceNowReadResponse + | ServiceNowUpdateResponse + | ServiceNowDeleteResponse + | ServiceNowImportSetResponse + diff --git a/apps/sim/tools/servicenow/update.ts b/apps/sim/tools/servicenow/update.ts new file mode 100644 index 0000000000..29e19e47fa --- /dev/null +++ b/apps/sim/tools/servicenow/update.ts @@ -0,0 +1,159 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { ServiceNowUpdateParams, ServiceNowUpdateResponse } from '@/tools/servicenow/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ServiceNowUpdateTool') + +/** + * Encode credentials to base64 for Basic Auth + * Works in both Node.js (Buffer) and browser (btoa) environments + */ +function encodeBasicAuth(username: string, password: string): string { + const credentials = `${username}:${password}` + // Check for Buffer in global scope (Node.js) + const BufferGlobal = typeof globalThis !== 'undefined' && (globalThis as any).Buffer + if (BufferGlobal) { + return BufferGlobal.from(credentials).toString('base64') + } + return btoa(credentials) +} + +export const updateTool: ToolConfig = { + id: 'servicenow_update', + name: 'Update ServiceNow Record', + description: 'Update an existing record in a ServiceNow table', + version: '1.0.0', + + oauth: { + required: false, + provider: 'servicenow', + }, + + params: { + instanceUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ServiceNow instance URL (auto-detected from OAuth if not provided)', + }, + authMethod: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Authentication method (oauth or basic)', + }, + credential: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'ServiceNow OAuth credential ID', + }, + username: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ServiceNow username (for Basic Auth)', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ServiceNow password (for Basic Auth)', + }, + tableName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name', + }, + sysId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Record sys_id to update', + }, + fields: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Fields to update (JSON object)', + }, + }, + + request: { + url: (params) => { + // Use instanceUrl if provided, otherwise fall back to idToken (stored instance URL from OAuth) + const baseUrl = (params.instanceUrl || params.idToken || '').replace(/\/$/, '') + if (!baseUrl) { + throw new Error('ServiceNow instance URL is required') + } + return `${baseUrl}/api/now/table/${params.tableName}/${params.sysId}` + }, + method: 'PATCH', + headers: (params) => { + // Support both OAuth and Basic Auth + if (params.accessToken) { + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + } + } + // Fall back to Basic Auth + if (!params.username || !params.password) { + throw new Error('Either OAuth credential or username/password is required') + } + const credentials = encodeBasicAuth(params.username, params.password) + return { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + } + }, + body: (params) => { + if (!params.fields || typeof params.fields !== 'object') { + throw new Error('Fields must be a JSON object') + } + return params.fields + }, + }, + + transformResponse: async (response: Response, params?: ServiceNowUpdateParams) => { + try { + const data = await response.json() + + if (!response.ok) { + const error = data.error || data + throw new Error( + typeof error === 'string' ? error : error.message || JSON.stringify(error) + ) + } + + return { + success: true, + output: { + record: data.result, + metadata: { + recordCount: 1, + updatedFields: params ? Object.keys(params.fields || {}) : [], + }, + }, + } + } catch (error) { + logger.error('ServiceNow update - Error processing response:', { error }) + throw error + } + }, + + outputs: { + record: { + type: 'json', + description: 'Updated ServiceNow record', + }, + metadata: { + type: 'json', + description: 'Operation metadata', + }, + }, +} +