sap-ai-dev-toolkit 0.9.9917 → 0.9.9920

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 (59) hide show
  1. package/.github/agents/sap-integration-architect.agent.md +32 -18
  2. package/.github/agents/sap-integration-developer.agent.md +28 -12
  3. package/.github/skills/integration-suite-delivery/SKILL.md +94 -37
  4. package/.github/skills/sap-integration-architect/SKILL.md +49 -25
  5. package/README.md +73 -14
  6. package/dist/checksums.txt +5 -5
  7. package/dist/vsp-darwin-arm64 +0 -0
  8. package/dist/vsp-darwin-x64 +0 -0
  9. package/dist/vsp-linux-arm64 +0 -0
  10. package/dist/vsp-linux-x64 +0 -0
  11. package/dist/vsp-win32-x64.exe +0 -0
  12. package/inventory.md +97 -57
  13. package/package.json +1 -1
  14. package/scripts/check-live-integration.mjs +151 -0
  15. package/scripts/install-local.mjs +11 -4
  16. package/scripts/run-live-integration.mjs +52 -60
  17. package/src/apim-bundle.mjs +416 -13
  18. package/src/apim-cleanup-tools.mjs +145 -0
  19. package/src/apim-content.mjs +157 -0
  20. package/src/apim-delete-tools.mjs +139 -0
  21. package/src/apim-documentation.mjs +162 -0
  22. package/src/apim-inspection.mjs +141 -0
  23. package/src/apim-monitoring.mjs +140 -0
  24. package/src/apim-policy-steps.mjs +31 -0
  25. package/src/apim-runtime.mjs +262 -0
  26. package/src/apim-tools.mjs +202 -109
  27. package/src/cf-integration.mjs +46 -7
  28. package/src/cpi-async-monitoring.mjs +177 -0
  29. package/src/cpi-content.mjs +92 -0
  30. package/src/cpi-monitoring.mjs +225 -0
  31. package/src/cpi-operational.mjs +129 -0
  32. package/src/cpi-release.mjs +273 -0
  33. package/src/cpi-security.mjs +48 -0
  34. package/src/cpi-tools.mjs +184 -319
  35. package/src/devhub-retirement.mjs +345 -0
  36. package/src/devhub-tools.mjs +177 -0
  37. package/src/edge-monitoring.mjs +101 -0
  38. package/src/iflow-bundle.mjs +295 -7
  39. package/src/integration-alerts.mjs +248 -0
  40. package/src/integration-analysis.mjs +488 -0
  41. package/src/integration-capabilities.mjs +181 -0
  42. package/src/integration-collections.mjs +203 -0
  43. package/src/integration-common.mjs +140 -35
  44. package/src/integration-config.mjs +20 -14
  45. package/src/integration-credentials.mjs +16 -10
  46. package/src/integration-http.mjs +255 -67
  47. package/src/integration-messaging.mjs +118 -0
  48. package/src/integration-optional-config.mjs +122 -0
  49. package/src/integration-optional-tools.mjs +153 -0
  50. package/src/integration-release-store.mjs +410 -0
  51. package/src/integration-server.mjs +34 -16
  52. package/src/integration-state.mjs +31 -0
  53. package/src/integration-tools.mjs +44 -8
  54. package/src/integration-transport.mjs +184 -0
  55. package/src/integration-validation.mjs +26 -0
  56. package/src/integration-verify.mjs +143 -62
  57. package/src/open-connectors-tools.mjs +143 -0
  58. package/src/setup.mjs +66 -44
  59. package/tools.md +193 -26
@@ -19,7 +19,13 @@ export function xmlEscape(value) {
19
19
  }
20
20
 
21
21
  function xmlUnescape(value) {
22
- return value.replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&quot;', '"').replaceAll('&apos;', "'").replaceAll('&amp;', '&');
22
+ return value.replace(/&(?:#x([0-9a-f]+)|#([0-9]+)|(lt|gt|quot|apos|amp));/gi, (match, hex, decimal, named) => {
23
+ if (hex || decimal) {
24
+ const point = Number.parseInt(hex ?? decimal, hex ? 16 : 10);
25
+ return point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff) ? String.fromCodePoint(point) : match;
26
+ }
27
+ return { lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' }[named.toLowerCase()];
28
+ });
23
29
  }
24
30
 
25
31
  function proxyXml({ name, title, description, serviceType }) {
@@ -95,7 +101,7 @@ function locate(xml, path) {
95
101
  let element = childElements(xml, 0, xml.length)[0];
96
102
  if (!element) throw new IntegrationError('API proxy bundle XML has no root element');
97
103
  for (const name of path) {
98
- element = children(xml, element).find(child => child.name === name);
104
+ element = children(xml, element).find(child => child.name.split(':').at(-1) === name);
99
105
  if (!element) return undefined;
100
106
  }
101
107
  return element;
@@ -145,24 +151,28 @@ function registerRootEntry(xml, listTag, entryTag, type, name, before = []) {
145
151
 
146
152
  function rootEntryNames(xml, listTag, entryTag) {
147
153
  const listElement = locate(xml, [listTag]);
148
- return listElement ? children(xml, listElement).filter(child => child.name === entryTag).map(child => textOf(xml, child)) : [];
154
+ return listElement ? children(xml, listElement).filter(child => child.name.split(':').at(-1) === entryTag).map(child => textOf(xml, child)) : [];
149
155
  }
150
156
 
151
- /** Append a policy step to an endpoint flow (created when missing); an existing step for the policy is kept. */
157
+ /** Append a step, or update only an explicitly supplied condition on its exact assignment. */
152
158
  function addStep(xml, flowTag, phase, policyName, condition) {
153
159
  const flowName = flowTag === 'preFlow' ? 'PreFlow' : 'PostFlow';
160
+ selectedSteps(xml, flowTag === 'preFlow' ? 'pre' : 'post', phase);
154
161
  xml = ensureChild(xml, [], flowTag, `<name>${flowName}</name>`, flowTag === 'preFlow' ? ['postFlow', 'conditionalFlows'] : ['conditionalFlows']);
155
162
  xml = ensureChild(xml, [flowTag], phase, `<isRequest>${phase === 'request'}</isRequest><steps></steps>`, phase === 'request' ? ['response'] : []);
156
163
  xml = ensureChild(xml, [flowTag, phase], 'steps');
157
164
  const steps = locate(xml, [flowTag, phase, 'steps']);
158
- let sequence = 0;
159
- for (const step of children(xml, steps).filter(child => child.name === 'step')) {
160
- const fields = children(xml, step);
161
- const field = name => fields.find(child => child.name === name);
162
- if (field('policy_name') && textOf(xml, field('policy_name')) === policyName) return { xml, added: false };
163
- const value = Number(field('sequence') ? textOf(xml, field('sequence')) : NaN);
164
- if (Number.isFinite(value)) sequence = Math.max(sequence, value);
165
+ const assignments = supportedSteps(xml, steps);
166
+ const matches = assignments.filter(step => step.policy_name === policyName);
167
+ if (matches.length > 1) throw new IntegrationError('API proxy has ambiguous duplicate policy assignments');
168
+ if (matches.length) {
169
+ const existing = matches[0];
170
+ if (condition === undefined) return { xml, added: false };
171
+ const changed = setStepField(xml.slice(existing.element.start, existing.element.end), 'condition', condition);
172
+ return { xml: splice(xml, existing.element.start, existing.element.end, changed), added: false };
165
173
  }
174
+ let sequence = 0;
175
+ for (const assignment of assignments) sequence = Math.max(sequence, assignment.sequence);
166
176
  const step = `<step><policy_name>${xmlEscape(policyName)}</policy_name><condition>${xmlEscape(condition ?? '')}</condition><sequence>${sequence + 1}</sequence></step>`;
167
177
  return { xml: splice(xml, steps.contentEnd, steps.contentEnd, step), added: true };
168
178
  }
@@ -203,6 +213,275 @@ export function bundlePolicyNames(files, proxyName) {
203
213
  return rootEntryNames(readText(files, proxyLayout(files, proxyName).root), 'policies', 'policy');
204
214
  }
205
215
 
216
+ /** Explicit HTTPS issuers from registered VerifyJWT policies, never unregistered files or variable refs. */
217
+ export function bundleJwtIssuers(files, proxyName) {
218
+ const layout = proxyLayout(files, proxyName);
219
+ const rootXml = readText(files, layout.root);
220
+ const localName = element => element.name.split(':').at(-1);
221
+ const policyLists = children(rootXml, locate(rootXml, [])).filter(element => localName(element) === 'policies');
222
+ if (policyLists.length > 1) throw new IntegrationError('API proxy bundle has ambiguous policy registrations');
223
+ const policies = policyLists[0];
224
+ const issuers = new Set();
225
+ for (const entry of policies ? children(rootXml, policies) : []) {
226
+ if (localName(entry) !== 'policy') continue;
227
+ const name = textOf(rootXml, entry);
228
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)) continue;
229
+ const path = `${layout.dir}Policy/${name}.xml`;
230
+ if (!files.has(path)) continue;
231
+ const xml = readText(files, path);
232
+ const root = locate(xml, []);
233
+ if (localName(root) !== 'VerifyJWT') continue;
234
+ for (const issuer of children(xml, root).filter(element => localName(element) === 'Issuer')) {
235
+ if (children(xml, issuer).length || /\bref\s*=/.test(xml.slice(issuer.start, issuer.contentStart))) continue;
236
+ const raw = xml.slice(issuer.contentStart, issuer.contentEnd).trim();
237
+ const value = raw.startsWith('<![CDATA[') && raw.endsWith(']]>') ? raw.slice(9, -3).trim() : textOf(xml, issuer);
238
+ if (/[{}\s]/.test(value)) continue;
239
+ try {
240
+ const url = new URL(value);
241
+ if (url.protocol === 'https:' && !url.username && !url.password && !url.hash && !url.search) issuers.add(value);
242
+ } catch { /* Dynamic or malformed issuer text is not a trusted endpoint. */ }
243
+ }
244
+ }
245
+ return [...issuers];
246
+ }
247
+
248
+ // Shared structural scanner for read-only inspection and surgical policy editing.
249
+ export { childElements, children as xmlChildren, locate as xmlLocate, textOf as xmlText, proxyLayout as apiProxyLayout };
250
+
251
+ const localName = element => element?.name?.split(':').at(-1) ?? '';
252
+ const oneChild = (xml, parent, name, required = false) => {
253
+ const matches = children(xml, parent).filter(child => localName(child) === name);
254
+ if (matches.length > 1 || (required && matches.length !== 1)) throw new IntegrationError(`Ambiguous API proxy policy layout: expected one ${name}`);
255
+ if (matches[0] && matches[0].name !== name) throw new IntegrationError('Unsupported namespaced API proxy assignment layout');
256
+ return matches[0];
257
+ };
258
+
259
+ function supportedSteps(xml, container) {
260
+ const elements = children(xml, container);
261
+ if (elements.some(element => element.name !== 'step')) throw new IntegrationError('Unsupported API proxy steps layout');
262
+ const result = elements.map(element => {
263
+ const policy = oneChild(xml, element, 'policy_name', true);
264
+ const condition = oneChild(xml, element, 'condition');
265
+ const sequence = oneChild(xml, element, 'sequence', true);
266
+ if (children(xml, element).some(child => !['policy_name', 'condition', 'sequence'].includes(child.name))
267
+ || [policy, condition, sequence].filter(Boolean).some(child => children(xml, child).length)) {
268
+ throw new IntegrationError('Unsupported API proxy step fields');
269
+ }
270
+ const policy_name = textOf(xml, policy);
271
+ const order = textOf(xml, sequence);
272
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,99}$/.test(policy_name) || !/^[1-9]\d*$/.test(order) || !Number.isSafeInteger(Number(order))) {
273
+ throw new IntegrationError('Unsupported API proxy step identity or sequence');
274
+ }
275
+ return { element, policy_name, condition: condition ? textOf(xml, condition) : '', sequence: Number(order) };
276
+ });
277
+ if (new Set(result.map(item => item.policy_name)).size !== result.length
278
+ || new Set(result.map(item => item.sequence)).size !== result.length) {
279
+ throw new IntegrationError('API proxy has ambiguous duplicate policy assignments or sequences');
280
+ }
281
+ if (result.some((item, index) => index && item.sequence <= result[index - 1].sequence)) {
282
+ throw new IntegrationError('API proxy step XML order disagrees with sequence order');
283
+ }
284
+ return result;
285
+ }
286
+
287
+ function setStepField(xml, name, value) {
288
+ const root = locate(xml, []);
289
+ const field = oneChild(xml, root, name);
290
+ if (field) return field.selfClosing ? expand(xml, field, xmlEscape(value))
291
+ : splice(xml, field.contentStart, field.contentEnd, xmlEscape(value));
292
+ return splice(xml, root.contentEnd, root.contentEnd, `<${name}>${xmlEscape(value)}</${name}>`);
293
+ }
294
+
295
+ function policyEditLayout(files, proxyName, endpoint, endpointName) {
296
+ const layout = proxyLayout(files, proxyName);
297
+ const rootXml = readText(files, layout.root);
298
+ const roots = [...files.keys()].filter(path => path.startsWith(layout.dir) && path.endsWith('.xml') && !path.slice(layout.dir.length).includes('/'));
299
+ if (roots.length !== 1 || locate(rootXml, []).name !== 'APIProxy'
300
+ || textOf(rootXml, oneChild(rootXml, locate(rootXml, []), 'name', true)) !== proxyName) {
301
+ throw new IntegrationError('Ambiguous API proxy root identity');
302
+ }
303
+ const folder = `${layout.dir}${endpoint === 'proxy' ? 'APIProxyEndPoint' : 'APITargetEndPoint'}/`;
304
+ const matches = [];
305
+ for (const path of files.keys()) {
306
+ if (!path.startsWith(folder) || !path.endsWith('.xml')) continue;
307
+ const xml = readText(files, path);
308
+ const root = locate(xml, []);
309
+ if (root.name !== (endpoint === 'proxy' ? 'ProxyEndPoint' : 'TargetEndPoint')) throw new IntegrationError('Unsupported API proxy endpoint layout');
310
+ if (textOf(xml, oneChild(xml, root, 'name', true)) === endpointName) matches.push({ path, xml });
311
+ }
312
+ if (matches.length !== 1) throw new IntegrationError('Expected one exact named API proxy endpoint');
313
+ return { ...layout, rootXml, ...matches[0] };
314
+ }
315
+
316
+ function selectedSteps(xml, flow, phase, create = false) {
317
+ const flowTag = flow === 'pre' ? 'preFlow' : 'postFlow';
318
+ const root = locate(xml, []);
319
+ const flowElement = oneChild(xml, root, flowTag);
320
+ const phaseElement = flowElement && oneChild(xml, flowElement, phase);
321
+ const steps = phaseElement && oneChild(xml, phaseElement, 'steps');
322
+ if (!create) return { xml, steps, assignments: steps ? supportedSteps(xml, steps) : [] };
323
+ xml = ensureChild(xml, [], flowTag, `<name>${flow === 'pre' ? 'PreFlow' : 'PostFlow'}</name>`, flow === 'pre' ? ['postFlow', 'conditionalFlows'] : ['conditionalFlows']);
324
+ xml = ensureChild(xml, [flowTag], phase, `<isRequest>${phase === 'request'}</isRequest><steps/>`, phase === 'request' ? ['response'] : []);
325
+ xml = ensureChild(xml, [flowTag, phase], 'steps');
326
+ return selectedSteps(xml, flow, phase);
327
+ }
328
+
329
+ /** Private full reference inventory, including conditional/fault and unknown assignment locations. */
330
+ function policyReferences(files, layout) {
331
+ const references = new Set();
332
+ let unknown = false;
333
+ for (const [path] of files) {
334
+ if (!path.startsWith(layout.dir) || !path.endsWith('.xml')) continue;
335
+ const xml = readText(files, path);
336
+ const roots = childElements(xml, 0, xml.length);
337
+ if (roots.length !== 1) throw new IntegrationError('Ambiguous API proxy XML document');
338
+ const walk = (element, parent) => {
339
+ const tag = localName(element);
340
+ const fields = children(xml, element);
341
+ const attributes = xmlAttributes(xml, element);
342
+ if ((tag === 'steps' || tag === 'Steps') && fields.some(child => !['step', 'Step'].includes(localName(child)))) unknown = true;
343
+ if (/step/i.test(tag) && !['step', 'Step', 'steps', 'Steps'].includes(tag)) unknown = true;
344
+ if (tag === 'step' || tag === 'Step') {
345
+ const identities = fields.filter(child => ['policy_name', 'Name'].includes(localName(child)));
346
+ if (identities.length !== 1 || fields.some(child => !['policy_name', 'Name', 'condition', 'Condition', 'sequence'].includes(localName(child)))
347
+ || Object.keys(attributes).some(name => name !== 'name')) unknown = true;
348
+ if (identities.length === 1 && !children(xml, identities[0]).length) {
349
+ const name = textOf(xml, identities[0]);
350
+ if (/^[A-Za-z][A-Za-z0-9_-]{0,99}$/.test(name)) references.add(name);
351
+ else unknown = true;
352
+ } else unknown = true;
353
+ }
354
+ if (['policy_name', 'policyName', 'PolicyName', 'policyRef', 'PolicyRef', 'policy_ref'].includes(tag)) {
355
+ const name = textOf(xml, element);
356
+ if (fields.length || Object.keys(attributes).length || !/^[A-Za-z][A-Za-z0-9_-]{0,99}$/.test(name)) unknown = true;
357
+ else references.add(name);
358
+ }
359
+ if ((tag === 'policy' || tag === 'Policy') && !(path === layout.root && localName(parent ?? {}) === 'policies')) unknown = true;
360
+ if (/policy/i.test(tag) && !['policy', 'Policy', 'policy_name', 'policyName', 'PolicyName', 'policyRef', 'PolicyRef', 'policy_ref'].includes(tag)) unknown = true;
361
+ if (Object.keys(attributes).some(name => /policy/i.test(name))) unknown = true;
362
+ for (const child of fields) walk(child, element);
363
+ };
364
+ walk(roots[0]);
365
+ }
366
+ return { references, unknown };
367
+ }
368
+
369
+ /** Surgical complete-order edit for one exact assignment; never regenerate endpoints or shared resources. */
370
+ export function editPolicySteps(files, proxyName, { endpoint, endpoint_name, flow, phase, steps, remove_unreferenced = [] }) {
371
+ const layout = policyEditLayout(files, proxyName, endpoint, endpoint_name);
372
+ const policyList = oneChild(layout.rootXml, locate(layout.rootXml, []), 'policies');
373
+ const registrations = policyList ? children(layout.rootXml, policyList) : [];
374
+ if (registrations.some(element => element.name !== 'policy' || children(layout.rootXml, element).length)) throw new IntegrationError('Unsupported API proxy policy registrations');
375
+ const registered = registrations.map(element => textOf(layout.rootXml, element));
376
+ if (new Set(registered).size !== registered.length) throw new IntegrationError('Ambiguous API proxy policy registrations');
377
+ if (new Set(steps.map(step => step.policy_name)).size !== steps.length || new Set(remove_unreferenced).size !== remove_unreferenced.length) {
378
+ throw new IntegrationError('Duplicate requested policy identities');
379
+ }
380
+ for (const step of steps) {
381
+ if (!registered.includes(step.policy_name) || !files.has(`${layout.dir}Policy/${step.policy_name}.xml`)) {
382
+ throw new IntegrationError('Every desired policy must have one existing registration and policy document');
383
+ }
384
+ }
385
+ const selected = selectedSteps(layout.xml, flow, phase, true);
386
+ let xml = selected.xml;
387
+ const desired = steps.map((step, index) => {
388
+ const existing = selected.assignments.find(item => item.policy_name === step.policy_name);
389
+ let body = existing ? xml.slice(existing.element.start, existing.element.end)
390
+ : `<step><policy_name>${xmlEscape(step.policy_name)}</policy_name><condition></condition><sequence>1</sequence></step>`;
391
+ if (step.condition !== undefined) body = setStepField(body, 'condition', step.condition);
392
+ return setStepField(body, 'sequence', index + 1);
393
+ });
394
+ if (selected.steps.selfClosing) xml = expand(xml, selected.steps, desired.join(''));
395
+ else {
396
+ const changes = selected.assignments.map((assignment, index) => ({ start: assignment.element.start, end: assignment.element.end, text: desired[index] ?? '' }));
397
+ if (desired.length > changes.length) changes.push({ start: selected.steps.contentEnd, end: selected.steps.contentEnd, text: desired.slice(changes.length).join('') });
398
+ for (const change of changes.sort((a, b) => b.start - a.start)) xml = splice(xml, change.start, change.end, change.text);
399
+ }
400
+ // Work on a private candidate until every deletion precondition passes.
401
+ const candidate = new Map(files);
402
+ writeText(candidate, layout.path, xml);
403
+ const inventory = policyReferences(candidate, layout);
404
+ for (const name of remove_unreferenced) {
405
+ if (!registered.includes(name) || !candidate.has(`${layout.dir}Policy/${name}.xml`)) throw new IntegrationError('Policy removal requires an existing registration and document');
406
+ if (inventory.unknown || inventory.references.has(name)) throw new IntegrationError('Policy removal blocked by retained or unknown policy references');
407
+ }
408
+ let rootXml = layout.rootXml;
409
+ for (const entry of registrations.filter(element => remove_unreferenced.includes(textOf(layout.rootXml, element))).sort((a, b) => b.start - a.start)) {
410
+ rootXml = splice(rootXml, entry.start, entry.end, '');
411
+ }
412
+ if (remove_unreferenced.length) writeText(candidate, layout.root, rootXml);
413
+ for (const name of remove_unreferenced) candidate.delete(`${layout.dir}Policy/${name}.xml`);
414
+ const assignments = selectedSteps(xml, flow, phase).assignments;
415
+ for (const [path, bytes] of candidate) files.set(path, bytes);
416
+ for (const name of remove_unreferenced) files.delete(`${layout.dir}Policy/${name}.xml`);
417
+ return {
418
+ endpoint, endpoint_name, flow, phase,
419
+ steps: assignments.map(item => ({ policy_name: item.policy_name, sequence: item.sequence, condition_present: Boolean(item.condition.trim()) })),
420
+ removed_policies: remove_unreferenced
421
+ };
422
+ }
423
+
424
+ export function xmlAttributes(xml, element) {
425
+ const attributes = {};
426
+ const opening = xml.slice(element.start, element.contentStart);
427
+ const pattern = /([A-Za-z_][\w.:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
428
+ for (const match of opening.matchAll(pattern)) attributes[match[1].split(':').at(-1)] = xmlUnescape(match[2] ?? match[3]);
429
+ return attributes;
430
+ }
431
+
432
+ /** Locate only the unambiguous, registered SAP SWAGGER_JSON English documentation layout. */
433
+ export function bundleDocumentation(files, proxyName) {
434
+ const { root, dir } = proxyLayout(files, proxyName);
435
+ const xml = readText(files, root);
436
+ const rootElement = locate(xml, []);
437
+ if (rootElement.name.split(':').at(-1) !== 'APIProxy') throw new IntegrationError('Unsupported API documentation layout: root is not APIProxy');
438
+ const rootFiles = [...files.keys()].filter(path => path.startsWith(dir) && path.endsWith('.xml') && !path.slice(dir.length).includes('/'));
439
+ if (rootFiles.length !== 1) throw new IntegrationError('Unsupported API documentation layout: ambiguous root XML');
440
+ const one = (source, parent, name) => {
441
+ const matches = children(source, parent).filter(element => element.name.split(':').at(-1) === name);
442
+ if (matches.length !== 1) throw new IntegrationError(`Unsupported API documentation layout: expected one ${name}`);
443
+ return matches[0];
444
+ };
445
+ if (textOf(xml, one(xml, rootElement, 'name')) !== proxyName) throw new IntegrationError('API documentation proxy name does not match bundle');
446
+ const endpointList = one(xml, rootElement, 'proxyEndPoints');
447
+ const endpoints = children(xml, endpointList).filter(element => element.name.split(':').at(-1) === 'proxyEndPoint');
448
+ if (endpoints.length !== 1 || textOf(xml, one(xml, endpoints[0], 'apiResourceName')) !== 'SWAGGER_JSON') {
449
+ throw new IntegrationError('Unsupported API documentation layout: expected a single SWAGGER_JSON endpoint registration');
450
+ }
451
+ const endpointName = textOf(xml, one(xml, endpoints[0], 'proxyEndPointName'));
452
+ const resourcePath = `${dir}APIResource/SWAGGER_JSON.xml`;
453
+ const documentationPath = `${dir}Documentation/SWAGGER_JSON_en.html`;
454
+ if (!files.has(resourcePath) || !files.has(documentationPath)) throw new IntegrationError('Unsupported API documentation layout: registered resource or document is missing');
455
+ const resource = readText(files, resourcePath);
456
+ const resourceRoot = locate(resource, []);
457
+ if (resourceRoot.name.split(':').at(-1) !== 'APIResource') throw new IntegrationError('Unsupported API documentation layout: resource is not APIResource');
458
+ if (textOf(resource, one(resource, resourceRoot, 'name')) !== 'SWAGGER_JSON') throw new IntegrationError('API documentation resource name does not match registration');
459
+ if (textOf(resource, one(resource, resourceRoot, 'resource_path')) !== 'SWAGGER_JSON'
460
+ || !endpointName || textOf(resource, one(resource, resourceRoot, 'proxyEndPointName')) !== endpointName) {
461
+ throw new IntegrationError('API documentation resource path or endpoint does not match registration');
462
+ }
463
+ const docs = one(resource, resourceRoot, 'documentations');
464
+ const english = children(resource, docs).filter(element => element.name.split(':').at(-1) === 'documentation');
465
+ if (english.length !== 1) throw new IntegrationError('Unsupported API documentation layout: expected one documentation');
466
+ const entry = english[0];
467
+ const opening = resource.slice(entry.start, entry.contentStart);
468
+ const attribute = name => {
469
+ const values = [...opening.matchAll(new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')`, 'g'))];
470
+ return values.length === 1 ? xmlUnescape(values[0][1] ?? values[0][2]) : undefined;
471
+ };
472
+ if (attribute('locale') !== 'en' || attribute('mimeType') !== 'json' || children(resource, entry).length || textOf(resource, entry) !== 'SWAGGER_JSON') {
473
+ throw new IntegrationError('Unsupported API documentation layout: expected SWAGGER_JSON locale=en mimeType=json');
474
+ }
475
+ return { path: documentationPath, text: readText(files, documentationPath) };
476
+ }
477
+
478
+ /** Replace documentation bytes only; routing, provider and security resources remain untouched. */
479
+ export function patchBundleDocumentation(files, proxyName, text) {
480
+ const { path } = bundleDocumentation(files, proxyName);
481
+ writeText(files, path, text);
482
+ return { path };
483
+ }
484
+
206
485
  /**
207
486
  * Patch an exported bundle in place (Map path → bytes): root title/description/service_code when given,
208
487
  * proxy endpoint base_path, and the target head (url/provider_id/relativePath). Policies, steps,
@@ -302,7 +581,7 @@ export function policyDocuments(spec) {
302
581
  const steps = [place({ endpoint: 'target' })];
303
582
  const user = `private.${name}.user`;
304
583
  const password = `private.${name}.password`;
305
- const get = (variable, entry) => `<Get assignTo="${xmlEscape(variable)}"><Key><Parameter>${xmlEscape(entry)}</Parameter></Key></Get>`;
584
+ const get = (variable, entry) => `<Get assignTo="${xmlEscape(variable)}" index="1"><Key><Parameter>${xmlEscape(entry)}</Parameter></Key></Get>`;
306
585
  return [
307
586
  {
308
587
  name: `${name}_kvm`,
@@ -316,7 +595,7 @@ export function policyDocuments(spec) {
316
595
  type: 'BasicAuth',
317
596
  xml: `${policyOpen('BasicAuthentication', 'true')}<Operation>Encode</Operation><IgnoreUnresolvedVariables>false</IgnoreUnresolvedVariables>`
318
597
  + `<User ref="${xmlEscape(user)}"></User><Password ref="${xmlEscape(password)}"></Password>`
319
- + '<AssignTo createNew="false">request.header.Authorization</AssignTo></BasicAuthentication>',
598
+ + '<AssignTo createNew="true">request.header.Authorization</AssignTo></BasicAuthentication>',
320
599
  steps
321
600
  }
322
601
  ];
@@ -379,3 +658,127 @@ export function attachPolicies(files, proxyName, specs) {
379
658
  for (const { path, xml } of Object.values(endpoints)) writeText(files, path, xml);
380
659
  return { policies: documents.map(document => document.name), stepsAdded };
381
660
  }
661
+
662
+ /**
663
+ * Select a backend from already verified JWT claims. Kept ES5-compatible because
664
+ * this same function is embedded in the gateway's JavaScript policy.
665
+ */
666
+ export function selectAuthorizedBackend(claims, policy, now) {
667
+ if (!claims || claims.iss !== policy.issuer || claims.grant_type !== 'client_credentials'
668
+ || typeof claims.exp !== 'number' || !isFinite(claims.exp) || claims.exp <= now
669
+ || (claims.nbf !== undefined && (typeof claims.nbf !== 'number' || claims.nbf > now))
670
+ || !Array.isArray(claims.scope)) return null;
671
+ var prefix = policy.scopePrefix + '.';
672
+ var selected = null;
673
+ for (var i = 0; i < claims.scope.length; i += 1) {
674
+ var scope = claims.scope[i];
675
+ if (typeof scope !== 'string') return null;
676
+ if (scope.indexOf(prefix + 'S4') !== 0 || scope.slice(-7) !== '.Access') continue;
677
+ var system = scope.slice(prefix.length, -7);
678
+ if (policy.systems.indexOf(system) === -1 || (selected !== null && selected !== system)) return null;
679
+ selected = system;
680
+ }
681
+ var audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
682
+ return selected !== null && audiences.indexOf(prefix + selected) !== -1 ? selected : null;
683
+ }
684
+
685
+ /**
686
+ * Build one fully secured capability proxy with fixed provider-backed targets.
687
+ * Environment configuration changes at promotion; public names/paths do not.
688
+ * No credentials or caller-supplied target identifiers belong in this config.
689
+ */
690
+ export function buildRoutedApiProxyBundle(config) {
691
+ const { name, title, description = 'OAuth client credentials; backend selected by one authorized system scope.',
692
+ basePath, serviceType, backendPath, issuer, jwksUrl, scopePrefix, environment, systems, vimVersion = false } = config;
693
+ if (!['production', 'non-production'].includes(environment)) throw new IntegrationError('An explicit deployment environment is required');
694
+ if (!Array.isArray(systems) || !systems.length) throw new IntegrationError('At least one fixed backend system is required');
695
+ if (new Set(systems.map(system => system.id)).size !== systems.length) throw new IntegrationError('Duplicate system route authorities are not allowed');
696
+ if (systems.some(system => !/^S4[DTQP](?:\d{3})?$/.test(system.id) || !system.provider || !system.kvm)) {
697
+ throw new IntegrationError('Every route requires a fixed S4 system, API provider and encrypted KVM name');
698
+ }
699
+ if (new Set(systems.map(system => system.id.charAt(2))).size !== systems.length) {
700
+ throw new IntegrationError('Duplicate backend credential-policy names are not allowed; configure one client per system');
701
+ }
702
+ if (environment === 'production' && (systems.length !== 1 || !/^S4P(?:\d{3})?$/.test(systems[0].id))) {
703
+ throw new IntegrationError('A production deployment must contain only one S4P backend');
704
+ }
705
+ if (environment === 'non-production' && systems.some(system => /^S4P/.test(system.id))) {
706
+ throw new IntegrationError('A non-production deployment cannot contain an S4P backend');
707
+ }
708
+ const issuerUrl = new URL(issuer);
709
+ const keysUrl = new URL(jwksUrl);
710
+ if (issuerUrl.protocol !== 'https:' || keysUrl.protocol !== 'https:' || keysUrl.origin !== issuerUrl.origin
711
+ || issuerUrl.username || issuerUrl.password || keysUrl.username || keysUrl.password
712
+ || issuerUrl.search || issuerUrl.hash || keysUrl.search || keysUrl.hash) {
713
+ throw new IntegrationError('JWKS must use the trusted HTTPS issuer origin without embedded credentials or query parameters');
714
+ }
715
+ if (!scopePrefix) throw new IntegrationError('The trusted application scope prefix is required');
716
+ const files = new Map();
717
+ const rootPath = `APIProxy/${name}.xml`;
718
+ const proxyPath = 'APIProxy/APIProxyEndPoint/default.xml';
719
+ const endpointName = index => index === 0 ? 'default' : systems[index].id;
720
+ let root = proxyXml({ name, title: title ?? name, description, serviceType });
721
+ const targets = locate(root, ['targetEndPoints']);
722
+ root = splice(root, targets.contentStart, targets.contentEnd,
723
+ systems.map((system, index) => `<targetEndPoint>${xmlEscape(endpointName(index))}</targetEndPoint>`).join(''));
724
+ writeText(files, rootPath, root);
725
+ let proxy = proxyEndPointXml(basePath);
726
+ const routes = locate(proxy, ['routeRules']);
727
+ proxy = splice(proxy, routes.contentStart, routes.contentEnd, systems.map((system, index) =>
728
+ `<routeRule><name>route${xmlEscape(system.id)}</name><conditions>${xmlEscape(`private.backendSystem = "${system.id}"`)}</conditions>`
729
+ + `<targetEndPointName>${xmlEscape(endpointName(index))}</targetEndPointName><sequence>${index + 1}</sequence><faultRules/></routeRule>`).join(''));
730
+ writeText(files, proxyPath, proxy);
731
+ const policy = { issuer, scopePrefix, systems: systems.map(system => system.id) };
732
+ const script = `${selectAuthorizedBackend.toString()}\n`
733
+ + 'var backend = null;\n'
734
+ + 'if (String(context.getVariable("jwt.VerifyClientCredentialsJWT.valid")) === "true") {\n'
735
+ + ` backend = selectAuthorizedBackend(JSON.parse(String(context.getVariable("jwt.VerifyClientCredentialsJWT.payload-json"))), ${JSON.stringify(policy)}, new Date().getTime() / 1000);\n`
736
+ + '}\ncontext.setVariable("private.backendSystem", backend === null ? "" : backend);\n'
737
+ + 'context.setVariable("private.backendAuthorized", backend === null ? "false" : "true");\n';
738
+ // Scope and its corresponding audience are checked together by the selector.
739
+ // VerifyJWT's multi-value Audience requires all values, not any one system.
740
+ const custom = (policyName, type, xml, condition) => ({
741
+ name: policyName, template: 'custom', endpoint: 'proxy', flow: 'pre', phase: 'request', condition, params: { type, xml }
742
+ });
743
+ attachPolicies(files, name, [
744
+ { name: 'NormalizeBackendEncoding', template: 'assign_headers', params: { message: 'request', set: { 'Accept-Encoding': 'gzip, deflate' } } },
745
+ custom('ExtractOAuthBearer', 'ExtractVariable',
746
+ `${policyOpen('ExtractVariables')}<Source>request</Source><Header name="Authorization"><Pattern ignoreCase="true">Bearer {access_token}</Pattern></Header>`
747
+ + '<VariablePrefix>private.oauth</VariablePrefix><IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables></ExtractVariables>'),
748
+ custom('FetchOAuthJwks', 'ServiceCallout',
749
+ `${policyOpen('ServiceCallout')}<Request clearPayload="true" variable="oauthJwksRequest"><Remove><Headers>`
750
+ + '<Header name="Authorization"/><Header name="Cookie"/><Header name="apikey"/></Headers></Remove>'
751
+ + '<Set><Headers><Header name="Accept">application/json</Header></Headers><Verb>GET</Verb></Set>'
752
+ + '<IgnoreUnresolvedVariables>true</IgnoreUnresolvedVariables></Request><Response>oauthJwksResponse</Response><Timeout>10000</Timeout>'
753
+ + `<HTTPTargetConnection><URL>${xmlEscape(jwksUrl)}</URL></HTTPTargetConnection></ServiceCallout>`,
754
+ '(private.oauth.access_token != null) and (private.oauth.access_token != "")'),
755
+ custom('VerifyClientCredentialsJWT', 'VerifyJWT',
756
+ `${policyOpen('VerifyJWT')}<Algorithm>RS256</Algorithm><Source>private.oauth.access_token</Source><IgnoreUnresolvedVariables>false</IgnoreUnresolvedVariables>`
757
+ + `<PublicKey><JWKS ref="oauthJwksResponse.content"/></PublicKey><Issuer>${xmlEscape(issuer)}</Issuer>`
758
+ + '<AdditionalClaims><Claim name="grant_type" type="string">client_credentials</Claim></AdditionalClaims></VerifyJWT>'),
759
+ { name: 'SelectAuthorizedBackend', template: 'javascript', params: { file: 'select-authorized-backend', script } },
760
+ { name: 'RejectUnauthorizedBackend', template: 'raise_fault', condition: 'private.backendAuthorized != "true"',
761
+ params: { status_code: 403, reason_phrase: 'Forbidden', content_type: 'application/json',
762
+ payload: '{"error":"Exactly one authorized system scope and its matching audience are required."}' } }
763
+ ]);
764
+ root = readText(files, rootPath);
765
+ for (const [index, system] of systems.entries()) {
766
+ const targetName = endpointName(index);
767
+ let target = targetEndPointXml({ provider: system.provider, relativePath: backendPath });
768
+ target = setRootText(target, 'name', targetName);
769
+ target = setRootText(target, 'isDefault', index === 0 ? 'true' : 'false');
770
+ const specs = [{ name: `SAPBackend${system.id.slice(2, 3)}`, template: 'basic_auth_from_kvm',
771
+ params: { kvm: system.kvm, user_key: 'username', password_key: 'password' } }];
772
+ if (vimVersion) specs.push({ name: 'VIMVersion', template: 'custom', endpoint: 'target', params: { type: 'AssignMessage',
773
+ xml: `${policyOpen('AssignMessage')}<Set><QueryParams><QueryParam name="version">1</QueryParam></QueryParams></Set>`
774
+ + '<IgnoreUnresolvedVariables>false</IgnoreUnresolvedVariables><AssignTo createNew="false" type="request"/></AssignMessage>' } });
775
+ for (const document of specs.flatMap(policyDocuments)) {
776
+ root = registerRootEntry(root, 'policies', 'policy', document.type, document.name, ['fileResources']);
777
+ writeText(files, `APIProxy/Policy/${document.name}.xml`, `${XML_HEADER}\n${document.xml}`);
778
+ for (const step of document.steps) target = addStep(target, step.flow, step.phase, document.name, step.condition).xml;
779
+ }
780
+ writeText(files, `APIProxy/APITargetEndPoint/${targetName}.xml`, target);
781
+ }
782
+ writeText(files, rootPath, root);
783
+ return zipSync(Object.fromEntries(files));
784
+ }
@@ -0,0 +1,145 @@
1
+ // Explicit-set APIM cleanup orchestration; native lifecycle operations are injected.
2
+ import { createHash } from 'node:crypto';
3
+ import { z } from 'zod';
4
+ import { confirmation, OVERWRITE_ANNOTATIONS, requireConfirmation } from './integration-common.mjs';
5
+
6
+ const APIM_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,99}$/;
7
+ const names = z.array(z.string().regex(APIM_NAME)).max(100)
8
+ .refine(value => new Set(value).size === value.length, 'names must be unique').default([]);
9
+ const cleanupInput = z.object({
10
+ products: names,
11
+ proxies: names,
12
+ allow_deployed: z.boolean().default(false),
13
+ dry_run: z.boolean().default(true),
14
+ confirmation: confirmation.optional()
15
+ }).strict().refine(value => value.products.length + value.proxies.length > 0, 'select at least one product or proxy')
16
+ .refine(value => value.products.length + value.proxies.length <= 200, 'select at most 200 objects');
17
+
18
+ // Unknown and transitional states are never treated as safe inactive proxies.
19
+ const INACTIVE_STATES = new Set(['CREATED', 'UNDEPLOYED']);
20
+
21
+ function approvalFor(products, proxies, allow_deployed) {
22
+ const digest = createHash('sha256').update(JSON.stringify({ products, proxies, allow_deployed })).digest('hex');
23
+ return `DELETE APIM OBJECTS ${digest}`;
24
+ }
25
+
26
+ function validInspection(value, name, dependencyField) {
27
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.name !== name) return false;
28
+ const dependencies = value[dependencyField];
29
+ return dependencyField === 'blockingApplicationCount'
30
+ ? Number.isSafeInteger(dependencies) && dependencies >= 0
31
+ : Array.isArray(dependencies) && dependencies.every(dependency => typeof dependency === 'string' && dependency.length > 0);
32
+ }
33
+
34
+ async function inspect(name, operation, dependencyField) {
35
+ try {
36
+ const value = await operation(name);
37
+ if (value === null) return { entry: { name, status: 'already_absent', blockers: [] }, value: null };
38
+ if (!validInspection(value, name, dependencyField)) {
39
+ return { entry: { name, status: 'failed', blockers: [{ code: 'invalid_inspection' }] }, value: null };
40
+ }
41
+ return { entry: { name, status: 'ready', blockers: [] }, value };
42
+ } catch {
43
+ // Callback errors and complete tenant entities may contain hosts or credentials.
44
+ return { entry: { name, status: 'failed', blockers: [{ code: 'inspection_failed' }] }, value: null };
45
+ }
46
+ }
47
+
48
+ function verifiedOutcome(value) {
49
+ if (!value || typeof value !== 'object' || value.pending === true || value.verified === false
50
+ || value.deletion_verified === false) return null;
51
+ if (value.deleted === true && value.already_absent !== true) return 'deleted';
52
+ if (value.already_absent === true && value.deleted !== true) return 'already_absent';
53
+ return null;
54
+ }
55
+
56
+ export function registerApimCleanupTools(register, operations) {
57
+ register('apim_cleanup_api_objects', 'Clean up explicit API products and proxies',
58
+ 'Preflight explicit product/proxy lists (maximum 100 each, 200 total); dry_run defaults to true. '
59
+ + 'Never discovers targets, cascades, or deletes providers, KVMs, applications or credentials. '
60
+ + 'Products with application dependencies and proxies with unselected or blocked product dependencies block the entire batch. '
61
+ + 'DEPLOYED proxies additionally require allow_deployed=true; unknown lifecycle states block cleanup. '
62
+ + 'Execute only with the exact required_confirmation from a dry run of the same lists and allow_deployed value: '
63
+ + 'DELETE APIM OBJECTS <SHA256 of JSON {products:sorted names,proxies:sorted names,allow_deployed}>. '
64
+ + 'Deletes products first, stops at the first error or unverified result, and never retries. '
65
+ + 'complete is true only after execution verifies every selected object deleted or already absent.',
66
+ cleanupInput, OVERWRITE_ANNOTATIONS,
67
+ async input => {
68
+ const { products, proxies, allow_deployed, dry_run, confirmation: approved } = cleanupInput.parse(input);
69
+ const productNames = [...products].sort();
70
+ const proxyNames = [...proxies].sort();
71
+ const required_confirmation = approvalFor(productNames, proxyNames, allow_deployed);
72
+ // Approval is bound to the exact explicit set and deployment permission before any reads.
73
+ if (!dry_run) requireConfirmation(approved, required_confirmation);
74
+
75
+ const productEntries = [];
76
+ for (const name of productNames) {
77
+ const { entry, value } = await inspect(name, operations.inspectProduct, 'blockingApplicationCount');
78
+ if (value?.blockingApplicationCount) {
79
+ entry.status = 'blocked';
80
+ entry.blockers.push({ code: 'application_dependencies', count: value.blockingApplicationCount });
81
+ }
82
+ productEntries.push(entry);
83
+ }
84
+ const selectedProducts = new Map(productEntries.map(entry => [entry.name, entry]));
85
+ const proxyEntries = [];
86
+ for (const name of proxyNames) {
87
+ const { entry, value } = await inspect(name, operations.inspectProxy, 'blockingProducts');
88
+ if (value) {
89
+ const blocking = value.blockingProducts.filter(product => {
90
+ const selected = selectedProducts.get(product);
91
+ return !selected || !['ready', 'already_absent'].includes(selected.status);
92
+ });
93
+ if (blocking.length) {
94
+ entry.blockers.push({ code: 'product_dependencies', count: blocking.length });
95
+ }
96
+ if (value.state === 'DEPLOYED') {
97
+ if (!allow_deployed) entry.blockers.push({ code: 'deployed_requires_approval' });
98
+ } else if (!INACTIVE_STATES.has(value.state)) {
99
+ entry.blockers.push({ code: 'unknown_lifecycle_state' });
100
+ }
101
+ if (entry.blockers.length) entry.status = 'blocked';
102
+ }
103
+ proxyEntries.push(entry);
104
+ }
105
+
106
+ const all = [...productEntries, ...proxyEntries];
107
+ const preflight_ok = all.every(entry => ['ready', 'already_absent'].includes(entry.status));
108
+ const result = {
109
+ dry_run, allow_deployed, required_confirmation, preflight_ok, complete: false,
110
+ products: productEntries, proxies: proxyEntries
111
+ };
112
+ if (dry_run) return result;
113
+ for (const entry of all) {
114
+ if (entry.status === 'ready') entry.status = 'not_attempted';
115
+ }
116
+ if (!preflight_ok) return result;
117
+
118
+ const queue = [
119
+ ...productEntries.map(entry => ({ entry, remove: () => operations.deleteProduct(entry.name) })),
120
+ ...proxyEntries.map(entry => ({ entry, remove: () => operations.deleteProxy(entry.name, { allow_deployed }) }))
121
+ ];
122
+ for (const { entry, remove } of queue) {
123
+ if (entry.status === 'already_absent') continue;
124
+ let outcome;
125
+ try {
126
+ outcome = verifiedOutcome(await remove());
127
+ } catch {
128
+ entry.status = 'failed';
129
+ entry.blockers.push({ code: 'deletion_failed' });
130
+ entry.deletion_verified = false;
131
+ return result;
132
+ }
133
+ if (!outcome) {
134
+ entry.status = 'failed';
135
+ entry.blockers.push({ code: 'deletion_unverified' });
136
+ entry.deletion_verified = false;
137
+ return result;
138
+ }
139
+ entry.status = outcome;
140
+ if (outcome === 'deleted') entry.deletion_verified = true;
141
+ }
142
+ result.complete = true;
143
+ return result;
144
+ });
145
+ }