mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
chore(deps): audit and clean up dependencies (#4531)
* chore(deps): audit and clean up dependencies - Remove unused: chalk, chart.js, dotenv, encoding, entities, thread-stream, uuid, @opentelemetry/exporter-jaeger, critters, marked, redis, soap - Replace soap with hand-rolled Workday SOAP client - Migrate marked to unified pipeline for inbox responses - Bump zustand v5, @react-email/* - Align all @aws-sdk/* to 3.1032.0 - Move type-only deps to devDependencies - Remove duplicate drizzle-orm/postgres overrides * fix(workday): coerce SOAP scalar strings to typed booleans/numbers - XML parser returns leaf text as strings; `!"false"` evaluated to `false`, causing all organizations to report `isActive: false` - Add parseSoapBoolean and parseSoapNumber helpers and apply at consumer sites (Inactive, Total_Results) - Drop unused service/soapAction fields from WD_OPERATIONS map * fix(workday): coerce compensation amounts and guard Date marshaling - get-compensation returned Amount/Per_Unit_Amount/Individual_Target_Amount as strings (XML leaf text), violating the tool's number contract - Coerce via parseSoapNumber and widen plan type to number | string - Add defensive Date branch in marshal() so Date inputs serialize as ISO 8601 instead of String(date)
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
createWorkdaySoapClient,
|
||||
extractRefId,
|
||||
normalizeSoapArray,
|
||||
parseSoapNumber,
|
||||
type WorkdayCompensationDataSoap,
|
||||
type WorkdayCompensationPlanSoap,
|
||||
type WorkdayWorkerSoap,
|
||||
@@ -60,7 +61,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const mapPlan = (p: WorkdayCompensationPlanSoap) => ({
|
||||
id: extractRefId(p.Compensation_Plan_Reference) ?? null,
|
||||
planName: p.Compensation_Plan_Reference?.attributes?.Descriptor ?? null,
|
||||
amount: p.Amount ?? p.Per_Unit_Amount ?? p.Individual_Target_Amount ?? null,
|
||||
amount:
|
||||
parseSoapNumber(p.Amount) ??
|
||||
parseSoapNumber(p.Per_Unit_Amount) ??
|
||||
parseSoapNumber(p.Individual_Target_Amount) ??
|
||||
null,
|
||||
currency: extractRefId(p.Currency_Reference) ?? null,
|
||||
frequency: extractRefId(p.Frequency_Reference) ?? null,
|
||||
})
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
createWorkdaySoapClient,
|
||||
extractRefId,
|
||||
normalizeSoapArray,
|
||||
parseSoapBoolean,
|
||||
parseSoapNumber,
|
||||
type WorkdayOrganizationSoap,
|
||||
} from '@/tools/workday/soap'
|
||||
|
||||
@@ -63,15 +65,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
| undefined
|
||||
)
|
||||
|
||||
const organizations = orgsArray.map((o) => ({
|
||||
id: extractRefId(o.Organization_Reference) ?? null,
|
||||
descriptor: o.Organization_Descriptor ?? null,
|
||||
type: extractRefId(o.Organization_Data?.Organization_Type_Reference) ?? null,
|
||||
subtype: extractRefId(o.Organization_Data?.Organization_Subtype_Reference) ?? null,
|
||||
isActive: o.Organization_Data?.Inactive != null ? !o.Organization_Data.Inactive : null,
|
||||
}))
|
||||
const organizations = orgsArray.map((o) => {
|
||||
const inactive = parseSoapBoolean(o.Organization_Data?.Inactive)
|
||||
return {
|
||||
id: extractRefId(o.Organization_Reference) ?? null,
|
||||
descriptor: o.Organization_Descriptor ?? null,
|
||||
type: extractRefId(o.Organization_Data?.Organization_Type_Reference) ?? null,
|
||||
subtype: extractRefId(o.Organization_Data?.Organization_Subtype_Reference) ?? null,
|
||||
isActive: inactive == null ? null : !inactive,
|
||||
}
|
||||
})
|
||||
|
||||
const total = result?.Response_Results?.Total_Results ?? organizations.length
|
||||
const total = parseSoapNumber(result?.Response_Results?.Total_Results) ?? organizations.length
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createWorkdaySoapClient,
|
||||
extractRefId,
|
||||
normalizeSoapArray,
|
||||
parseSoapNumber,
|
||||
type WorkdayWorkerSoap,
|
||||
} from '@/tools/workday/soap'
|
||||
|
||||
@@ -61,7 +62,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
employmentData: w.Worker_Data?.Employment_Data ?? null,
|
||||
}))
|
||||
|
||||
const total = result?.Response_Results?.Total_Results ?? workers.length
|
||||
const total = parseSoapNumber(result?.Response_Results?.Total_Results) ?? workers.length
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { marked } from 'marked'
|
||||
import { toHtml } from 'hast-util-to-html'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkRehype from 'remark-rehype'
|
||||
import { unified } from 'unified'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import * as agentmail from '@/lib/mothership/inbox/agentmail-client'
|
||||
import { replaceUntilStable } from '@/lib/mothership/inbox/format'
|
||||
@@ -37,7 +42,7 @@ export async function sendInboxResponse(
|
||||
: `I wasn't able to complete this task.\n\nError: ${result.error || 'Unknown error'}\n\n[View details](${chatUrl})\n\nBest,\nMothership`
|
||||
|
||||
const html = result.success
|
||||
? renderEmailHtml(result.content, chatUrl)
|
||||
? await renderEmailHtml(result.content, chatUrl)
|
||||
: renderErrorHtml(result.error || 'Unknown error', chatUrl)
|
||||
|
||||
try {
|
||||
@@ -93,8 +98,20 @@ function stripUnsafeUrls(html: string): string {
|
||||
return html.replace(/href\s*=\s*"(javascript|vbscript|data):[^"]*"/gi, 'href="#"')
|
||||
}
|
||||
|
||||
function renderEmailHtml(markdown: string, chatUrl: string): string {
|
||||
const bodyHtml = stripUnsafeUrls(marked.parse(stripRawHtml(markdown), { async: false }) as string)
|
||||
const markdownProcessor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkBreaks)
|
||||
.use(remarkRehype)
|
||||
|
||||
async function markdownToHtml(markdown: string): Promise<string> {
|
||||
const mdast = markdownProcessor.parse(markdown)
|
||||
const hast = await markdownProcessor.run(mdast)
|
||||
return toHtml(hast)
|
||||
}
|
||||
|
||||
async function renderEmailHtml(markdown: string, chatUrl: string): Promise<string> {
|
||||
const bodyHtml = stripUnsafeUrls(await markdownToHtml(stripRawHtml(markdown)))
|
||||
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>${EMAIL_STYLES}</style></head>
|
||||
<body>
|
||||
|
||||
@@ -81,9 +81,6 @@ const nextConfig: NextConfig = {
|
||||
'unpdf',
|
||||
'ffmpeg-static',
|
||||
'fluent-ffmpeg',
|
||||
'pino',
|
||||
'pino-pretty',
|
||||
'thread-stream',
|
||||
'ws',
|
||||
'isolated-vm',
|
||||
],
|
||||
|
||||
+25
-36
@@ -33,24 +33,24 @@
|
||||
"@1password/sdk": "0.3.1",
|
||||
"@a2a-js/sdk": "0.3.7",
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@aws-sdk/client-athena": "3.1024.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.940.0",
|
||||
"@aws-sdk/client-cloudformation": "3.1019.0",
|
||||
"@aws-sdk/client-cloudwatch": "3.940.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "3.940.0",
|
||||
"@aws-sdk/client-dynamodb": "3.940.0",
|
||||
"@aws-sdk/client-iam": "3.1029.0",
|
||||
"@aws-sdk/client-athena": "3.1032.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1032.0",
|
||||
"@aws-sdk/client-cloudformation": "3.1032.0",
|
||||
"@aws-sdk/client-cloudwatch": "3.1032.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "3.1032.0",
|
||||
"@aws-sdk/client-dynamodb": "3.1032.0",
|
||||
"@aws-sdk/client-iam": "3.1032.0",
|
||||
"@aws-sdk/client-identitystore": "3.1032.0",
|
||||
"@aws-sdk/client-organizations": "3.1032.0",
|
||||
"@aws-sdk/client-rds-data": "3.940.0",
|
||||
"@aws-sdk/client-s3": "^3.779.0",
|
||||
"@aws-sdk/client-secrets-manager": "3.940.0",
|
||||
"@aws-sdk/client-sesv2": "3.940.0",
|
||||
"@aws-sdk/client-sqs": "3.947.0",
|
||||
"@aws-sdk/client-rds-data": "3.1032.0",
|
||||
"@aws-sdk/client-s3": "3.1032.0",
|
||||
"@aws-sdk/client-secrets-manager": "3.1032.0",
|
||||
"@aws-sdk/client-sesv2": "3.1032.0",
|
||||
"@aws-sdk/client-sqs": "3.1032.0",
|
||||
"@aws-sdk/client-sso-admin": "3.1032.0",
|
||||
"@aws-sdk/client-sts": "3.1029.0",
|
||||
"@aws-sdk/lib-dynamodb": "3.940.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.779.0",
|
||||
"@aws-sdk/client-sts": "3.1032.0",
|
||||
"@aws-sdk/lib-dynamodb": "3.1032.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1032.0",
|
||||
"@azure/communication-email": "1.0.0",
|
||||
"@azure/storage-blob": "12.27.0",
|
||||
"@better-auth/sso": "1.3.12",
|
||||
@@ -65,7 +65,6 @@
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-jaeger": "2.1.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
|
||||
"@opentelemetry/resources": "^2.0.0",
|
||||
"@opentelemetry/sdk-node": "^0.200.0",
|
||||
@@ -92,8 +91,8 @@
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "1.2.4",
|
||||
"@react-email/components": "^0.0.34",
|
||||
"@react-email/render": "2.0.0",
|
||||
"@react-email/components": "0.5.7",
|
||||
"@react-email/render": "2.0.8",
|
||||
"@sim/audit": "workspace:*",
|
||||
"@sim/logger": "workspace:*",
|
||||
"@sim/realtime-protocol": "workspace:*",
|
||||
@@ -106,15 +105,11 @@
|
||||
"@tanstack/react-query": "5.90.8",
|
||||
"@tanstack/react-query-devtools": "5.90.2",
|
||||
"@trigger.dev/sdk": "4.4.3",
|
||||
"@types/react-window": "2.0.0",
|
||||
"@types/three": "0.177.0",
|
||||
"ajv": "8.18.0",
|
||||
"better-auth": "1.3.12",
|
||||
"better-auth-harmony": "1.3.1",
|
||||
"binary-extensions": "^2.0.0",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"chalk": "5.6.2",
|
||||
"chart.js": "4.5.1",
|
||||
"cheerio": "1.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -127,8 +122,6 @@
|
||||
"docx": "^9.6.1",
|
||||
"docx-preview": "^0.3.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"encoding": "0.1.13",
|
||||
"entities": "6.0.1",
|
||||
"es-toolkit": "1.45.1",
|
||||
"ffmpeg-static": "5.3.0",
|
||||
"fluent-ffmpeg": "2.1.3",
|
||||
@@ -137,6 +130,7 @@
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"groq-sdk": "^0.15.0",
|
||||
"hast-util-to-html": "9.0.5",
|
||||
"html-to-image": "1.11.13",
|
||||
"html-to-text": "^9.0.5",
|
||||
"idb-keyval": "6.2.2",
|
||||
@@ -153,7 +147,6 @@
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.479.0",
|
||||
"mammoth": "^1.9.0",
|
||||
"marked": "17.0.4",
|
||||
"mermaid": "11.14.0",
|
||||
"micromatch": "4.0.8",
|
||||
"monaco-editor": "0.55.1",
|
||||
@@ -184,16 +177,16 @@
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"react-window": "2.2.3",
|
||||
"reactflow": "^11.11.4",
|
||||
"redis": "5.10.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"remark-parse": "11.0.0",
|
||||
"remark-rehype": "11.1.2",
|
||||
"resend": "^4.1.2",
|
||||
"rss-parser": "3.13.0",
|
||||
"safe-regex2": "5.1.0",
|
||||
"sharp": "0.34.3",
|
||||
"soap": "1.8.0",
|
||||
"socket.io-client": "4.8.1",
|
||||
"ssh2": "^1.17.0",
|
||||
"streamdown": "2.5.0",
|
||||
@@ -201,14 +194,13 @@
|
||||
"svix": "1.88.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thread-stream": "4.0.0",
|
||||
"three": "0.177.0",
|
||||
"twilio": "5.9.0",
|
||||
"unified": "11.0.5",
|
||||
"unpdf": "1.4.0",
|
||||
"uuid": "^11.1.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "4.3.6",
|
||||
"zustand": "^4.5.7"
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sim/testing": "workspace:*",
|
||||
@@ -227,16 +219,15 @@
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-window": "2.0.0",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/three": "0.177.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.0.8",
|
||||
"autoprefixer": "10.4.21",
|
||||
"concurrently": "^9.1.0",
|
||||
"critters": "0.0.25",
|
||||
"dotenv": "^16.4.7",
|
||||
"jsdom": "^26.0.0",
|
||||
"postcss": "^8",
|
||||
"react-email": "^4.0.13",
|
||||
"react-email": "4.3.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
@@ -252,8 +243,6 @@
|
||||
"overrides": {
|
||||
"next": "16.2.4",
|
||||
"@next/env": "16.2.4",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"postgres": "^3.4.5",
|
||||
"react-floater": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { listWorkersTool } from '@/tools/workday/list_workers'
|
||||
import { terminateWorkerTool } from '@/tools/workday/terminate_worker'
|
||||
import { updateWorkerTool } from '@/tools/workday/update_worker'
|
||||
|
||||
export * from './types'
|
||||
export {
|
||||
assignOnboardingTool as workdayAssignOnboardingTool,
|
||||
changeJobTool as workdayChangeJobTool,
|
||||
@@ -21,5 +22,3 @@ export {
|
||||
terminateWorkerTool as workdayTerminateWorkerTool,
|
||||
updateWorkerTool as workdayUpdateWorkerTool,
|
||||
}
|
||||
|
||||
export * from './types'
|
||||
|
||||
+499
-22
@@ -1,5 +1,4 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import * as soap from 'soap'
|
||||
import { validateWorkdayTenantUrl } from '@/lib/core/security/input-validation'
|
||||
|
||||
const logger = createLogger('WorkdaySoapClient')
|
||||
@@ -16,10 +15,10 @@ export type WorkdayServiceKey = keyof typeof WORKDAY_SERVICES
|
||||
export interface WorkdaySoapResult {
|
||||
Response_Data?: Record<string, unknown>
|
||||
Response_Results?: {
|
||||
Total_Results?: number
|
||||
Total_Pages?: number
|
||||
Page_Results?: number
|
||||
Page?: number
|
||||
Total_Results?: number | string
|
||||
Total_Pages?: number | string
|
||||
Page_Results?: number | string
|
||||
Page?: number | string
|
||||
}
|
||||
Event_Reference?: WorkdayReference
|
||||
Employee_Reference?: WorkdayReference
|
||||
@@ -80,9 +79,9 @@ export interface WorkdayCompensationDataSoap {
|
||||
|
||||
export interface WorkdayCompensationPlanSoap {
|
||||
Compensation_Plan_Reference?: WorkdayReference
|
||||
Amount?: number
|
||||
Per_Unit_Amount?: number
|
||||
Individual_Target_Amount?: number
|
||||
Amount?: number | string
|
||||
Per_Unit_Amount?: number | string
|
||||
Individual_Target_Amount?: number | string
|
||||
Currency_Reference?: WorkdayReference
|
||||
Frequency_Reference?: WorkdayReference
|
||||
}
|
||||
@@ -99,7 +98,7 @@ export interface WorkdayOrganizationSoap {
|
||||
export interface WorkdayOrganizationDataSoap {
|
||||
Organization_Type_Reference?: WorkdayReference
|
||||
Organization_Subtype_Reference?: WorkdayReference
|
||||
Inactive?: boolean
|
||||
Inactive?: boolean | string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,11 +110,57 @@ export function normalizeSoapArray<T>(value: T | T[] | undefined): T[] {
|
||||
return Array.isArray(value) ? value : [value]
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces a SOAP scalar to a boolean. The XML parser returns leaf text as strings,
|
||||
* so `"true"`/`"false"` must be normalized before boolean operations like negation.
|
||||
* Returns null when the value is null/undefined or unrecognized.
|
||||
*/
|
||||
export function parseSoapBoolean(value: unknown): boolean | null {
|
||||
if (value == null) return null
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
if (trimmed === 'true' || trimmed === '1') return true
|
||||
if (trimmed === 'false' || trimmed === '0') return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces a SOAP scalar to a number. The XML parser returns leaf text as strings,
|
||||
* so numeric fields like `Total_Results` must be normalized before arithmetic.
|
||||
* Returns null when the value is null/undefined or not a finite number.
|
||||
*/
|
||||
export function parseSoapNumber(value: unknown): number | null {
|
||||
if (value == null) return null
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === '') return null
|
||||
const n = Number(trimmed)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const WD_OPERATIONS = [
|
||||
'Get_Workers',
|
||||
'Get_Organizations',
|
||||
'Put_Applicant',
|
||||
'Hire_Employee',
|
||||
'Change_Job',
|
||||
'Terminate_Employee',
|
||||
'Change_Personal_Information',
|
||||
'Put_Onboarding_Plan_Assignment',
|
||||
] as const
|
||||
|
||||
type WorkdayOperation = (typeof WD_OPERATIONS)[number]
|
||||
|
||||
type SoapOperationFn = (
|
||||
args: Record<string, unknown>
|
||||
) => Promise<[WorkdaySoapResult, string, Record<string, unknown>, string]>
|
||||
|
||||
export interface WorkdayClient extends soap.Client {
|
||||
export interface WorkdayClient {
|
||||
Get_WorkersAsync: SoapOperationFn
|
||||
Get_OrganizationsAsync: SoapOperationFn
|
||||
Put_ApplicantAsync: SoapOperationFn
|
||||
@@ -127,12 +172,12 @@ export interface WorkdayClient extends soap.Client {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the WSDL URL for a Workday SOAP service.
|
||||
* Pattern: {tenantUrl}/ccx/service/{tenant}/{serviceName}/{version}?wsdl
|
||||
* Builds the service endpoint URL for a Workday SOAP service.
|
||||
* Pattern: {tenantUrl}/ccx/service/{tenant}/{serviceName}/{version}
|
||||
*
|
||||
* @throws Error if tenantUrl is not a trusted Workday-hosted URL (SSRF guard)
|
||||
*/
|
||||
export function buildWsdlUrl(
|
||||
export function buildServiceUrl(
|
||||
tenantUrl: string,
|
||||
tenant: string,
|
||||
service: WorkdayServiceKey
|
||||
@@ -143,12 +188,433 @@ export function buildWsdlUrl(
|
||||
}
|
||||
const svc = WORKDAY_SERVICES[service]
|
||||
const baseUrl = (validation.sanitized ?? tenantUrl).replace(/\/$/, '')
|
||||
return `${baseUrl}/ccx/service/${tenant}/${svc.name}/${svc.version}?wsdl`
|
||||
return `${baseUrl}/ccx/service/${tenant}/${svc.name}/${svc.version}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a typed SOAP client for a Workday service.
|
||||
* Uses the `soap` npm package to parse the WSDL and auto-marshall JSON to XML.
|
||||
* Builds the WSDL URL for a Workday SOAP service. Retained for backwards compatibility
|
||||
* with any external consumers; the runtime no longer fetches the WSDL.
|
||||
*/
|
||||
export function buildWsdlUrl(
|
||||
tenantUrl: string,
|
||||
tenant: string,
|
||||
service: WorkdayServiceKey
|
||||
): string {
|
||||
return `${buildServiceUrl(tenantUrl, tenant, service)}?wsdl`
|
||||
}
|
||||
|
||||
const XML_ENTITIES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (c) => XML_ENTITIES[c] ?? c)
|
||||
}
|
||||
|
||||
function serializeAttributes(attrs?: Record<string, string>): string {
|
||||
if (!attrs) return ''
|
||||
let out = ''
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (v === undefined || v === null) continue
|
||||
out += ` ${k}="${escapeXml(String(v))}"`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Marshals a JS value into XML under the `wd:` namespace.
|
||||
* Conventions:
|
||||
* - Plain objects become elements with named children
|
||||
* - `attributes` becomes element attributes
|
||||
* - `$value` (or `_`) provides the element text content
|
||||
* - Arrays produce repeated elements with the same name
|
||||
* - Booleans render as "true"/"false", numbers via String()
|
||||
*/
|
||||
function marshal(name: string, value: unknown): string {
|
||||
if (value === undefined || value === null) return ''
|
||||
const tag = `wd:${name}`
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
let out = ''
|
||||
for (const item of value) {
|
||||
out += marshal(name, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return `<${tag}>${value.toISOString()}</${tag}>`
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>
|
||||
const attrs = obj.attributes as Record<string, string> | undefined
|
||||
const text = (obj.$value ?? obj._) as string | number | boolean | undefined
|
||||
|
||||
if (text !== undefined) {
|
||||
const childKeys = Object.keys(obj).filter(
|
||||
(k) => k !== 'attributes' && k !== '$value' && k !== '_'
|
||||
)
|
||||
if (childKeys.length === 0) {
|
||||
return `<${tag}${serializeAttributes(attrs)}>${escapeXml(String(text))}</${tag}>`
|
||||
}
|
||||
}
|
||||
|
||||
let inner = ''
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (k === 'attributes' || k === '$value' || k === '_') continue
|
||||
inner += marshal(k, v)
|
||||
}
|
||||
if (text !== undefined) inner = escapeXml(String(text)) + inner
|
||||
return `<${tag}${serializeAttributes(attrs)}>${inner}</${tag}>`
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return `<${tag}>${value ? 'true' : 'false'}</${tag}>`
|
||||
}
|
||||
|
||||
return `<${tag}>${escapeXml(String(value))}</${tag}>`
|
||||
}
|
||||
|
||||
function buildEnvelope(
|
||||
operation: string,
|
||||
args: Record<string, unknown>,
|
||||
username: string,
|
||||
password: string
|
||||
): string {
|
||||
let body = ''
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
body += marshal(k, v)
|
||||
}
|
||||
|
||||
const wsseNs = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd'
|
||||
const wssePwdType =
|
||||
'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText'
|
||||
|
||||
return (
|
||||
`<?xml version="1.0" encoding="UTF-8"?>` +
|
||||
`<env:Envelope xmlns:env="http://schemas.xmlsoap.org/soap/envelope/" xmlns:wd="urn:com.workday/bsvc">` +
|
||||
`<env:Header>` +
|
||||
`<wsse:Security xmlns:wsse="${wsseNs}" env:mustUnderstand="1">` +
|
||||
`<wsse:UsernameToken>` +
|
||||
`<wsse:Username>${escapeXml(username)}</wsse:Username>` +
|
||||
`<wsse:Password Type="${wssePwdType}">${escapeXml(password)}</wsse:Password>` +
|
||||
`</wsse:UsernameToken>` +
|
||||
`</wsse:Security>` +
|
||||
`</env:Header>` +
|
||||
`<env:Body>` +
|
||||
`<wd:${operation}_Request>` +
|
||||
body +
|
||||
`</wd:${operation}_Request>` +
|
||||
`</env:Body>` +
|
||||
`</env:Envelope>`
|
||||
)
|
||||
}
|
||||
|
||||
interface XmlNode {
|
||||
name: string
|
||||
localName: string
|
||||
attributes: Record<string, string>
|
||||
children: XmlNode[]
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal XML parser tuned for Workday SOAP responses: namespaced tags,
|
||||
* attributes, mixed text, self-closing tags, and CDATA sections.
|
||||
* Not a general-purpose parser — it does not expand entities beyond the
|
||||
* standard five and ignores processing instructions and DOCTYPE.
|
||||
*/
|
||||
function parseXml(xml: string): XmlNode {
|
||||
let i = 0
|
||||
const len = xml.length
|
||||
|
||||
function skipWhitespace() {
|
||||
while (i < len && xml.charCodeAt(i) <= 32) i++
|
||||
}
|
||||
|
||||
function readName(): string {
|
||||
const start = i
|
||||
while (i < len) {
|
||||
const c = xml[i]
|
||||
if (
|
||||
c === ' ' ||
|
||||
c === '\t' ||
|
||||
c === '\n' ||
|
||||
c === '\r' ||
|
||||
c === '>' ||
|
||||
c === '/' ||
|
||||
c === '='
|
||||
)
|
||||
break
|
||||
i++
|
||||
}
|
||||
return xml.slice(start, i)
|
||||
}
|
||||
|
||||
function readAttributes(): Record<string, string> {
|
||||
const attrs: Record<string, string> = {}
|
||||
while (i < len) {
|
||||
skipWhitespace()
|
||||
const c = xml[i]
|
||||
if (c === '>' || c === '/' || c === '?') return attrs
|
||||
const name = readName()
|
||||
skipWhitespace()
|
||||
if (xml[i] !== '=') {
|
||||
attrs[name] = ''
|
||||
continue
|
||||
}
|
||||
i++ // =
|
||||
skipWhitespace()
|
||||
const quote = xml[i]
|
||||
if (quote !== '"' && quote !== "'") {
|
||||
attrs[name] = ''
|
||||
continue
|
||||
}
|
||||
i++
|
||||
const start = i
|
||||
while (i < len && xml[i] !== quote) i++
|
||||
attrs[name] = decodeEntities(xml.slice(start, i))
|
||||
if (i < len) i++ // closing quote
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
function decodeEntities(s: string): string {
|
||||
return s.replace(/&(amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);/g, (_, ent) => {
|
||||
switch (ent) {
|
||||
case 'amp':
|
||||
return '&'
|
||||
case 'lt':
|
||||
return '<'
|
||||
case 'gt':
|
||||
return '>'
|
||||
case 'quot':
|
||||
return '"'
|
||||
case 'apos':
|
||||
return "'"
|
||||
default:
|
||||
if (ent.startsWith('#x')) return String.fromCodePoint(Number.parseInt(ent.slice(2), 16))
|
||||
if (ent.startsWith('#')) return String.fromCodePoint(Number.parseInt(ent.slice(1), 10))
|
||||
return `&${ent};`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function localOf(name: string): string {
|
||||
const idx = name.indexOf(':')
|
||||
return idx === -1 ? name : name.slice(idx + 1)
|
||||
}
|
||||
|
||||
function parseNode(): XmlNode {
|
||||
if (xml[i] !== '<') throw new Error(`Expected '<' at ${i}`)
|
||||
i++
|
||||
const name = readName()
|
||||
const attrs = readAttributes()
|
||||
skipWhitespace()
|
||||
const node: XmlNode = {
|
||||
name,
|
||||
localName: localOf(name),
|
||||
attributes: attrs,
|
||||
children: [],
|
||||
text: '',
|
||||
}
|
||||
if (xml[i] === '/') {
|
||||
i += 2 // />
|
||||
return node
|
||||
}
|
||||
if (xml[i] !== '>') throw new Error(`Expected '>' at ${i}`)
|
||||
i++
|
||||
|
||||
while (i < len) {
|
||||
if (xml[i] === '<') {
|
||||
if (xml.startsWith('<!--', i)) {
|
||||
const end = xml.indexOf('-->', i)
|
||||
i = end === -1 ? len : end + 3
|
||||
continue
|
||||
}
|
||||
if (xml.startsWith('<![CDATA[', i)) {
|
||||
const end = xml.indexOf(']]>', i + 9)
|
||||
const data = xml.slice(i + 9, end === -1 ? len : end)
|
||||
node.text += data
|
||||
i = end === -1 ? len : end + 3
|
||||
continue
|
||||
}
|
||||
if (xml[i + 1] === '/') {
|
||||
i += 2
|
||||
while (i < len && xml[i] !== '>') i++
|
||||
if (i < len) i++
|
||||
return node
|
||||
}
|
||||
node.children.push(parseNode())
|
||||
} else {
|
||||
const start = i
|
||||
while (i < len && xml[i] !== '<') i++
|
||||
node.text += decodeEntities(xml.slice(start, i))
|
||||
}
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// Skip XML declaration and DOCTYPE
|
||||
while (i < len) {
|
||||
skipWhitespace()
|
||||
if (xml.startsWith('<?', i)) {
|
||||
const end = xml.indexOf('?>', i)
|
||||
i = end === -1 ? len : end + 2
|
||||
continue
|
||||
}
|
||||
if (xml.startsWith('<!--', i)) {
|
||||
const end = xml.indexOf('-->', i)
|
||||
i = end === -1 ? len : end + 3
|
||||
continue
|
||||
}
|
||||
if (xml.startsWith('<!', i)) {
|
||||
const end = xml.indexOf('>', i)
|
||||
i = end === -1 ? len : end + 1
|
||||
continue
|
||||
}
|
||||
if (xml[i] === '<') break
|
||||
i++
|
||||
}
|
||||
return parseNode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a parsed XML node tree into the JS object shape that the previous
|
||||
* `soap` library produced: nested objects keyed by local element name,
|
||||
* attributes under `attributes`, repeated elements collapsed into arrays,
|
||||
* and pure text nodes returned as strings.
|
||||
*/
|
||||
function nodeToValue(node: XmlNode): unknown {
|
||||
const hasChildren = node.children.length > 0
|
||||
const trimmedText = node.text.trim()
|
||||
const attrKeys = Object.keys(node.attributes).filter(
|
||||
(k) => k !== 'xmlns' && !k.startsWith('xmlns:')
|
||||
)
|
||||
|
||||
if (!hasChildren && attrKeys.length === 0) {
|
||||
return trimmedText
|
||||
}
|
||||
|
||||
const obj: Record<string, unknown> = {}
|
||||
if (attrKeys.length > 0) {
|
||||
const attrs: Record<string, string> = {}
|
||||
for (const k of attrKeys) {
|
||||
const localKey = k.includes(':') ? k.slice(k.indexOf(':') + 1) : k
|
||||
attrs[localKey] = node.attributes[k]
|
||||
}
|
||||
obj.attributes = attrs
|
||||
}
|
||||
|
||||
if (!hasChildren && trimmedText !== '') {
|
||||
obj.$value = trimmedText
|
||||
return obj
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const key = child.localName
|
||||
const value = nodeToValue(child)
|
||||
if (key in obj) {
|
||||
const existing = obj[key]
|
||||
if (Array.isArray(existing)) {
|
||||
existing.push(value)
|
||||
} else {
|
||||
obj[key] = [existing, value]
|
||||
}
|
||||
} else {
|
||||
obj[key] = value
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
function findFirst(node: XmlNode, localName: string): XmlNode | null {
|
||||
if (node.localName === localName) return node
|
||||
for (const child of node.children) {
|
||||
const found = findFirst(child, localName)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractFaultMessage(envelope: XmlNode): string | null {
|
||||
const fault = findFirst(envelope, 'Fault')
|
||||
if (!fault) return null
|
||||
const faultstring = findFirst(fault, 'faultstring')
|
||||
if (faultstring?.text.trim()) return faultstring.text.trim()
|
||||
const reason = findFirst(fault, 'Reason')
|
||||
if (reason) {
|
||||
const text = findFirst(reason, 'Text')
|
||||
if (text?.text.trim()) return text.text.trim()
|
||||
}
|
||||
const detail = findFirst(fault, 'detail') ?? findFirst(fault, 'Detail')
|
||||
if (detail) {
|
||||
const msg = findFirst(detail, 'Validation_Error') ?? findFirst(detail, 'Detail_Message')
|
||||
if (msg?.text.trim()) return msg.text.trim()
|
||||
}
|
||||
return 'SOAP fault returned by Workday'
|
||||
}
|
||||
|
||||
async function callOperation(
|
||||
operation: WorkdayOperation,
|
||||
args: Record<string, unknown>,
|
||||
endpoint: string,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<[WorkdaySoapResult, string, Record<string, unknown>, string]> {
|
||||
const envelope = buildEnvelope(operation, args, username, password)
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
SOAPAction: `""`,
|
||||
},
|
||||
body: envelope,
|
||||
})
|
||||
|
||||
const responseText = await response.text()
|
||||
|
||||
let root: XmlNode
|
||||
try {
|
||||
root = parseXml(responseText)
|
||||
} catch (err) {
|
||||
logger.error('Failed to parse Workday SOAP response', {
|
||||
operation,
|
||||
status: response.status,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
throw new Error(
|
||||
`Workday returned an unparseable response (HTTP ${response.status}): ${responseText.slice(0, 500)}`
|
||||
)
|
||||
}
|
||||
|
||||
const fault = extractFaultMessage(root)
|
||||
if (fault) {
|
||||
throw new Error(fault)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Workday SOAP request failed (HTTP ${response.status})`)
|
||||
}
|
||||
|
||||
const responseElement = findFirst(root, `${operation}_Response`)
|
||||
const value = (responseElement ? nodeToValue(responseElement) : {}) as WorkdaySoapResult
|
||||
|
||||
return [value, responseText, {}, envelope]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a typed SOAP client for a Workday service. The returned object
|
||||
* exposes the same `<Operation>Async` methods the previous `soap`-library
|
||||
* client did, so existing call sites do not change. Internally this issues
|
||||
* SOAP-over-HTTP requests directly with hand-built envelopes and an XML
|
||||
* response parser — no WSDL fetch.
|
||||
*/
|
||||
export async function createWorkdaySoapClient(
|
||||
tenantUrl: string,
|
||||
@@ -157,17 +623,28 @@ export async function createWorkdaySoapClient(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<WorkdayClient> {
|
||||
const wsdlUrl = buildWsdlUrl(tenantUrl, tenant, service)
|
||||
logger.info('Creating Workday SOAP client', { service, wsdlUrl })
|
||||
const endpoint = buildServiceUrl(tenantUrl, tenant, service)
|
||||
logger.info('Creating Workday SOAP client', { service, endpoint })
|
||||
|
||||
const client = await soap.createClientAsync(wsdlUrl)
|
||||
client.setSecurity(new soap.BasicAuthSecurity(username, password))
|
||||
return client as WorkdayClient
|
||||
function bind(operation: WorkdayOperation): SoapOperationFn {
|
||||
return (args) => callOperation(operation, args, endpoint, username, password)
|
||||
}
|
||||
|
||||
return {
|
||||
Get_WorkersAsync: bind('Get_Workers'),
|
||||
Get_OrganizationsAsync: bind('Get_Organizations'),
|
||||
Put_ApplicantAsync: bind('Put_Applicant'),
|
||||
Hire_EmployeeAsync: bind('Hire_Employee'),
|
||||
Change_JobAsync: bind('Change_Job'),
|
||||
Terminate_EmployeeAsync: bind('Terminate_Employee'),
|
||||
Change_Personal_InformationAsync: bind('Change_Personal_Information'),
|
||||
Put_Onboarding_Plan_AssignmentAsync: bind('Put_Onboarding_Plan_Assignment'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Workday object reference in the format the SOAP API expects.
|
||||
* Generates: { ID: { attributes: { type: idType }, $value: idValue } }
|
||||
* Generates: { ID: { attributes: { 'wd:type': idType }, $value: idValue } }
|
||||
*/
|
||||
export function wdRef(idType: string, idValue: string): { ID: WorkdayIdEntry } {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user