@techspokes/typescript-wsdl-client 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +13 -0
  2. package/dist/checkedPipeline.d.ts +73 -2
  3. package/dist/checkedPipeline.js +118 -8
  4. package/dist/checkedPlanning.d.ts +5 -2
  5. package/dist/checkedPlanning.js +30 -5
  6. package/dist/checkedResponseInspection.d.ts +100 -0
  7. package/dist/checkedResponseInspection.js +499 -0
  8. package/dist/cli.js +55 -1
  9. package/dist/client/checkedDirectRuntime.d.ts +19 -1
  10. package/dist/client/checkedDirectRuntime.js +27 -2
  11. package/dist/client/directClientRuntime.d.ts +25 -1
  12. package/dist/client/directClientRuntime.js +23 -5
  13. package/dist/client/directClientVersion.d.ts +4 -0
  14. package/dist/client/directClientVersion.js +4 -0
  15. package/dist/client/generateDirectClient.js +14 -9
  16. package/dist/compiler/componentRecordProjection.d.ts +1 -0
  17. package/dist/compiler/componentRecordProjection.js +15 -4
  18. package/dist/compiler/directCodec.js +4 -12
  19. package/dist/compiler/directOperation.d.ts +2 -1
  20. package/dist/compiler/directOperation.js +27 -11
  21. package/dist/compiler/directSoap.d.ts +87 -12
  22. package/dist/compiler/directSoap.js +316 -94
  23. package/dist/compiler/directSoapSource.js +3 -2
  24. package/dist/compiler/directSourceCapsule.js +12 -3
  25. package/dist/compiler/soapHeaders.d.ts +10 -4
  26. package/dist/compiler/soapHeaders.js +28 -9
  27. package/dist/compiler/sourceCorrections.d.ts +118 -0
  28. package/dist/compiler/sourceCorrections.js +794 -0
  29. package/dist/gateway/generateCheckedGateway.js +27 -8
  30. package/dist/index.d.ts +3 -1
  31. package/dist/index.js +2 -1
  32. package/dist/util/readBoundedFile.d.ts +9 -0
  33. package/dist/util/readBoundedFile.js +36 -0
  34. package/dist/util/xmlBytes.d.ts +9 -0
  35. package/dist/util/xmlBytes.js +24 -0
  36. package/docs/api-reference.md +28 -1
  37. package/docs/checked-gateway.md +52 -0
  38. package/docs/checked-generation.md +4 -4
  39. package/docs/cli-reference.md +16 -1
  40. package/docs/migration.md +20 -0
  41. package/docs/start-here.md +1 -1
  42. package/docs/supported-patterns.md +6 -0
  43. package/package.json +1 -1
@@ -1,11 +1,11 @@
1
1
  import { isApplicationUnmetered } from './semanticBudget.js';
2
2
  import { directDataOwnership, directLimit } from './directExecutionPolicy.js';
3
- /** Shared retained-operation SOAP 1.1 exchange; profile-specific authority stays explicit. */
3
+ /** Shared retained-operation SOAP exchange; profile-specific authority stays explicit. */
4
4
  import { checkSemanticBudget } from './semanticBudget.js';
5
5
  import { DataOwnershipLimitError } from './dataOwnership.js';
6
6
  import { Agent } from 'node:http';
7
7
  import { Agent as HttpsAgent } from 'node:https';
8
- import { types } from 'node:util';
8
+ import { types, MIMEType } from 'node:util';
9
9
  import { WSDL } from 'soap/lib/wsdl/index.js';
10
10
  import { Client } from 'soap/lib/client.js';
11
11
  import { HttpClient } from 'soap/lib/http.js';
@@ -16,7 +16,7 @@ import { prepareDirectSoapSource, prepareComponentSoapWsdl, DirectSoapSourceErro
16
16
  import { isXmlNCName, resolveLexicalQName, SchemaLoadingError, syntaxElements, syntaxAttribute, WSDL_NAMESPACE } from '../loader/orderedSyntax.js';
17
17
  import { decodeComponentRecordXml, inspectComponentRecordProjection } from './componentRecordProjection.js';
18
18
  import { assessComponentSoapHeader } from './soapHeaders.js';
19
- const SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
19
+ const SOAP11 = 'http://schemas.xmlsoap.org/soap/envelope/';
20
20
  class Stop extends Error {
21
21
  kind;
22
22
  rule;
@@ -44,7 +44,7 @@ export function directSoapOptionsShape(options, check, additional = []) {
44
44
  /** Authentication and fresh-value validation precede dependency bootstrap and all HTTP I/O. */
45
45
  export async function exchangeDirectSoap(contract, inputMapping, application, options) {
46
46
  const input = inspectDirectValueMapping(inputMapping, contract);
47
- return exchangeSoap(contract, input, application, options, {
47
+ const result = await exchangeSoap(contract, input, application, options, {
48
48
  component: false, encode: encodeDirectXml, decode: decodeDirectXml, create: createDirectValueMapping,
49
49
  prepare: async (selection, check) => {
50
50
  const { source } = inspectDirectOperation(contract);
@@ -54,13 +54,16 @@ export async function exchangeDirectSoap(contract, inputMapping, application, op
54
54
  return wsdl;
55
55
  },
56
56
  });
57
+ if (result.kind === 'soap-fault' && !('decoded' in result))
58
+ throw new TypeError('Component fault on direct profile');
59
+ return result;
57
60
  }
58
- export async function exchangeComponentSoap(contract, inputMapping, application, options, responseProjection) {
61
+ export async function exchangeComponentSoap(contract, inputMapping, application, options, responseProjection, soap11ProtocolFaults = true) {
59
62
  const { mapping: input } = inspectComponentValueMapping(inputMapping, contract);
60
63
  if (responseProjection !== undefined) {
61
64
  inspectComponentRecordProjection(responseProjection, contract);
62
65
  return exchangeSoap(contract, input, application, options, {
63
- component: true,
66
+ component: true, soap11ProtocolFaults,
64
67
  encode(owner, mapping, value, controls) {
65
68
  if (mapping.kind !== 'component-mapping')
66
69
  throw new TypeError('SOAP input requires the native input mapping');
@@ -83,12 +86,13 @@ export async function exchangeComponentSoap(contract, inputMapping, application,
83
86
  });
84
87
  }
85
88
  return exchangeSoap(contract, input, application, options, {
86
- component: true, encode: encodeComponentXml, decode: decodeComponentXml,
89
+ component: true, soap11ProtocolFaults, encode: encodeComponentXml, decode: decodeComponentXml,
87
90
  create: (owner, direction, controls) => createComponentDirectionalMapping(owner, input, direction, controls),
88
91
  prepare: (selection, check) => prepareComponentSoapWsdl(contract, selection, check),
89
92
  });
90
93
  }
91
94
  async function exchangeSoap(contract, input, application, options, profile) {
95
+ const soap12 = profile.component && contract.binding.version === '1.2';
92
96
  const { context } = inspectDirectOperation(contract);
93
97
  if (input.direction.kind !== 'input')
94
98
  throw new TypeError('SOAP request requires the exact input mapping');
@@ -120,7 +124,7 @@ async function exchangeSoap(contract, input, application, options, profile) {
120
124
  const { source } = inspectDirectOperation(contract);
121
125
  const services = source.documents.flatMap(d => syntaxElements(d.root, WSDL_NAMESPACE, 'service').filter(n => syntaxAttribute(d.root, 'targetNamespace') === options.service?.namespace && syntaxAttribute(n, 'name') === options.service?.local));
122
126
  const ports = services.flatMap(n => syntaxElements(n, WSDL_NAMESPACE, 'port').filter(p => syntaxAttribute(p, 'name') === options.port));
123
- const addresses = ports.flatMap(p => syntaxElements(p, 'http://schemas.xmlsoap.org/wsdl/soap/', 'address'));
127
+ const addresses = ports.flatMap(p => syntaxElements(p, soap12 ? 'http://schemas.xmlsoap.org/wsdl/soap12/' : 'http://schemas.xmlsoap.org/wsdl/soap/', 'address'));
124
128
  if (services.length !== 1 || ports.length !== 1 || addresses.length !== 1 || !syntaxAttribute(addresses[0], 'location')) {
125
129
  return fail('unsupported', 'Select one SOAP service/port address or supply an endpoint override');
126
130
  }
@@ -174,10 +178,6 @@ async function exchangeSoap(contract, input, application, options, profile) {
174
178
  const rpc = contract.binding.style === 'rpc';
175
179
  if (/[^\x20-\x21\x23-\x7e]|\\/.test(action))
176
180
  return fail('unsupported', 'unsafe SOAPAction');
177
- const elements = (node) => {
178
- check();
179
- return node.children.filter((child) => child.kind === 'element');
180
- };
181
181
  const encoded = profile.encode(contract, input, application, { signal, limits });
182
182
  if (!('bytes' in encoded))
183
183
  return fail(encoded.kind, encoded.rule, 'input');
@@ -192,42 +192,7 @@ async function exchangeSoap(contract, input, application, options, profile) {
192
192
  return fail(mapping.kind, mapping.rule);
193
193
  faults.set(fault.name, mapping);
194
194
  }
195
- const shape = (node, encodedScope = false) => {
196
- const children = elements(node);
197
- if (node.attributes.some(a => !(encodedScope && a.name.namespace === SOAP && a.name.local === 'encodingStyle'
198
- && a.value === 'http://schemas.xmlsoap.org/soap/encoding/')) || node.children.some(n => n.kind === 'text' && /[^\t\r\n ]/.test(n.value)))
199
- stop('protocol', 'SOAP protocol attributes or text');
200
- return children;
201
- };
202
- const ignoredHeaders = [];
203
- const bodyRoot = (root, incoming = false) => {
204
- if (!same(root.name, { namespace: SOAP, local: 'Envelope' }))
205
- stop('protocol', 'SOAP 1.1 Envelope required');
206
- const children = shape(root, rpc);
207
- if (children[0]?.name.namespace === SOAP && children[0].name.local === 'Header') {
208
- if (profile.component && incoming) {
209
- const assessed = assessComponentSoapHeader(children[0], check);
210
- if (assessed.kind !== 'ignored-optional-headers')
211
- stop(assessed.kind, assessed.rule);
212
- else
213
- for (const name of assessed.headers) {
214
- check();
215
- ignoredHeaders.push(name);
216
- }
217
- }
218
- else if (shape(children[0]).length)
219
- stop('unsupported', 'incoming SOAP headers');
220
- children.shift();
221
- }
222
- if (children.length !== 1 || !same(children[0].name, { namespace: SOAP, local: 'Body' }))
223
- stop('protocol', 'one SOAP Body required');
224
- const body = shape(children[0], rpc);
225
- if (rpc && !body.some(n => same(n.name, { namespace: SOAP, local: 'Fault' })))
226
- return children[0];
227
- if (body.length !== 1)
228
- stop('protocol', 'one SOAP Body payload required');
229
- return body[0];
230
- };
195
+ const { bodyRoot } = soapProtocolSelection(contract, profile.component, check, stop);
231
196
  const wsdl = await profile.prepare(config, check);
232
197
  check();
233
198
  // @why node-soap's RPC writer would wrap `_xml` as a parameter. This private
@@ -255,7 +220,8 @@ async function exchangeSoap(contract, input, application, options, profile) {
255
220
  request(url, data, callback, headers, _options) {
256
221
  try {
257
222
  check();
258
- if (url !== endpoint.href || headers.SOAPAction !== `"${action}"` || headers['Content-Type'] !== 'text/xml; charset=utf-8')
223
+ if (url !== endpoint.href || (soap12 ? headers.SOAPAction !== undefined || headers['Content-Type'] !== requestContentType
224
+ : headers.SOAPAction !== `"${action}"` || headers['Content-Type'] !== 'text/xml; charset=utf-8'))
259
225
  stop('protocol', 'dependency dispatch mismatch');
260
226
  if (Buffer.byteLength(data) > limits.maxBytes)
261
227
  stop('resource-limit', 'SOAP request byte limit');
@@ -294,7 +260,8 @@ async function exchangeSoap(contract, input, application, options, profile) {
294
260
  maxBodyLength: limits.maxBytes, proxy: false, httpAgent: agent, httpsAgent: agent, signal: controller.signal, timeout: config.timeoutMs });
295
261
  }
296
262
  }
297
- const client = new Client(wsdl, endpoint.href, { httpClient: new BufferedHttp() });
263
+ const requestContentType = soap12 ? 'application/soap+xml; charset=utf-8' + (action ? `; action="${action}"` : '') : 'text/xml; charset=utf-8';
264
+ const client = new Client(wsdl, endpoint.href, { httpClient: new BufferedHttp(), ...(soap12 ? { forceSoap12Headers: true } : {}) });
298
265
  // Every lookup is own and explicit; the dependency's first-port shortcut is
299
266
  // never consulted. Source checks above bind these names to the native handle.
300
267
  const own = (object, key) => Object.getOwnPropertyDescriptor(object, key)?.value;
@@ -304,7 +271,7 @@ async function exchangeSoap(contract, input, application, options, profile) {
304
271
  if (typeof method !== 'function')
305
272
  return fail('unsupported', 'dependency selected method unavailable');
306
273
  check();
307
- await new Promise((resolve, reject) => method(rpc ? rpcArguments : { _xml: Buffer.from(encoded.bytes).toString('utf8') }, (error) => error === complete ? resolve() : reject(error), {}, { SOAPAction: `"${action}"` }));
274
+ await new Promise((resolve, reject) => method(rpc ? rpcArguments : { _xml: Buffer.from(encoded.bytes).toString('utf8') }, (error) => error === complete ? resolve() : reject(error), {}, soap12 ? { 'Content-Type': requestContentType } : { SOAPAction: `"${action}"` }));
308
275
  check();
309
276
  if (timedOut)
310
277
  return fail('transport', 'SOAP deadline exceeded');
@@ -313,35 +280,238 @@ async function exchangeSoap(contract, input, application, options, profile) {
313
280
  if (!response)
314
281
  return fail('transport', 'incomplete HTTP response');
315
282
  const observed = response;
316
- if (observed.encoding && observed.encoding.toLowerCase() !== 'identity')
317
- return fail('unsupported', 'HTTP compression');
318
- const media = /^text\/xml(?:\s*;\s*charset\s*=\s*(?:"(utf-8|utf-16|utf-16le|utf-16be|iso-8859-1)"|(utf-8|utf-16|utf-16le|utf-16be|iso-8859-1)))?\s*$/i.exec(observed.contentType);
319
- if (!media)
320
- return fail('protocol', 'SOAP response Content-Type');
321
- if (observed.status !== 200 && observed.status !== 500)
322
- return fail('transport', 'SOAP HTTP status');
323
- // Header charset is a protocol assertion, checked against actual BOM bytes.
324
- const charset = (media[1] ?? media[2])?.toLowerCase();
325
- if (!profile.component && charset === 'iso-8859-1')
326
- return fail('protocol', 'SOAP response Content-Type');
327
- const bom = observed.bytes[0] === 0xff && observed.bytes[1] === 0xfe ? 'utf-16le'
328
- : observed.bytes[0] === 0xfe && observed.bytes[1] === 0xff ? 'utf-16be' : 'utf-8';
329
- if (!profile.component && charset && charset !== bom && !(charset === 'utf-16' && bom.startsWith('utf-16')))
330
- return fail('protocol', 'SOAP charset disagrees with bytes');
283
+ const assessed = assessSoapResponse(contract, output, faults, observed.bytes, profile, { signal, limits, uri: endpoint.href,
284
+ responseMetadata: { status: observed.status, contentType: observed.contentType, contentEncoding: observed.encoding } });
285
+ if (!('admitted' in assessed))
286
+ return assessed;
287
+ if (assessed.decoded === undefined) {
288
+ if (!profile.component || !assessed.fault || 'name' in assessed.fault)
289
+ throw new TypeError('Missing decoded SOAP response');
290
+ return Object.freeze({ kind: 'soap-fault', admitted: false, fault: assessed.fault,
291
+ responseBytes: new Uint8Array(observed.bytes), status: observed.status, usage: assessed.usage,
292
+ ...(assessed.ignoredHeaders ? { ignoredHeaders: assessed.ignoredHeaders } : {}) });
293
+ }
294
+ if (assessed.fault && !('name' in assessed.fault))
295
+ throw new TypeError('Decoded fault requires its declared application mapping');
296
+ return Object.freeze({ kind: assessed.kind, admitted: false, decoded: assessed.decoded,
297
+ responseBytes: new Uint8Array(observed.bytes), status: observed.status, usage: assessed.usage,
298
+ ...(assessed.fault && 'name' in assessed.fault ? { fault: assessed.fault } : {}),
299
+ ...(assessed.ignoredHeaders ? { ignoredHeaders: assessed.ignoredHeaders } : {}) });
300
+ }
301
+ catch (error) {
302
+ if (budget.exhausted())
303
+ return fail('resource-limit', 'request-budget');
304
+ if (error instanceof DataOwnershipLimitError)
305
+ return fail('resource-limit', error.message);
306
+ if (signal?.aborted)
307
+ return fail('cancelled', 'cooperative-cancellation');
308
+ if (error instanceof DirectSoapSourceError)
309
+ return fail(error.kind, error.message);
310
+ if (error instanceof Stop)
311
+ return fail(error.kind, error.rule);
312
+ if (error instanceof SchemaLoadingError)
313
+ return fail(error.category === 'resource-limit' ? 'resource-limit' : error.category === 'invalid-schema' ? 'protocol' : 'unsupported', 'SOAP protocol XML');
314
+ if (error instanceof Error && 'code' in error) {
315
+ if (error.code === 'ERR_BAD_RESPONSE' && error.message.startsWith('maxContentLength size of '))
316
+ return fail('resource-limit', 'HTTP response byte limit');
317
+ return fail('transport', error.code === 'ERR_CANCELED' ? 'SOAP deadline exceeded' : 'HTTP exchange failed');
318
+ }
319
+ throw error;
320
+ }
321
+ finally {
322
+ if (deadline)
323
+ clearTimeout(deadline);
324
+ signal?.removeEventListener('abort', abort);
325
+ controller?.abort();
326
+ agent?.destroy();
327
+ }
328
+ }
329
+ /** Assess caller-observed response bytes through the same protocol and native decoder used by real exchanges.
330
+ * @param contract Authenticated selected component operation.
331
+ * @param inputMapping Exact operation input mapping, used only to derive response/fault mappings.
332
+ * @param bytes Actual observed SOAP entity bytes; no request or transport is constructed.
333
+ * @param options XML controls, optional actual HTTP metadata and exact response projection.
334
+ * @returns Native response/fault assessment or semantic failure; HTTP remains unassessed when metadata is absent.
335
+ * @throws TypeError For foreign native handles; RangeError for invalid explicit XML limits.
336
+ * @constraints This response-only helper performs no transport, endpoint authorization or request-value assessment.
337
+ */
338
+ export function inspectComponentSoapResponse(contract, inputMapping, bytes, options = {}) {
339
+ const { mapping: input } = inspectComponentValueMapping(inputMapping, contract);
340
+ const { context } = inspectDirectOperation(contract);
341
+ const failure = (kind, rule) => Object.freeze({ kind, rule, phase: 'response', usage: context.budget.usage() });
342
+ if (options.signal?.aborted)
343
+ return failure('cancelled', 'cooperative-cancellation');
344
+ const limits = { ...DIRECT_XML_LIMITS, ...options.limits };
345
+ for (const [key, value] of Object.entries(limits)) {
346
+ if (!['maxBytes', 'maxDepth', 'maxNodes', 'maxText'].includes(key) || !Number.isSafeInteger(value) || value < 1)
347
+ throw new RangeError('Invalid SOAP XML limit');
348
+ }
349
+ if (options.responseProjection)
350
+ inspectComponentRecordProjection(options.responseProjection, contract);
351
+ const output = options.responseProjection ?? createComponentDirectionalMapping(contract, input, { kind: 'output' }, { signal: options.signal });
352
+ if (!('direction' in output))
353
+ return output;
354
+ const faults = new Map();
355
+ for (const fault of contract.faults) {
356
+ const mapping = createComponentDirectionalMapping(contract, input, { kind: 'fault', name: fault.name }, { signal: options.signal });
357
+ if (mapping.kind !== 'component-mapping')
358
+ return mapping;
359
+ faults.set(fault.name, mapping);
360
+ }
361
+ return assessSoapResponse(contract, output, faults, bytes, { component: true, soap11ProtocolFaults: true,
362
+ decode(owner, mapping, observed, controls) {
363
+ if (mapping.kind === 'component-record-projection')
364
+ return decodeComponentRecordXml(owner, mapping, observed, controls);
365
+ return decodeComponentXml(owner, mapping, observed, { ...controls, selectPayload(root) {
366
+ const selected = controls.selectPayload(root);
367
+ if (selected.mapping.kind !== 'component-mapping')
368
+ throw new TypeError('Normal component decoding requires its exact mapping');
369
+ return { payload: selected.payload, mapping: selected.mapping };
370
+ } });
371
+ },
372
+ }, { ...options, limits });
373
+ }
374
+ /** Shared response episode for offline inspection and actual HTTP exchanges. */
375
+ function assessSoapResponse(contract, output, faults, bytes, profile, options) {
376
+ const { context } = inspectDirectOperation(contract), { budget } = context;
377
+ const signal = options.signal, limits = options.limits;
378
+ const soap12 = profile.component && contract.binding.version === '1.2';
379
+ const SOAP = soap12 ? 'http://www.w3.org/2003/05/soap-envelope' : SOAP11;
380
+ const fail = (kind, rule, phase) => Object.freeze({ kind, rule, ...(phase ? { phase } : {}), usage: Object.freeze({ ...budget.usage() }) });
381
+ const check = () => { checkSemanticBudget(budget); if (signal?.aborted)
382
+ throw new Stop('cancelled', 'cooperative-cancellation'); };
383
+ const stop = (kind, rule) => { throw new Stop(kind, rule); };
384
+ try {
385
+ check();
386
+ const { elements, shape, bodyRoot, ignoredHeaders } = soapProtocolSelection(contract, profile.component, check, stop);
387
+ let charset;
388
+ const metadata = options.responseMetadata;
389
+ if (metadata) {
390
+ if ((metadata.contentEncoding ?? '') && (metadata.contentEncoding ?? '').toLowerCase() !== 'identity')
391
+ return fail('unsupported', 'HTTP compression');
392
+ let soap12Charset;
393
+ let validSoap12Media = false;
394
+ if (soap12) {
395
+ try {
396
+ const mime = new MIMEType(metadata.contentType);
397
+ soap12Charset = mime.params.get('charset')?.toLowerCase();
398
+ validSoap12Media = mime.essence === 'application/soap+xml' && (!soap12Charset || ['utf-8', 'utf-16', 'utf-16le', 'utf-16be', 'iso-8859-1'].includes(soap12Charset));
399
+ }
400
+ catch { /* Invalid media types are refused below. */ }
401
+ }
402
+ const media = soap12 ? validSoap12Media ? ['', soap12Charset] : null : /^text\/xml(?:\s*;\s*charset\s*=\s*(?:"(utf-8|utf-16|utf-16le|utf-16be|iso-8859-1)"|(utf-8|utf-16|utf-16le|utf-16be|iso-8859-1)))?\s*$/i.exec(metadata.contentType);
403
+ if (!media)
404
+ return fail('protocol', 'SOAP response Content-Type');
405
+ if (metadata.status !== 200 && metadata.status !== 500 && !(soap12 && metadata.status === 400))
406
+ return fail('transport', 'SOAP HTTP status');
407
+ // Header charset is a protocol assertion, checked against actual BOM bytes.
408
+ charset = (media[1] ?? media[2])?.toLowerCase();
409
+ if (!profile.component && charset === 'iso-8859-1')
410
+ return fail('protocol', 'SOAP response Content-Type');
411
+ const bom = bytes[0] === 0xff && bytes[1] === 0xfe ? 'utf-16le'
412
+ : bytes[0] === 0xfe && bytes[1] === 0xff ? 'utf-16be' : 'utf-8';
413
+ if (!profile.component && charset && charset !== bom && !(charset === 'utf-16' && bom.startsWith('utf-16')))
414
+ return fail('protocol', 'SOAP charset disagrees with bytes');
415
+ }
331
416
  let faultInfo;
332
- const decoded = profile.decode(contract, output, observed.bytes, { signal, limits, uri: endpoint.href,
417
+ let protocolFault;
418
+ const protocolFaultSignal = new Error('SOAP protocol fault assessed');
419
+ const decodeResponse = () => profile.decode(contract, output, bytes, { signal, limits, uri: options.uri ?? 'urn:typescript-wsdl-client:observed-soap-response',
333
420
  ...(profile.component ? { componentResponseEncoding: { charset } } : {}),
334
421
  selectPayload: root => {
335
422
  const payload = bodyRoot(root, true);
336
423
  if (!same(payload.name, { namespace: SOAP, local: 'Fault' })) {
337
- if (observed.status !== 200)
424
+ if (options.responseMetadata && options.responseMetadata.status !== 200)
338
425
  stop('protocol', 'HTTP 500 requires SOAP Fault');
339
426
  return { payload, mapping: output };
340
427
  }
428
+ if (soap12) {
429
+ const children = shape(payload);
430
+ const names = children.map(n => n.name.namespace === SOAP ? n.name.local : '');
431
+ if (!/^Code,Reason(?:,Node)?(?:,Role)?(?:,Detail)?$/.test(names.join(',')))
432
+ stop('protocol', 'SOAP 1.2 Fault children');
433
+ const scalar = (node, language = false) => {
434
+ check();
435
+ if (elements(node).length || node.attributes.some(a => !(language && a.name.namespace === 'http://www.w3.org/XML/1998/namespace' && a.name.local === 'lang')))
436
+ stop('protocol', 'SOAP 1.2 Fault scalar child');
437
+ return node.children.map(n => n.kind === 'text' ? n.value : '').join('');
438
+ };
439
+ const qname = (node) => {
440
+ const lexical = scalar(node);
441
+ try {
442
+ const name = resolveLexicalQName(lexical, node);
443
+ return name;
444
+ }
445
+ catch (error) {
446
+ if (error instanceof SchemaLoadingError)
447
+ return stop('protocol', 'SOAP Fault code QName');
448
+ throw error;
449
+ }
450
+ };
451
+ const codes = [];
452
+ let codeNode;
453
+ let codeContainer = children[0];
454
+ while (codeContainer) {
455
+ check();
456
+ const nested = shape(codeContainer);
457
+ if (nested.length < 1 || nested.length > 2 || !same(nested[0].name, { namespace: SOAP, local: 'Value' })
458
+ || nested[1] && !same(nested[1].name, { namespace: SOAP, local: 'Subcode' }))
459
+ stop('protocol', 'SOAP 1.2 Fault Code');
460
+ codeNode ??= nested[0];
461
+ codes.push(qname(nested[0]));
462
+ codeContainer = nested[1];
463
+ }
464
+ const code = codes[0];
465
+ if (code.namespace !== SOAP || !['VersionMismatch', 'MustUnderstand', 'DataEncodingUnknown', 'Sender', 'Receiver'].includes(code.local))
466
+ stop('protocol', 'SOAP 1.2 Fault code');
467
+ if (options.responseMetadata?.status === 400 && code.local !== 'Sender')
468
+ stop('protocol', 'HTTP 400 requires SOAP Sender Fault');
469
+ const reasonChildren = shape(children[1]);
470
+ if (!reasonChildren.length)
471
+ stop('protocol', 'SOAP 1.2 Fault Reason');
472
+ const reasons = reasonChildren.map(node => {
473
+ check();
474
+ if (!same(node.name, { namespace: SOAP, local: 'Text' }))
475
+ stop('protocol', 'SOAP 1.2 Fault Reason Text');
476
+ const text = scalar(node, true), lang = syntaxAttribute(node, 'lang', 'http://www.w3.org/XML/1998/namespace');
477
+ if (lang === undefined || !/^[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*$/.test(lang))
478
+ stop('protocol', 'SOAP 1.2 Fault language');
479
+ return { lang: lang, text };
480
+ });
481
+ const uriField = (local) => {
482
+ const node = children.find(n => n.name.local === local);
483
+ if (!node)
484
+ return undefined;
485
+ const value = scalar(node).trim();
486
+ if (!/^[A-Za-z][A-Za-z0-9+.-]*:[^\s]*$/.test(value))
487
+ stop('protocol', 'SOAP 1.2 Fault URI');
488
+ return value;
489
+ };
490
+ const node = uriField('Node'), role = uriField('Role');
491
+ const metadata = { version: '1.2', code, subcodes: codes.slice(1), reasons, string: reasons[0].text,
492
+ ...(node === undefined ? {} : { node }), ...(role === undefined ? {} : { role }) };
493
+ const detailNode = children.find(n => n.name.local === 'Detail');
494
+ // @see SOAP 1.2 section 5.4.5 permits attributes on Detail; these protocol-wrapper attributes stay in the original bytes.
495
+ if (detailNode?.children.some(n => n.kind === 'text' && /[^\t\r\n ]/.test(n.value)))
496
+ stop('protocol', 'SOAP 1.2 Fault Detail text');
497
+ const detail = detailNode ? elements(detailNode) : [];
498
+ const matches = contract.faults.filter(f => detail.some(n => same(f.root.element.name, n.name)));
499
+ if (matches.length) {
500
+ if (matches.length !== 1 || detail.length !== 1)
501
+ stop('protocol', 'ambiguous SOAP Fault detail');
502
+ faultInfo = directDataOwnership(budget).freeze({ ...metadata, name: matches[0].name, codeNode: codeNode });
503
+ return { payload: detail[0], mapping: faults.get(faultInfo.name) };
504
+ }
505
+ protocolFault = directDataOwnership(budget).freeze(metadata);
506
+ // @constraints Only protocol grammar was assessed; no fabricated application receipt is published.
507
+ throw protocolFaultSignal;
508
+ }
341
509
  const children = shape(payload);
342
510
  const names = children.map(n => n.name.namespace === '' ? n.name.local : '');
343
- if (names.join(',') !== 'faultcode,faultstring,detail' && names.join(',') !== 'faultcode,faultstring,faultactor,detail')
511
+ const hasDetail = names.at(-1) === 'detail';
512
+ if (!/^faultcode,faultstring(?:,faultactor)?(?:,detail)?$/.test(names.join(',')) || !hasDetail && !profile.soap11ProtocolFaults) {
344
513
  stop('protocol', 'SOAP Fault children');
514
+ }
345
515
  const text = (node) => {
346
516
  if (node.attributes.some(a => !(node.name.local === 'faultstring' && a.name.namespace === 'http://www.w3.org/XML/1998/namespace' && a.name.local === 'lang')) || elements(node).length)
347
517
  stop('protocol', 'SOAP Fault scalar child');
@@ -360,49 +530,101 @@ async function exchangeSoap(contract, input, application, options, profile) {
360
530
  stop('protocol', 'SOAP Fault code namespace required');
361
531
  const string = text(children[1]);
362
532
  const actor = names[2] === 'faultactor' ? text(children[2]) : undefined;
363
- const detail = shape(children.at(-1));
364
- if (detail.length !== 1)
533
+ const detail = hasDetail ? shape(children.at(-1)) : [];
534
+ if (!profile.soap11ProtocolFaults && detail.length !== 1)
365
535
  stop('protocol', 'one declared SOAP Fault detail required');
366
- const matches = contract.faults.filter(f => same(f.root.element.name, detail[0].name));
367
- if (matches.length !== 1)
536
+ const matches = contract.faults.filter(f => detail.some(node => same(f.root.element.name, node.name)));
537
+ if (!matches.length && profile.soap11ProtocolFaults) {
538
+ protocolFault = directDataOwnership(budget).freeze({ version: '1.1', code, string, ...(actor === undefined ? {} : { actor }) });
539
+ throw protocolFaultSignal;
540
+ }
541
+ if (detail.length !== 1 || matches.length !== 1)
368
542
  stop('protocol', 'unknown or ambiguous SOAP Fault detail');
369
543
  faultInfo = { name: matches[0].name, code, codeNode: children[0], string, ...(actor === undefined ? {} : { actor }) };
370
544
  directDataOwnership(budget).freeze(faultInfo);
371
545
  return { payload: detail[0], mapping: faults.get(faultInfo.name) };
372
546
  } });
547
+ let decoded;
548
+ try {
549
+ decoded = decodeResponse();
550
+ }
551
+ catch (error) {
552
+ if (error !== protocolFaultSignal)
553
+ throw error;
554
+ }
555
+ if (protocolFault && decoded === undefined) {
556
+ check();
557
+ return Object.freeze({ kind: 'soap-fault', admitted: false, fault: protocolFault,
558
+ ...(ignoredHeaders.length ? { ignoredHeaders: Object.freeze(ignoredHeaders) } : {}), usage: Object.freeze({ ...budget.usage() }) });
559
+ }
560
+ if (!decoded)
561
+ throw new TypeError('Missing decoded SOAP response');
373
562
  if (!('validated' in decoded))
374
- return fail(decoded.kind, decoded.rule, 'response');
563
+ return profile.component ? Object.freeze({ ...decoded, phase: 'response' }) : fail(decoded.kind, decoded.rule);
375
564
  check();
376
565
  check();
377
566
  return Object.freeze({ kind: faultInfo ? 'soap-fault' : 'soap-response', admitted: false, decoded,
378
- responseBytes: new Uint8Array(observed.bytes), status: observed.status, ...(faultInfo ? { fault: faultInfo } : {}),
567
+ ...(faultInfo ? { fault: faultInfo } : {}),
379
568
  ...(ignoredHeaders.length ? { ignoredHeaders: Object.freeze(ignoredHeaders) } : {}), usage: Object.freeze({ ...budget.usage() }) });
380
569
  }
381
570
  catch (error) {
382
571
  if (budget.exhausted())
383
- return fail('resource-limit', 'request-budget');
572
+ return fail('resource-limit', 'response-budget', 'response');
384
573
  if (error instanceof DataOwnershipLimitError)
385
- return fail('resource-limit', error.message);
574
+ return fail('resource-limit', error.message, 'response');
386
575
  if (signal?.aborted)
387
- return fail('cancelled', 'cooperative-cancellation');
388
- if (error instanceof DirectSoapSourceError)
389
- return fail(error.kind, error.message);
576
+ return fail('cancelled', 'cooperative-cancellation', 'response');
390
577
  if (error instanceof Stop)
391
- return fail(error.kind, error.rule);
578
+ return fail(error.kind, error.rule, 'response');
392
579
  if (error instanceof SchemaLoadingError)
393
- return fail(error.category === 'resource-limit' ? 'resource-limit' : error.category === 'invalid-schema' ? 'protocol' : 'unsupported', 'SOAP protocol XML');
394
- if (error instanceof Error && 'code' in error) {
395
- if (error.code === 'ERR_BAD_RESPONSE' && error.message.startsWith('maxContentLength size of '))
396
- return fail('resource-limit', 'HTTP response byte limit');
397
- return fail('transport', error.code === 'ERR_CANCELED' ? 'SOAP deadline exceeded' : 'HTTP exchange failed');
398
- }
580
+ return fail(error.category === 'resource-limit' ? 'resource-limit' : error.category === 'invalid-schema' ? 'protocol' : 'unsupported', 'SOAP protocol XML', 'response');
399
581
  throw error;
400
582
  }
401
- finally {
402
- if (deadline)
403
- clearTimeout(deadline);
404
- signal?.removeEventListener('abort', abort);
405
- controller?.abort();
406
- agent?.destroy();
407
- }
583
+ }
584
+ /** Select protocol-owned nodes without relabeling observed XML or granting value authority. */
585
+ function soapProtocolSelection(contract, component, check, stop) {
586
+ const soap12 = component && contract.binding.version === '1.2';
587
+ const SOAP = soap12 ? 'http://www.w3.org/2003/05/soap-envelope' : SOAP11;
588
+ const rpc = contract.binding.style === 'rpc';
589
+ const elements = (node) => {
590
+ check();
591
+ return node.children.filter((child) => child.kind === 'element');
592
+ };
593
+ const shape = (node, encodedScope = false) => {
594
+ const children = elements(node);
595
+ if (node.attributes.some(a => !(encodedScope && a.name.namespace === SOAP && a.name.local === 'encodingStyle'
596
+ && a.value === 'http://schemas.xmlsoap.org/soap/encoding/')) || node.children.some(n => n.kind === 'text' && /[^\t\r\n ]/.test(n.value)))
597
+ stop('protocol', 'SOAP protocol attributes or text');
598
+ return children;
599
+ };
600
+ const ignoredHeaders = [];
601
+ const bodyRoot = (root, incoming = false) => {
602
+ if (!same(root.name, { namespace: SOAP, local: 'Envelope' }))
603
+ stop('protocol', `SOAP ${contract.binding.version} Envelope required`);
604
+ const children = shape(root, rpc);
605
+ if (children[0]?.name.namespace === SOAP && children[0].name.local === 'Header') {
606
+ if (component && incoming) {
607
+ const assessed = assessComponentSoapHeader(children[0], check, contract.binding.version);
608
+ if (assessed.kind !== 'ignored-optional-headers')
609
+ stop(assessed.kind, assessed.rule);
610
+ else
611
+ for (const name of assessed.headers) {
612
+ check();
613
+ ignoredHeaders.push(name);
614
+ }
615
+ }
616
+ else if (shape(children[0]).length)
617
+ stop('unsupported', 'incoming SOAP headers');
618
+ children.shift();
619
+ }
620
+ if (children.length !== 1 || !same(children[0].name, { namespace: SOAP, local: 'Body' }))
621
+ stop('protocol', 'one SOAP Body required');
622
+ const body = shape(children[0], rpc);
623
+ if (rpc && !body.some(n => same(n.name, { namespace: SOAP, local: 'Fault' })))
624
+ return children[0];
625
+ if (body.length !== 1)
626
+ stop('protocol', 'one SOAP Body payload required');
627
+ return body[0];
628
+ };
629
+ return { elements, shape, bodyRoot, ignoredHeaders };
408
630
  }
@@ -16,6 +16,7 @@ export class DirectSoapSourceError extends Error {
16
16
  }
17
17
  }
18
18
  function validateSoapSelection(source, contract, config, check) {
19
+ const addressNamespace = contract.binding.version === '1.2' ? 'http://schemas.xmlsoap.org/wsdl/soap12/' : WSDL_SOAP;
19
20
  const elements = (node) => { check(); return node.children.filter((child) => child.kind === 'element'); };
20
21
  const definitions = source.root.root;
21
22
  if (definitions.name.namespace !== WSDL_NAMESPACE || definitions.name.local !== 'definitions')
@@ -30,8 +31,8 @@ function validateSoapSelection(source, contract, config, check) {
30
31
  const binding = syntaxAttribute(ports[0], 'binding');
31
32
  if (!binding || !same(resolveLexicalQName(binding, ports[0]), contract.selector.binding))
32
33
  throw new DirectSoapSourceError('SOAP binding mismatch');
33
- const addresses = elements(ports[0]).filter(n => n.name.namespace === WSDL_SOAP && n.name.local === 'address');
34
- if (addresses.length !== 1 || elements(ports[0]).some(n => !(n.name.namespace === WSDL_SOAP && n.name.local === 'address')
34
+ const addresses = elements(ports[0]).filter(n => n.name.namespace === addressNamespace && n.name.local === 'address');
35
+ if (addresses.length !== 1 || elements(ports[0]).some(n => !(n.name.namespace === addressNamespace && n.name.local === 'address')
35
36
  && !(n.name.namespace === WSDL_NAMESPACE && n.name.local === 'documentation')))
36
37
  throw new DirectSoapSourceError('SOAP port extension');
37
38
  }
@@ -316,6 +316,8 @@ export function checkDirectSourceCompatibility(expected, contract, selection, si
316
316
  const componentVersions = Object.freeze({ ...versions, scope: 'component-soap11-utf8-graph-v1', mapping: 'component-values-v1',
317
317
  runtimePolicy: 'explicit-http-verified-https-transport-v1' });
318
318
  const scopedComponentVersions = Object.freeze({ ...componentVersions, scope: 'component-soap11-utf8-graph-v2', mapping: 'component-values-and-record-scope-v1' });
319
+ const soap12ComponentVersions = Object.freeze({ ...componentVersions, scope: 'component-soap12-document-literal-v1' });
320
+ const scopedSoap12ComponentVersions = Object.freeze({ ...scopedComponentVersions, scope: 'component-soap12-document-literal-record-v1' });
319
321
  const rpcComponentVersions = Object.freeze({ ...componentVersions, scope: 'component-soap11-rpc-strings-v1', mapping: 'component-rpc-string-values-v1' });
320
322
  const typedRpcComponentVersions = Object.freeze({ ...componentVersions, scope: 'component-soap11-rpc-typed-latin1-v1', mapping: 'component-rpc-typed-values-v1' });
321
323
  const componentReconstructions = new WeakMap();
@@ -380,6 +382,7 @@ async function componentDescriptor(contract, selection, signal, responseProjecti
380
382
  const input = make({ kind: 'input' }), output = make({ kind: 'output' });
381
383
  const faults = contract.faults.map(fault => ({ name: fault.name, root: make({ kind: 'fault', name: fault.name }) }));
382
384
  const compatibility = { selector: contract.selector, service: owned.service, port: owned.port,
385
+ ...(contract.binding.version === '1.2' ? { binding: { version: '1.2', actionRequired: contract.binding.actionRequired } } : {}),
383
386
  action: contract.binding.action, input, output, faults };
384
387
  let projection = null;
385
388
  if (responseProjection) {
@@ -431,7 +434,7 @@ function componentCapsule(derived, budget) {
431
434
  const typedRpc = rpc && compatibility.input.parts.some(part => part.type.namespace !== 'http://www.w3.org/2001/XMLSchema' || part.type.local !== 'string');
432
435
  if (rpc && (extended || derived.applicationGroups))
433
436
  throw new Stop('unsupported', 'rpc-artifact-extension');
434
- const value = { versions: typedRpc ? typedRpcComponentVersions : rpc ? rpcComponentVersions : extended ? scopedComponentVersions : componentVersions, resources, compatibility,
437
+ const value = { versions: compatibility.binding?.version === '1.2' ? extended ? scopedSoap12ComponentVersions : soap12ComponentVersions : typedRpc ? typedRpcComponentVersions : rpc ? rpcComponentVersions : extended ? scopedComponentVersions : componentVersions, resources, compatibility,
435
438
  ...(extended ? { libraries, projection } : {}), ...(derived.applicationGroups ? { applicationGroups: derived.applicationGroups, applicationGroupIdentity: derived.applicationGroupIdentity } : {}) };
436
439
  return { ...value, fingerprint: hash(json(value, budget)) };
437
440
  }
@@ -493,7 +496,8 @@ function readComponent(bytes, budget, signal) {
493
496
  const rpc = typedRpc || !!parsed.versions && typeof parsed.versions === 'object' && parsed.versions.scope === rpcComponentVersions.scope;
494
497
  if (rpc && (extended || grouped))
495
498
  reject('rpc-artifact-extension');
496
- const expectedVersions = typedRpc ? typedRpcComponentVersions : rpc ? rpcComponentVersions : extended ? scopedComponentVersions : componentVersions;
499
+ const soap12 = !!parsed.versions && typeof parsed.versions === 'object' && ['component-soap12-document-literal-v1', 'component-soap12-document-literal-record-v1'].includes(parsed.versions.scope);
500
+ const expectedVersions = soap12 ? extended ? scopedSoap12ComponentVersions : soap12ComponentVersions : typedRpc ? typedRpcComponentVersions : rpc ? rpcComponentVersions : extended ? scopedComponentVersions : componentVersions;
497
501
  const actual = record(parsed.versions, Object.keys(expectedVersions));
498
502
  for (const key of Object.keys(expectedVersions))
499
503
  if (actual[key] !== expectedVersions[key])
@@ -564,7 +568,12 @@ function readComponent(bytes, budget, signal) {
564
568
  if (!parsed.libraries.length && parsed.projection === null)
565
569
  reject('empty-component-extension');
566
570
  }
567
- const c = record(parsed.compatibility, ['selector', 'service', 'port', 'action', 'input', 'output', 'faults']);
571
+ const c = record(parsed.compatibility, ['selector', 'service', 'port', 'action', 'input', 'output', 'faults', ...(soap12 ? ['binding'] : [])]);
572
+ if (soap12) {
573
+ const binding = record(c.binding, ['version', 'actionRequired']);
574
+ if (binding.version !== '1.2' || typeof binding.actionRequired !== 'boolean')
575
+ reject('soap12-binding');
576
+ }
568
577
  selector(c.selector);
569
578
  name(c.service);
570
579
  ncname(c.port);