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:
Waleed
2026-05-08 23:21:09 -07:00
committed by GitHub
parent cb3a876d3e
commit 1d3ca79779
9 changed files with 726 additions and 695 deletions
@@ -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,
+21 -4
View File
@@ -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>
-3
View File
@@ -81,9 +81,6 @@ const nextConfig: NextConfig = {
'unpdf',
'ffmpeg-static',
'fluent-ffmpeg',
'pino',
'pino-pretty',
'thread-stream',
'ws',
'isolated-vm',
],
+25 -36
View File
@@ -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"
+1 -2
View File
@@ -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
View File
@@ -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> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;',
}
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 {
+159 -618
View File
File diff suppressed because it is too large Load Diff