chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export { servicenowConnector } from '@/connectors/servicenow/servicenow'
+119
View File
@@ -0,0 +1,119 @@
import { ServiceNowIcon } from '@/components/icons'
import type { ConnectorMeta } from '@/connectors/types'
export const DEFAULT_MAX_ITEMS = 500
export const servicenowConnectorMeta: ConnectorMeta = {
id: 'servicenow',
name: 'ServiceNow',
description: 'Sync Knowledge Base articles and Incidents from ServiceNow',
version: '1.0.0',
icon: ServiceNowIcon,
auth: {
mode: 'apiKey',
label: 'API Key',
placeholder: 'Enter your ServiceNow API key or password',
},
configFields: [
{
id: 'instanceUrl',
title: 'Instance URL',
type: 'short-input',
placeholder: 'yourinstance.service-now.com',
required: true,
description: 'Your ServiceNow instance URL',
},
{
id: 'username',
title: 'Username',
type: 'short-input',
placeholder: 'admin',
required: true,
description: 'ServiceNow username for Basic Auth',
},
{
id: 'contentType',
title: 'Content Type',
type: 'dropdown',
required: true,
description: 'Type of content to sync from ServiceNow',
options: [
{ label: 'Knowledge Base Articles', id: 'kb_knowledge' },
{ label: 'Incidents', id: 'incident' },
],
},
{
id: 'workflowState',
title: 'Article State',
type: 'dropdown',
required: false,
description: 'Filter KB articles by workflow state',
options: [
{ label: 'All States', id: 'all' },
{ label: 'Published', id: 'published' },
{ label: 'Draft', id: 'draft' },
{ label: 'Review', id: 'review' },
{ label: 'Retired', id: 'retired' },
{ label: 'Outdated', id: 'outdated' },
],
},
{
id: 'kbCategory',
title: 'KB Category',
type: 'short-input',
required: false,
placeholder: 'e.g. IT, HR, General',
description: 'Filter KB articles by category label',
},
{
id: 'incidentState',
title: 'Incident State',
type: 'dropdown',
required: false,
description: 'Filter incidents by state',
options: [
{ label: 'All States', id: 'all' },
{ label: 'New', id: '1' },
{ label: 'In Progress', id: '2' },
{ label: 'On Hold', id: '3' },
{ label: 'Resolved', id: '6' },
{ label: 'Closed', id: '7' },
{ label: 'Canceled', id: '8' },
],
},
{
id: 'incidentPriority',
title: 'Incident Priority',
type: 'dropdown',
required: false,
description: 'Filter incidents by priority',
options: [
{ label: 'All Priorities', id: 'all' },
{ label: 'Critical', id: '1' },
{ label: 'High', id: '2' },
{ label: 'Moderate', id: '3' },
{ label: 'Low', id: '4' },
{ label: 'Planning', id: '5' },
],
},
{
id: 'maxItems',
title: 'Max Items',
type: 'short-input',
required: false,
placeholder: `e.g. 200 (default: ${DEFAULT_MAX_ITEMS})`,
description: 'Maximum number of items to sync',
},
],
tagDefinitions: [
{ id: 'type', displayName: 'Record Type', fieldType: 'text' },
{ id: 'state', displayName: 'State', fieldType: 'text' },
{ id: 'priority', displayName: 'Priority', fieldType: 'text' },
{ id: 'category', displayName: 'Category', fieldType: 'text' },
{ id: 'author', displayName: 'Author', fieldType: 'text' },
{ id: 'lastUpdated', displayName: 'Last Updated', fieldType: 'date' },
],
}
@@ -0,0 +1,650 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { validateServiceNowInstanceUrl } from '@/lib/core/security/input-validation'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import { DEFAULT_MAX_ITEMS, servicenowConnectorMeta } from '@/connectors/servicenow/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { htmlToPlainText, parseTagDate } from '@/connectors/utils'
const logger = createLogger('ServiceNowConnector')
const PAGE_SIZE = 100
/**
* ServiceNow sys_id whitelist: 32-character lowercase hex strings.
*
* The encoded query language uses `^` as the AND separator and `^OR` as the
* OR separator with no escape syntax, so any user-supplied value interpolated
* into a `sysparm_query` clause must be validated up front. Path-based
* fetches (`/api/now/table/{table}/{sys_id}`) likewise treat the sys_id as a
* URL path segment and must be constrained to safe characters.
*/
const SYS_ID_PATTERN = /^[a-f0-9]{32}$/i
const NUMERIC_ID_PATTERN = /^\d+$/
/**
* Reject characters that have meaning in a ServiceNow encoded query
* (`^` is the operator separator; control chars and quotes can break the
* URL). All other Unicode characters — including accented letters used in
* categories like "Général" or "Ação" — are allowed.
*/
const KB_CATEGORY_DISALLOWED = /[\^"'`\u0000-\u001f\u007f]/
const VALID_WORKFLOW_STATES = new Set(['published', 'draft', 'review', 'retired', 'outdated'])
interface ServiceNowRecord {
sys_id: string
sys_updated_on?: string
sys_created_on?: string
sys_created_by?: string
sys_updated_by?: string
}
interface KBArticle extends ServiceNowRecord {
short_description?: string
text?: string
wiki?: string
workflow_state?: string
kb_category?: string | { display_value?: string }
kb_knowledge_base?: string | { display_value?: string }
number?: string
author?: string | { display_value?: string }
}
interface Incident extends ServiceNowRecord {
number?: string
short_description?: string
description?: string
state?: string
priority?: string
category?: string
assigned_to?: string | { display_value?: string }
opened_by?: string | { display_value?: string }
close_notes?: string
comments_and_work_notes?: string
work_notes?: string
resolution_notes?: string
}
/**
* Normalizes and validates the ServiceNow instance URL.
*
* Prepends https:// if the scheme is missing, strips trailing slashes, then
* enforces a ServiceNow-owned domain allowlist to prevent SSRF — the instance
* URL is user-controlled and was previously fetched server-side with no
* validation.
*/
function resolveServiceNowInstanceUrl(rawUrl: string): string {
let url = (rawUrl ?? '').trim().replace(/\/+$/, '')
if (url && !url.startsWith('https://') && !url.startsWith('http://')) {
url = `https://${url}`
}
const validation = validateServiceNowInstanceUrl(url)
if (!validation.isValid) {
throw new Error(validation.error || 'Invalid instance URL')
}
return validation.sanitized ?? url
}
/**
* Builds Basic Auth header from username and API key/password.
*/
function buildAuthHeader(accessToken: string, sourceConfig: Record<string, unknown>): string {
const username = sourceConfig.username as string
const encoded = Buffer.from(`${username}:${accessToken}`).toString('base64')
return `Basic ${encoded}`
}
/**
* Calls the ServiceNow Table API.
*/
async function serviceNowApiGet(
instanceUrl: string,
tableName: string,
authHeader: string,
params: Record<string, string>,
retryOptions?: Parameters<typeof fetchWithRetry>[2]
): Promise<{ result: Record<string, unknown>[]; nextOffset?: number; totalCount?: number }> {
const queryParams = new URLSearchParams(params)
const url = `${instanceUrl}/api/now/table/${tableName}?${queryParams.toString()}`
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: authHeader,
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
retryOptions
)
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error')
throw new Error(`ServiceNow API error (${response.status}): ${errorText}`)
}
const data = (await response.json()) as { result: Record<string, unknown>[] }
const totalCountHeader = response.headers.get('X-Total-Count')
const totalCount = totalCountHeader ? Number(totalCountHeader) : undefined
const offset = Number(params.sysparm_offset || '0')
const limit = Number(params.sysparm_limit || String(PAGE_SIZE))
const resultCount = data.result?.length ?? 0
const nextOffset = resultCount >= limit ? offset + limit : undefined
return {
result: data.result || [],
nextOffset,
totalCount,
}
}
/**
* Fetches a single ServiceNow record by sys_id via the path-based Table API
* endpoint (`GET /api/now/table/{tableName}/{sys_id}`), which returns a
* `{ result: <record> }` object rather than the array shape returned by the
* list endpoint. Returns `null` when the record is not found (404).
*/
async function serviceNowApiGetById(
instanceUrl: string,
tableName: string,
sysId: string,
authHeader: string,
params: Record<string, string>,
retryOptions?: Parameters<typeof fetchWithRetry>[2]
): Promise<Record<string, unknown> | null> {
const queryParams = new URLSearchParams(params)
const queryString = queryParams.toString()
const url = `${instanceUrl}/api/now/table/${tableName}/${sysId}${queryString ? `?${queryString}` : ''}`
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: authHeader,
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
retryOptions
)
if (response.status === 404) {
return null
}
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error')
throw new Error(`ServiceNow API error (${response.status}): ${errorText}`)
}
const data = (await response.json()) as { result?: Record<string, unknown> }
return data.result ?? null
}
function isServiceNowRecord(record: unknown): record is ServiceNowRecord & Record<string, unknown> {
return (
typeof record === 'object' &&
record !== null &&
!Array.isArray(record) &&
typeof (record as Record<string, unknown>).sys_id === 'string' &&
((record as Record<string, unknown>).sys_id as string).length > 0
)
}
/**
* Extracts a display value from a field that may be a string or a reference object.
* When sysparm_display_value=true, fields are plain strings.
* When sysparm_display_value=all, fields are objects with display_value/value.
* This helper normalises both shapes.
*/
function displayValue(field: unknown): string | undefined {
if (typeof field === 'string') return field || undefined
if (field && typeof field === 'object') {
const obj = field as Record<string, unknown>
if ('display_value' in obj && typeof obj.display_value === 'string') {
return obj.display_value || undefined
}
if ('value' in obj && typeof obj.value === 'string') {
return obj.value || undefined
}
}
return undefined
}
/**
* Extracts the raw value from a field that may be a string or an object
* returned by sysparm_display_value=all. Prefers `value` over `display_value`
* so that coded values (e.g. state "1") are preserved for mapping functions.
*/
function rawValue(field: unknown): string | undefined {
if (typeof field === 'string') return field || undefined
if (field && typeof field === 'object') {
const obj = field as Record<string, unknown>
if ('value' in obj && typeof obj.value === 'string') {
return obj.value || undefined
}
if ('display_value' in obj && typeof obj.display_value === 'string') {
return obj.display_value || undefined
}
}
return undefined
}
/**
* Maps ServiceNow state codes to human-readable labels for incidents.
*/
function incidentStateLabel(state: string | undefined): string {
const stateMap: Record<string, string> = {
'1': 'New',
'2': 'In Progress',
'3': 'On Hold',
'6': 'Resolved',
'7': 'Closed',
'8': 'Canceled',
}
return state ? stateMap[state] || state : 'Unknown'
}
/**
* Maps ServiceNow priority codes to human-readable labels.
*/
function priorityLabel(priority: string | undefined): string {
const priorityMap: Record<string, string> = {
'1': 'Critical',
'2': 'High',
'3': 'Moderate',
'4': 'Low',
'5': 'Planning',
}
return priority ? priorityMap[priority] || priority : 'Unknown'
}
/**
* Converts a KB article record to an ExternalDocument.
*/
function kbArticleToDocument(article: KBArticle, instanceUrl: string): ExternalDocument {
const title = rawValue(article.short_description) || rawValue(article.number) || article.sys_id
/**
* Wiki-template KB articles populate `wiki` with the body and leave
* `text` empty; HTML-template articles do the opposite. Falling back
* to `wiki` keeps both layouts indexable.
*/
const articleText = rawValue(article.text) || rawValue(article.wiki) || ''
const content = htmlToPlainText(articleText)
const sysId = rawValue(article.sys_id) || article.sys_id
const updatedOn = rawValue(article.sys_updated_on) || ''
const contentHash = `servicenow:${sysId}:${updatedOn}`
const sourceUrl = `${instanceUrl}/kb_view.do?sys_kb_id=${sysId}`
return {
externalId: sysId,
title,
content,
mimeType: 'text/plain',
sourceUrl,
contentHash,
metadata: {
type: 'kb_article',
number: rawValue(article.number),
workflowState: rawValue(article.workflow_state),
category: displayValue(article.kb_category),
knowledgeBase: displayValue(article.kb_knowledge_base),
author: displayValue(article.author) || rawValue(article.sys_created_by),
lastUpdated: rawValue(article.sys_updated_on),
createdOn: rawValue(article.sys_created_on),
},
}
}
/**
* Converts an incident record to an ExternalDocument.
*/
function incidentToDocument(incident: Incident, instanceUrl: string): ExternalDocument {
const number = rawValue(incident.number)
const shortDesc = rawValue(incident.short_description)
const title = number ? `${number}: ${shortDesc || 'Untitled'}` : shortDesc || incident.sys_id
const parts: string[] = []
if (shortDesc) {
parts.push(`Summary: ${shortDesc}`)
}
const description = rawValue(incident.description)
if (description) {
parts.push(`Description: ${htmlToPlainText(description)}`)
}
const state = rawValue(incident.state)
const priority = rawValue(incident.priority)
parts.push(`State: ${incidentStateLabel(state)}`)
parts.push(`Priority: ${priorityLabel(priority)}`)
const category = rawValue(incident.category)
if (category) {
parts.push(`Category: ${category}`)
}
if (displayValue(incident.assigned_to)) {
parts.push(`Assigned To: ${displayValue(incident.assigned_to)}`)
}
if (displayValue(incident.opened_by)) {
parts.push(`Opened By: ${displayValue(incident.opened_by)}`)
}
const resolutionNotes = rawValue(incident.resolution_notes)
if (resolutionNotes) {
parts.push(`Resolution Notes: ${htmlToPlainText(resolutionNotes)}`)
}
const closeNotes = rawValue(incident.close_notes)
if (closeNotes) {
parts.push(`Close Notes: ${htmlToPlainText(closeNotes)}`)
}
const content = parts.join('\n')
const sysId = rawValue(incident.sys_id) || incident.sys_id
const updatedOn = rawValue(incident.sys_updated_on) || ''
const contentHash = `servicenow:${sysId}:${updatedOn}`
const sourceUrl = `${instanceUrl}/incident.do?sys_id=${sysId}`
return {
externalId: sysId,
title,
content,
mimeType: 'text/plain',
sourceUrl,
contentHash,
metadata: {
type: 'incident',
number,
state: incidentStateLabel(state),
priority: priorityLabel(priority),
category,
assignedTo: displayValue(incident.assigned_to),
openedBy: displayValue(incident.opened_by),
author: displayValue(incident.opened_by) || rawValue(incident.sys_created_by),
lastUpdated: rawValue(incident.sys_updated_on),
createdOn: rawValue(incident.sys_created_on),
},
}
}
/**
* Builds the sysparm_query filter string for KB articles.
*/
function buildKBQuery(sourceConfig: Record<string, unknown>): string {
const parts: string[] = []
const workflowState = sourceConfig.workflowState as string | undefined
if (workflowState && workflowState !== 'all') {
if (VALID_WORKFLOW_STATES.has(workflowState)) {
parts.push(`workflow_state=${workflowState}`)
} else {
logger.warn('Skipping workflowState filter: value is not in the allowed set', {
workflowState,
})
}
}
const kbCategory = sourceConfig.kbCategory as string | undefined
const trimmedCategory = kbCategory?.trim()
if (trimmedCategory) {
if (!KB_CATEGORY_DISALLOWED.test(trimmedCategory)) {
parts.push(`kb_category.label=${trimmedCategory}`)
} else {
logger.warn('Skipping kbCategory filter: value contains disallowed characters', {
kbCategory: trimmedCategory,
})
}
}
parts.push('ORDERBYDESCsys_updated_on')
return parts.join('^')
}
/**
* Builds the sysparm_query filter string for incidents.
*/
function buildIncidentQuery(sourceConfig: Record<string, unknown>): string {
const parts: string[] = []
const incidentState = sourceConfig.incidentState as string | undefined
if (incidentState && incidentState !== 'all') {
if (NUMERIC_ID_PATTERN.test(incidentState)) {
parts.push(`state=${incidentState}`)
} else {
logger.warn('Skipping incidentState filter: value is not a numeric ID', { incidentState })
}
}
const incidentPriority = sourceConfig.incidentPriority as string | undefined
if (incidentPriority && incidentPriority !== 'all') {
if (NUMERIC_ID_PATTERN.test(incidentPriority)) {
parts.push(`priority=${incidentPriority}`)
} else {
logger.warn('Skipping incidentPriority filter: value is not a numeric ID', {
incidentPriority,
})
}
}
parts.push('ORDERBYDESCsys_updated_on')
return parts.join('^')
}
export const servicenowConnector: ConnectorConfig = {
...servicenowConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
_syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const instanceUrl = resolveServiceNowInstanceUrl(sourceConfig.instanceUrl as string)
const contentType = (sourceConfig.contentType as string) || 'kb_knowledge'
const maxItems = sourceConfig.maxItems ? Number(sourceConfig.maxItems) : DEFAULT_MAX_ITEMS
const authHeader = buildAuthHeader(accessToken, sourceConfig)
const offset = cursor ? Number(cursor) : 0
const remaining = maxItems - offset
if (remaining <= 0) {
return { documents: [], hasMore: false }
}
const limit = Math.min(PAGE_SIZE, remaining)
const isKB = contentType === 'kb_knowledge'
const tableName = isKB ? 'kb_knowledge' : 'incident'
const query = isKB ? buildKBQuery(sourceConfig) : buildIncidentQuery(sourceConfig)
const fields = isKB
? 'sys_id,short_description,text,wiki,workflow_state,kb_category,kb_knowledge_base,number,author,sys_created_by,sys_updated_by,sys_updated_on,sys_created_on'
: 'sys_id,number,short_description,description,state,priority,category,assigned_to,opened_by,close_notes,resolution_notes,sys_created_by,sys_updated_by,sys_updated_on,sys_created_on'
const params: Record<string, string> = {
sysparm_limit: String(limit),
sysparm_offset: String(offset),
sysparm_query: query,
sysparm_fields: fields,
sysparm_display_value: 'all',
}
logger.info('Fetching ServiceNow records', {
table: tableName,
offset,
limit,
query,
})
const { result, nextOffset } = await serviceNowApiGet(
instanceUrl,
tableName,
authHeader,
params
)
const documents: ExternalDocument[] = []
for (const record of result) {
if (!isServiceNowRecord(record)) {
logger.warn('Skipping ServiceNow record without sys_id', { table: tableName })
continue
}
const doc = isKB
? kbArticleToDocument(record, instanceUrl)
: incidentToDocument(record, instanceUrl)
if (doc.content.trim()) {
documents.push(doc)
}
}
const hasMore = nextOffset !== undefined && nextOffset < maxItems
const nextCursor = hasMore ? String(nextOffset) : undefined
logger.info('Fetched ServiceNow documents', {
count: documents.length,
hasMore,
nextCursor,
})
return {
documents,
nextCursor,
hasMore,
}
},
getDocument: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const contentType = (sourceConfig.contentType as string) || 'kb_knowledge'
const authHeader = buildAuthHeader(accessToken, sourceConfig)
const isKB = contentType === 'kb_knowledge'
const tableName = isKB ? 'kb_knowledge' : 'incident'
if (!SYS_ID_PATTERN.test(externalId)) {
logger.warn('Rejecting ServiceNow getDocument with invalid sys_id', {
externalId,
table: tableName,
})
return null
}
const fields = isKB
? 'sys_id,short_description,text,wiki,workflow_state,kb_category,kb_knowledge_base,number,author,sys_created_by,sys_updated_by,sys_updated_on,sys_created_on'
: 'sys_id,number,short_description,description,state,priority,category,assigned_to,opened_by,close_notes,resolution_notes,sys_created_by,sys_updated_by,sys_updated_on,sys_created_on'
const instanceUrl = resolveServiceNowInstanceUrl(sourceConfig.instanceUrl as string)
try {
const record = await serviceNowApiGetById(instanceUrl, tableName, externalId, authHeader, {
sysparm_fields: fields,
sysparm_display_value: 'all',
})
if (!record || !isServiceNowRecord(record)) {
return null
}
const doc = isKB
? kbArticleToDocument(record, instanceUrl)
: incidentToDocument(record, instanceUrl)
return doc.content.trim() ? doc : null
} catch (error) {
logger.warn('Failed to get ServiceNow document', {
externalId,
table: tableName,
error: toError(error).message,
})
return null
}
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const instanceUrl = sourceConfig.instanceUrl as string | undefined
const username = sourceConfig.username as string | undefined
const contentType = sourceConfig.contentType as string | undefined
const maxItems = sourceConfig.maxItems as string | undefined
if (!instanceUrl?.trim()) {
return { valid: false, error: 'Instance URL is required' }
}
if (!username?.trim()) {
return { valid: false, error: 'Username is required' }
}
if (!contentType) {
return { valid: false, error: 'Content type is required' }
}
if (maxItems && (Number.isNaN(Number(maxItems)) || Number(maxItems) <= 0)) {
return { valid: false, error: 'Max items must be a positive number' }
}
let normalizedUrl: string
try {
normalizedUrl = resolveServiceNowInstanceUrl(instanceUrl)
} catch (error) {
return { valid: false, error: toError(error).message }
}
const authHeader = buildAuthHeader(accessToken, sourceConfig)
const tableName = contentType === 'kb_knowledge' ? 'kb_knowledge' : 'incident'
try {
await serviceNowApiGet(
normalizedUrl,
tableName,
authHeader,
{
sysparm_limit: '1',
sysparm_offset: '0',
},
VALIDATE_RETRY_OPTIONS
)
return { valid: true }
} catch (error) {
return { valid: false, error: toError(error).message || 'Failed to connect to ServiceNow' }
}
},
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
if (typeof metadata.type === 'string') {
result.type = metadata.type === 'kb_article' ? 'KB Article' : 'Incident'
}
const state = metadata.state ?? metadata.workflowState
if (typeof state === 'string') {
result.state = state
}
if (typeof metadata.priority === 'string') {
result.priority = metadata.priority
}
if (typeof metadata.category === 'string') {
result.category = metadata.category
}
const author = metadata.author
if (typeof author === 'string') {
result.author = author
}
const lastUpdated = parseTagDate(metadata.lastUpdated)
if (lastUpdated) {
result.lastUpdated = lastUpdated
}
return result
},
}