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
@@ -1,6 +1,5 @@
1
- // OAuth client-credentials HTTP client for one CPI or APIM tenant.
2
- // Requests are pinned to the configured tenant origin, bodies are size
3
- // capped, and token/error responses are never echoed raw.
1
+ // Origin-pinned OAuth and private static-authorization HTTP clients.
2
+ // Shared transport caps bodies, rejects redirects, and bounds complete reads.
4
3
  import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici';
5
4
  import { diagnosticText } from './redact.mjs';
6
5
 
@@ -13,11 +12,13 @@ const SERVER_RETRIES = 2;
13
12
  const RETRYABLE_STATUS = new Set([500, 502, 503, 504]);
14
13
  // Only idempotent methods are retried; POST (create/deploy/import) is never re-sent.
15
14
  const RETRYABLE_METHODS = new Set(['GET', 'PUT']);
15
+ const PAGINATION_HEADERS = new Set(['elements-next-page-token', 'nextpage']);
16
16
 
17
17
  export class IntegrationError extends Error {
18
- constructor(message) {
18
+ constructor(message, evidence = {}) {
19
19
  super(message);
20
20
  this.name = 'IntegrationError';
21
+ Object.assign(this, evidence);
21
22
  }
22
23
  }
23
24
 
@@ -33,22 +34,37 @@ export function odataDate(value) {
33
34
  return match ? new Date(Number(match[1])).toISOString() : value;
34
35
  }
35
36
 
36
- async function readCapped(response, limit, label) {
37
+ function discard(response) {
38
+ // Cancellation is cleanup, not another unbounded wait on a remote stream.
39
+ void response.body?.cancel().catch(() => {});
40
+ }
41
+
42
+ async function readCapped(response, limit, label, signal) {
37
43
  const declared = Number(response.headers.get('content-length'));
38
44
  if (Number.isFinite(declared) && declared > limit) {
39
- await response.body?.cancel().catch(() => {});
45
+ discard(response);
40
46
  throw new IntegrationError(`${label} response exceeded the safe size limit`);
41
47
  }
42
48
  if (!response.body) return new Uint8Array(0);
49
+ const reader = response.body.getReader();
50
+ const abort = () => { void reader.cancel().catch(() => {}); };
51
+ signal?.addEventListener('abort', abort, { once: true });
43
52
  const chunks = [];
44
53
  let total = 0;
45
- for await (const chunk of response.body) {
46
- total += chunk.byteLength;
47
- if (total > limit) {
48
- await response.body.cancel().catch(() => {});
49
- throw new IntegrationError(`${label} response exceeded the safe size limit`);
54
+ try {
55
+ for (;;) {
56
+ const { done, value: chunk } = await reader.read();
57
+ if (done) break;
58
+ total += chunk.byteLength;
59
+ if (total > limit) throw new IntegrationError(`${label} response exceeded the safe size limit`);
60
+ chunks.push(chunk);
50
61
  }
51
- chunks.push(chunk);
62
+ } catch (error) {
63
+ void reader.cancel().catch(() => {});
64
+ throw error;
65
+ } finally {
66
+ signal?.removeEventListener('abort', abort);
67
+ reader.releaseLock();
52
68
  }
53
69
  const bytes = new Uint8Array(total);
54
70
  let offset = 0;
@@ -73,11 +89,60 @@ function sapMessage(bodyText) {
73
89
  return bodyText.slice(0, 400);
74
90
  }
75
91
 
76
- function setCookiePairs(headers) {
77
- const values = typeof headers.getSetCookie === 'function'
92
+ function setCookieValues(headers) {
93
+ return typeof headers.getSetCookie === 'function'
78
94
  ? headers.getSetCookie()
79
- : [headers.get('set-cookie')].filter(Boolean);
80
- return values.map(value => value.split(';')[0].trim()).filter(Boolean);
95
+ : (headers.get('set-cookie') || '').split(/,(?=\s*[^;,=\s]+=)/).filter(Boolean);
96
+ }
97
+
98
+ function setCookiePairs(headers) {
99
+ return setCookieValues(headers).map(value => value.split(';')[0].trim()).filter(Boolean);
100
+ }
101
+
102
+ // This deliberately host-only jar never honors a server-supplied Domain. It is
103
+ // private to one pinned client and only enabled for broker management sessions.
104
+ function sessionCookies(now) {
105
+ const jar = new Map();
106
+ return {
107
+ remember(headers, target) {
108
+ for (const value of setCookieValues(headers)) {
109
+ if (value.length > 4096) continue;
110
+ const [pair, ...attributes] = value.split(';');
111
+ const separator = pair.indexOf('=');
112
+ if (separator <= 0) continue;
113
+ const name = pair.slice(0, separator).trim();
114
+ const content = pair.slice(separator + 1).trim();
115
+ if (!/^[!#$%&'*+\-.^_`|~\w]+$/.test(name) || /[\x00-\x20\x7f;,]/.test(content)) continue;
116
+ let path = target.pathname.slice(0, target.pathname.lastIndexOf('/')) || '/';
117
+ let secure = false;
118
+ let expires = Infinity;
119
+ let maxAge;
120
+ for (const attribute of attributes) {
121
+ const index = attribute.indexOf('=');
122
+ const key = (index < 0 ? attribute : attribute.slice(0, index)).trim().toLowerCase();
123
+ const setting = index < 0 ? '' : attribute.slice(index + 1).trim();
124
+ if (key === 'path' && setting.startsWith('/')) path = setting;
125
+ if (key === 'secure') secure = true;
126
+ if (key === 'max-age' && /^-?\d+$/.test(setting)) maxAge = Number(setting);
127
+ if (key === 'expires' && Number.isFinite(Date.parse(setting))) expires = Date.parse(setting);
128
+ }
129
+ if (maxAge !== undefined) expires = now() + maxAge * 1000;
130
+ const key = `${path}\0${name}`;
131
+ if (expires <= now()) jar.delete(key);
132
+ else if (jar.has(key) || jar.size < 100) jar.set(key, { name, content, path, secure, expires });
133
+ }
134
+ },
135
+ header(target) {
136
+ const matches = [];
137
+ for (const [key, cookie] of jar) {
138
+ if (cookie.expires <= now()) { jar.delete(key); continue; }
139
+ const inPath = target.pathname === cookie.path
140
+ || target.pathname.startsWith(cookie.path.endsWith('/') ? cookie.path : `${cookie.path}/`);
141
+ if (inPath && (!cookie.secure || target.protocol === 'https:')) matches.push(cookie);
142
+ }
143
+ return matches.sort((a, b) => b.path.length - a.path.length).map(cookie => `${cookie.name}=${cookie.content}`).join('; ');
144
+ }
145
+ };
81
146
  }
82
147
 
83
148
  // undici flags EnvHttpProxyAgent as experimental with a process warning that lands in the middle of the
@@ -97,7 +162,17 @@ function envProxyAgent(options) {
97
162
  }
98
163
 
99
164
  /** Create a tenant-scoped client using OAuth client credentials. */
100
- export function createServiceClient({
165
+ export function createServiceClient(options) {
166
+ return createTransportClient({ ...options, oauth: true });
167
+ }
168
+
169
+ /** Static authorization stays in a host-private closure, never a request input. */
170
+ export function createStaticServiceClient({ authorization, ...options }) {
171
+ if (typeof authorization !== 'function') throw new IntegrationError('Static authorization must be a private function');
172
+ return createTransportClient({ ...options, authorization, oauth: false, exposeErrorDetails: false });
173
+ }
174
+
175
+ function createTransportClient({
101
176
  label,
102
177
  url,
103
178
  tokenUrl,
@@ -107,9 +182,16 @@ export function createServiceClient({
107
182
  env = process.env,
108
183
  fetchImpl = undiciFetch,
109
184
  now = Date.now,
110
- retryDelayMs = 500
185
+ retryDelayMs = 500,
186
+ exposeErrorDetails = true,
187
+ oauth,
188
+ authorization,
189
+ reuseCookies = false
111
190
  }) {
112
- const origin = new URL(url).origin;
191
+ const base = new URL(url);
192
+ if (base.username || base.password || base.search || base.hash) throw new IntegrationError(`${label} base URL must not contain credentials, query, or fragment`);
193
+ const origin = base.origin;
194
+ const cookies = reuseCookies ? sessionCookies(now) : null;
113
195
  const httpProxy = env.HTTP_PROXY || env.http_proxy;
114
196
  const httpsProxy = env.HTTPS_PROXY || env.https_proxy;
115
197
  const dispatcher = httpProxy || httpsProxy
@@ -117,40 +199,92 @@ export function createServiceClient({
117
199
  : undefined;
118
200
  const transportOptions = dispatcher ? { dispatcher } : {};
119
201
  let cachedToken = null;
202
+ const responseDeadlines = new WeakMap();
203
+
204
+ function timeoutError(phase, deadline, status, deadlineTimer = false) {
205
+ const expired = deadlineTimer || deadline !== undefined && now() >= deadline;
206
+ return new IntegrationError(`${label} ${phase} ${expired ? 'deadline exceeded' : 'timed out'}`, {
207
+ code: expired ? 'INTEGRATION_DEADLINE' : 'INTEGRATION_TIMEOUT',
208
+ ...(expired ? { stop_reason: 'deadline', deadline } : {}),
209
+ phase,
210
+ ...(status === undefined ? {} : { status })
211
+ });
212
+ }
120
213
 
121
- async function token() {
214
+ // Race as well as abort: injected transports and a stalled response stream may
215
+ // ignore AbortSignal. The timer covers the complete operation, including reads.
216
+ async function bounded(operation, deadline, timeout, phase, status) {
217
+ const started = now();
218
+ const remaining = deadline === undefined ? timeout : Math.min(timeout, deadline - started);
219
+ const deadlineTimer = Number.isFinite(deadline) && deadline - started <= timeout;
220
+ if (remaining <= 0) throw timeoutError(phase, deadline, status);
221
+ const controller = new AbortController();
222
+ let timer;
223
+ const expired = new Promise((_, reject) => {
224
+ timer = setTimeout(() => {
225
+ reject(timeoutError(phase, deadline, status, deadlineTimer));
226
+ controller.abort();
227
+ }, remaining);
228
+ });
229
+ try {
230
+ const result = await Promise.race([expired, operation(controller.signal)]);
231
+ if (deadline !== undefined && now() >= deadline) throw timeoutError(phase, deadline, status);
232
+ return result;
233
+ } finally {
234
+ clearTimeout(timer);
235
+ }
236
+ }
237
+
238
+ async function read(response, limit, deadline, phase = 'response read') {
239
+ const end = Math.min(deadline ?? Infinity, responseDeadlines.get(response) ?? Infinity);
240
+ try {
241
+ return await bounded(signal => readCapped(response, limit, label, signal), end, REQUEST_TIMEOUT_MS, phase, response.status);
242
+ } catch (error) {
243
+ discard(response);
244
+ if (error instanceof IntegrationError) {
245
+ error.status ??= response.status;
246
+ error.phase ??= phase;
247
+ }
248
+ throw error;
249
+ }
250
+ }
251
+
252
+ async function token(deadline) {
122
253
  if (cachedToken && cachedToken.expiresAt > now()) return cachedToken.value;
254
+ const end = Math.min(deadline ?? Infinity, now() + TOKEN_TIMEOUT_MS);
123
255
  const basic = Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64');
124
256
  let response;
125
257
  try {
126
- response = await fetchImpl(tokenUrl, {
258
+ response = await bounded(signal => fetchImpl(tokenUrl, {
127
259
  method: 'POST',
128
260
  headers: {
129
261
  authorization: `Basic ${basic}`,
130
262
  'content-type': 'application/x-www-form-urlencoded',
131
- accept: 'application/json'
263
+ accept: 'application/json',
264
+ 'accept-encoding': 'gzip, deflate'
132
265
  },
133
266
  body: 'grant_type=client_credentials',
134
267
  redirect: 'manual',
135
- signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
268
+ signal,
136
269
  ...transportOptions
137
- });
138
- } catch {
139
- throw new IntegrationError(`${label} token request failed (network error)`);
270
+ }), end, TOKEN_TIMEOUT_MS, 'token request');
271
+ } catch (error) {
272
+ if (error instanceof IntegrationError) throw error;
273
+ throw new IntegrationError(`${label} token request failed (network error)`, { phase: 'token request' });
140
274
  }
141
275
  if (response.status < 200 || response.status > 299) {
142
- await response.body?.cancel().catch(() => {});
143
- throw new IntegrationError(`${label} token request failed (HTTP ${response.status})`);
276
+ discard(response);
277
+ throw new IntegrationError(`${label} token request failed (HTTP ${response.status})`, { status: response.status, phase: 'token request' });
144
278
  }
145
279
  let payload;
146
280
  try {
147
- payload = JSON.parse(new TextDecoder().decode(await readCapped(response, 64 * 1024, `${label} token`)));
281
+ payload = JSON.parse(new TextDecoder().decode(await read(response, 64 * 1024, end, 'token response read')));
148
282
  } catch (error) {
149
283
  if (error instanceof IntegrationError) throw error;
150
- throw new IntegrationError(`${label} token response is not valid JSON`);
284
+ throw new IntegrationError(`${label} token response is not valid JSON`, { phase: 'token response read' });
151
285
  }
152
286
  if (typeof payload?.access_token !== 'string' || !payload.access_token) {
153
- throw new IntegrationError(`${label} token response has no access_token`);
287
+ throw new IntegrationError(`${label} token response has no access_token`, { phase: 'token response read' });
154
288
  }
155
289
  const ttlMs = Number.isFinite(Number(payload.expires_in))
156
290
  ? Math.max(0, (Number(payload.expires_in) - 60) * 1000)
@@ -159,28 +293,38 @@ export function createServiceClient({
159
293
  return cachedToken.value;
160
294
  }
161
295
 
162
- async function send(target, init) {
296
+ async function send(target, init, deadline) {
297
+ const destination = new URL(target);
298
+ if (destination.origin !== origin || destination.username || destination.password || destination.hash) {
299
+ throw new IntegrationError(`${label} request escaped the configured tenant`);
300
+ }
301
+ const end = Math.min(deadline ?? Infinity, now() + REQUEST_TIMEOUT_MS);
163
302
  try {
164
- return await fetchImpl(target, {
303
+ const response = await bounded(signal => fetchImpl(target, {
165
304
  ...init,
305
+ headers: { ...init.headers, 'accept-encoding': 'gzip, deflate' },
166
306
  redirect: 'manual',
167
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
307
+ signal,
168
308
  ...transportOptions
169
- });
170
- } catch {
171
- throw new IntegrationError(`${label} ${init.method} ${new URL(target).pathname} failed (network error or timeout)`);
309
+ }), end, REQUEST_TIMEOUT_MS, 'HTTP request');
310
+ cookies?.remember(response.headers, destination);
311
+ responseDeadlines.set(response, end);
312
+ return response;
313
+ } catch (error) {
314
+ if (error instanceof IntegrationError) throw error;
315
+ throw new IntegrationError(`${label} ${init.method} ${new URL(target).pathname} failed (network error or timeout)`, { phase: 'HTTP request' });
172
316
  }
173
317
  }
174
318
 
175
- async function fetchCsrf(bearer) {
319
+ async function fetchCsrf(bearer, deadline) {
176
320
  const response = await send(new URL(`${url}${csrfPath}`).href, {
177
321
  method: 'GET',
178
322
  headers: { authorization: `Bearer ${bearer}`, 'x-csrf-token': 'Fetch', accept: 'application/json' }
179
- });
180
- await response.body?.cancel().catch(() => {});
323
+ }, deadline);
324
+ discard(response);
181
325
  const csrfToken = response.headers.get('x-csrf-token');
182
- if (!csrfToken || csrfToken.toLowerCase() === 'required') {
183
- throw new IntegrationError(`${label} CSRF token fetch failed (HTTP ${response.status})`);
326
+ if (response.status < 200 || response.status >= 300 || !csrfToken || csrfToken.toLowerCase() === 'required') {
327
+ throw new IntegrationError(`${label} CSRF token fetch failed (HTTP ${response.status})`, { status: response.status, phase: 'CSRF request' });
184
328
  }
185
329
  return { csrfToken, cookies: setCookiePairs(response.headers) };
186
330
  }
@@ -194,15 +338,24 @@ export function createServiceClient({
194
338
  expect = [200],
195
339
  responseType = 'json',
196
340
  withStatus = false,
197
- unwrap = true
341
+ unwrap = true,
342
+ deadline,
343
+ responseHeaders
198
344
  } = {}) {
345
+ if (deadline !== undefined && !Number.isFinite(deadline)) throw new IntegrationError(`${label} request deadline must be an absolute finite timestamp`);
346
+ if (deadline !== undefined && now() >= deadline) throw timeoutError('request', deadline);
347
+ if (responseHeaders !== undefined && (!Array.isArray(responseHeaders)
348
+ || responseHeaders.some(name => typeof name !== 'string' || !PAGINATION_HEADERS.has(name.toLowerCase())))) {
349
+ throw new IntegrationError(`${label} response headers are limited to documented pagination tokens`);
350
+ }
199
351
  const target = new URL(`${url}${path}`);
200
352
  for (const [key, value] of Object.entries(query || {})) {
201
353
  if (value !== undefined && value !== null) target.searchParams.set(key, String(value));
202
354
  }
203
- // URLSearchParams emits '+' for spaces; OData servers expect %20. Literal '+' is already %2B.
204
- target.search = target.search.replaceAll('+', '%20');
205
- if (target.origin !== origin) throw new IntegrationError(`${label} request escaped the configured tenant`);
355
+ // SAP management endpoints require literal OData '$' option names and '%20' spaces.
356
+ // Decode only a leading '$' in parameter names; values stay encoded (including '+' and '&').
357
+ target.search = target.search.replaceAll('+', '%20').replace(/([?&])%24(?=[A-Za-z_])/gi, (_, separator) => `${separator}$`);
358
+ if (target.origin !== origin || target.username || target.password || target.hash) throw new IntegrationError(`${label} request escaped the configured tenant`);
206
359
 
207
360
  const baseHeaders = { accept };
208
361
  let payload;
@@ -223,28 +376,48 @@ export function createServiceClient({
223
376
  let csrfRetried = false;
224
377
  let serverRetries = 0;
225
378
  for (;;) {
226
- const headers = { ...baseHeaders, authorization: `Bearer ${await token()}` };
379
+ let authentication;
380
+ if (oauth) authentication = `Bearer ${await token(deadline)}`;
381
+ else {
382
+ try {
383
+ authentication = await bounded(() => authorization({ method, path: target.pathname }), deadline, REQUEST_TIMEOUT_MS, 'authorization');
384
+ } catch (error) {
385
+ if (error instanceof IntegrationError && error.code === 'INTEGRATION_DEADLINE') throw error;
386
+ throw new IntegrationError(`${label} private authorization failed`, { phase: 'authorization' });
387
+ }
388
+ if (typeof authentication !== 'string' || !authentication || /[^\x20-\x7e]/.test(authentication)) {
389
+ throw new IntegrationError(`${label} private authorization is invalid`, { phase: 'authorization' });
390
+ }
391
+ }
392
+ const headers = { ...baseHeaders, authorization: authentication };
393
+ const cookieHeader = cookies?.header(target);
394
+ if (cookieHeader) headers.cookie = cookieHeader;
227
395
  if (csrf) {
228
396
  headers['x-csrf-token'] = csrf.csrfToken;
229
397
  if (csrf.cookies.length) headers.cookie = csrf.cookies.join('; ');
230
398
  }
231
- response = await send(target.href, { method, headers, body: payload });
232
- if (response.status === 401 && !authRetried) {
399
+ response = await send(target.href, { method, headers, body: payload }, deadline);
400
+ if (oauth && response.status === 401 && !authRetried) {
233
401
  authRetried = true;
234
- await response.body?.cancel().catch(() => {});
402
+ discard(response);
235
403
  cachedToken = null;
236
404
  continue;
237
405
  }
238
- if (response.status === 403 && !csrfRetried && (response.headers.get('x-csrf-token') || '').toLowerCase() === 'required') {
406
+ if (oauth && response.status === 403 && !csrfRetried && (response.headers.get('x-csrf-token') || '').toLowerCase() === 'required') {
239
407
  csrfRetried = true;
240
- await response.body?.cancel().catch(() => {});
241
- csrf = await fetchCsrf(await token());
408
+ discard(response);
409
+ csrf = await fetchCsrf(await token(deadline), deadline);
242
410
  continue;
243
411
  }
244
412
  if (RETRYABLE_METHODS.has(method) && RETRYABLE_STATUS.has(response.status) && !expect.includes(response.status) && serverRetries < SERVER_RETRIES) {
245
413
  serverRetries += 1;
246
- await response.body?.cancel().catch(() => {});
247
- await new Promise(resolve => setTimeout(resolve, retryDelayMs * serverRetries));
414
+ discard(response);
415
+ // Scheduling a backoff late is not a timeout; retain the normal request
416
+ // timeout and any caller deadline instead of racing the delay itself.
417
+ await bounded(signal => new Promise(resolve => {
418
+ const timer = setTimeout(resolve, retryDelayMs * serverRetries);
419
+ signal.addEventListener('abort', () => clearTimeout(timer), { once: true });
420
+ }), deadline, REQUEST_TIMEOUT_MS, 'retry delay', response.status);
248
421
  continue;
249
422
  }
250
423
  break;
@@ -252,31 +425,46 @@ export function createServiceClient({
252
425
 
253
426
  if (!expect.includes(response.status)) {
254
427
  let detail = '';
255
- try {
256
- detail = diagnosticText(sapMessage(new TextDecoder().decode(await readCapped(response, MAX_TEXT, label))), 400);
257
- } catch {
258
- detail = 'response body unavailable';
428
+ if (!exposeErrorDetails) {
429
+ discard(response);
430
+ detail = 'response body withheld to protect credentials';
431
+ } else {
432
+ try {
433
+ detail = diagnosticText(sapMessage(new TextDecoder().decode(await read(response, MAX_TEXT, deadline))), 400);
434
+ } catch (error) {
435
+ if (error?.stop_reason === 'deadline') throw error;
436
+ detail = 'response body unavailable';
437
+ }
259
438
  }
260
- throw new IntegrationError(`${label} ${method} ${target.pathname} failed (HTTP ${response.status}): ${detail}`);
439
+ throw new IntegrationError(`${label} ${method} ${target.pathname} failed (HTTP ${response.status}): ${detail}`, { status: response.status, phase: 'HTTP response' });
261
440
  }
262
441
  const status = response.status;
263
- const wrap = data => (withStatus ? { status, data } : data);
264
- if (status === 204 || status >= 300) {
265
- await response.body?.cancel().catch(() => {});
442
+ const wrap = data => {
443
+ if (responseHeaders === undefined) return withStatus ? { status, data } : data;
444
+ const selected = {};
445
+ for (const name of responseHeaders) {
446
+ const normalized = name.toLowerCase();
447
+ const value = response.headers.get(normalized);
448
+ if (value !== null) selected[normalized] = value;
449
+ }
450
+ return { ...(withStatus ? { status } : {}), data, responseHeaders: selected };
451
+ };
452
+ if (responseType === 'none' || status === 204 || status >= 300) {
453
+ discard(response);
266
454
  return wrap(null);
267
455
  }
268
- if (responseType === 'bytes') return wrap(await readCapped(response, MAX_BYTES, label));
269
- const responseText = new TextDecoder().decode(await readCapped(response, MAX_TEXT, label));
456
+ if (responseType === 'bytes') return wrap(await read(response, MAX_BYTES, deadline));
457
+ const responseText = new TextDecoder().decode(await read(response, MAX_TEXT, deadline));
270
458
  if (responseType === 'text') return wrap(responseText);
271
459
  if (!responseText.trim()) return wrap(null);
272
460
  let parsed;
273
461
  try {
274
462
  parsed = JSON.parse(responseText);
275
463
  } catch {
276
- throw new IntegrationError(`${label} ${method} ${target.pathname} returned a non-JSON response`);
464
+ throw new IntegrationError(`${label} ${method} ${target.pathname} returned a non-JSON response`, { status, phase: 'response parse' });
277
465
  }
278
466
  return wrap(unwrap ? parsed?.d?.results ?? parsed?.d ?? parsed : parsed);
279
467
  }
280
468
 
281
- return Object.freeze({ label, request });
469
+ return Object.freeze({ label, origin, request });
282
470
  }
@@ -0,0 +1,118 @@
1
+ // Narrow metadata-only adapters. No message consumption or broker control.
2
+ // GA cloud schema: https://api.solace.dev/cloud/openapi/mission-control.json
3
+ // SEMP schema linked by https://help.pubsub.em.services.cloud.sap/Admin/SEMP/SEMP-API-Ref.htm
4
+ // Session/auth contract: https://help.pubsub.em.services.cloud.sap/Admin/SEMP/SEMP-Security.htm
5
+ import { z } from 'zod';
6
+ import { READ_ONLY_ANNOTATIONS } from './integration-common.mjs';
7
+ import { createCollectionPager, cursorSchema } from './integration-collections.mjs';
8
+ import { IntegrationError } from './integration-http.mjs';
9
+
10
+ const CLOUD_ROOT = '/api/v2/missionControl';
11
+ const BROKERS = '/eventBrokerServices';
12
+ const identifier = z.string().min(1).max(255).refine(value => !/[\x00-\x1f\x7f]/.test(value) && value !== '.' && value !== '..');
13
+ const paging = z.object({ top: z.number().int().min(1).max(100).default(100), cursor: cursorSchema }).strict();
14
+ const text = value => typeof value === 'string' && value.length <= 255 && !/[\x00-\x1f\x7f]/.test(value) ? value : undefined;
15
+ const safeNames = values => Array.isArray(values) ? values.slice(0, 100).map(text).filter(value => value !== undefined) : undefined;
16
+ const numericBoolean = (row, names) => Object.fromEntries(names.filter(name => typeof row?.[name] === 'boolean' || typeof row?.[name] === 'number' && Number.isFinite(row[name])).map(name => [name, row[name]]));
17
+ const strings = (row, names) => Object.fromEntries(names.filter(name => text(row?.[name]) !== undefined).map(name => [name, row[name]]));
18
+ const failure = error => ({ result: error?.status === 401 ? 'unauthenticated' : error?.status === 403 ? 'forbidden' : [404, 501].includes(error?.status) ? 'unavailable' : 'error', ...(Number.isInteger(error?.status) ? { http_status: error.status } : {}) });
19
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
20
+ function namedData(raw, expectedId, expectedResourceId) {
21
+ if (!object(raw?.data) || text(raw.data.id) === undefined || raw.data.id !== expectedId) throw new IntegrationError('Named metadata identity is missing or does not match the requested resource');
22
+ if (expectedResourceId !== undefined && raw.data.resourceId !== undefined && raw.data.resourceId !== expectedResourceId) throw new IntegrationError('Operation resource identity does not match the requested service');
23
+ return raw.data;
24
+ }
25
+ function broker(row) {
26
+ return { ...strings(row, ['id', 'name', 'msgVpnName', 'adminState', 'creationState', 'eventBrokerServiceVersion', 'errorId']),
27
+ ...(text(row?.errorId) !== undefined ? { error_category: 'service_reported_broker_error' } : {}),
28
+ ...(Array.isArray(row?.ongoingOperationIds) ? { ongoingOperationIds: safeNames(row.ongoingOperationIds) } : {}),
29
+ ...(Array.isArray(row?.allowedActions) ? { allowedActions: safeNames(row.allowedActions) } : {}) };
30
+ }
31
+
32
+ export function registerEventMeshTools(register, client) {
33
+ register('event_mesh_get_queue', 'Inspect standalone Event Mesh queue',
34
+ 'Read named standalone default-plan queue configuration metadata only. This is not Suite-embedded Event Mesh, message consumption, backlog measurement or a health check.',
35
+ z.object({ name: identifier }).strict(), READ_ONLY_ANNOTATIONS, async ({ name }) => {
36
+ try {
37
+ const response = await client.request('GET', `/hub/rest/api/v1/management/messaging/queues/${encodeURIComponent(name)}`, { unwrap: false, withStatus: true });
38
+ const row = response.data;
39
+ if (!object(row)) throw new IntegrationError('Expected queue configuration metadata');
40
+ const properties = Object.keys(row).filter(key => /^[A-Za-z][A-Za-z0-9_]{0,79}$/.test(key) && !/secret|password|token|credential|authorization|payload|messageBody/i.test(key)).sort().slice(0, 100);
41
+ return { requested_name: name, result: 'ok', http_status: response.status, property_names: properties,
42
+ configuration: numericBoolean(row, ['maxQueueSizeInBytes', 'maxMessageSizeInBytes', 'maxRedeliveryCount', 'respectTtl', 'enableIngress', 'enableEgress']),
43
+ scope: 'standalone_default_plan_configuration', runtime_health: 'unverified' };
44
+ } catch (error) { return { requested_name: name, ...failure(error) }; }
45
+ });
46
+ }
47
+
48
+ export function registerAemTools(register, client) {
49
+ // Translate the shared signed offset cursor to the documented one-based GA page API.
50
+ // Fixed source pages keep the cursor independent of the caller's output page size.
51
+ const pager = createCollectionPager(client, { root: CLOUD_ROOT, request: async (_method, path, options) => {
52
+ const skip = Number(options.query.$skip ?? 0);
53
+ const pageNumber = Math.floor(skip / 100) + 1;
54
+ const raw = await client.request('GET', `${CLOUD_ROOT}${path}`, { unwrap: false, query: { pageNumber, pageSize: 100, expand: 'allowedActions', sort: 'name:asc' }, deadline: options.deadline });
55
+ if (!Array.isArray(raw?.data) || raw.data.length > 100) throw new IntegrationError('Invalid GA event broker collection');
56
+ // The public OpenAPI leaves meta untyped. Do not guess total fields; an empty source page proves traversal end.
57
+ return { value: raw.data.slice(skip % 100) };
58
+ } });
59
+ register('aem_list_brokers', 'List Advanced Event Mesh brokers',
60
+ 'Read the US GA Mission Control eventBrokerServices collection with allowedActions. Unknown source counts remain null; no broker credentials or health inference.',
61
+ paging, READ_ONLY_ANNOTATIONS, args => pager.page({ path: BROKERS, query: { expand: 'allowedActions' }, ...args, project: broker, fallback: false, consistency: 'best_effort', identity: 'id' }));
62
+ register('aem_get_broker', 'Inspect Advanced Event Mesh broker',
63
+ 'Read one GA broker with broker,allowedActions,messageSpoolDetails expansions privately; return only allowlisted identity/state metadata, not endpoints or credentials.',
64
+ z.object({ service_id: identifier }).strict(), READ_ONLY_ANNOTATIONS, async ({ service_id }) => {
65
+ try {
66
+ const raw = await client.request('GET', `${CLOUD_ROOT}${BROKERS}/${encodeURIComponent(service_id)}`, { unwrap: false, query: { expand: 'broker,allowedActions,messageSpoolDetails' } });
67
+ const row = namedData(raw, service_id);
68
+ if (!text(row.eventBrokerServiceVersion)) throw new IntegrationError('Named broker metadata is missing the documented service version');
69
+ return { requested_service_id: service_id, result: 'ok', ...broker(row), runtime_health: 'unverified' };
70
+ } catch (error) { return { requested_service_id: service_id, ...failure(error) }; }
71
+ });
72
+ register('aem_get_broker_operation', 'Inspect Advanced Event Mesh operation',
73
+ 'Read an existing named GA service operation. Preserve observed status without initiating an operation or interpreting free-form error text.',
74
+ z.object({ service_id: identifier, operation_id: identifier }).strict(), READ_ONLY_ANNOTATIONS, async ({ service_id, operation_id }) => {
75
+ try {
76
+ const raw = await client.request('GET', `${CLOUD_ROOT}${BROKERS}/${encodeURIComponent(service_id)}/operations/${encodeURIComponent(operation_id)}`, { unwrap: false });
77
+ const row = namedData(raw, operation_id, service_id);
78
+ return { requested_service_id: service_id, requested_operation_id: operation_id, result: 'ok', ...strings(row, ['id', 'operationType', 'status']),
79
+ ...(text(row.error?.errorId) !== undefined ? { errorId: row.error.errorId, error_category: 'service_reported_operation_error' } : {}) };
80
+ } catch (error) { return { requested_service_id: service_id, requested_operation_id: operation_id, ...failure(error) }; }
81
+ });
82
+ }
83
+
84
+ const QUEUE_NUMBERS = ['spooledMsgCount', 'spooledByteCount', 'maxMsgSpoolUsage', 'msgSpoolUsage', 'maxBindCount', 'ingressEnabled', 'egressEnabled', 'durable', 'respectTtlEnabled', 'redeliveryEnabled', 'maxRedeliveryCount', 'maxTtl', 'txMsgRate', 'rxMsgRate'];
85
+ export function registerAemSempTools(register, client, { sempVpnName } = {}) {
86
+ const root = '/SEMP/v2/monitor';
87
+ // Defer validation to invocation so null-client name enumeration is side-effect-free.
88
+ const collection = () => `/msgVpns/${encodeURIComponent(identifier.parse(sempVpnName))}/queues`;
89
+ const pager = createCollectionPager(client, { root, request: async (_method, path, options) => {
90
+ const query = { count: 100, ...(options.query.$skiptoken ? { cursor: options.query.$skiptoken } : {}) };
91
+ const raw = await client.request('GET', `${root}${path}`, { unwrap: false, query, deadline: options.deadline });
92
+ if (!Array.isArray(raw?.data) || raw.data.length > 100) throw new IntegrationError('Invalid SEMP queue collection');
93
+ let next;
94
+ const link = raw.meta?.paging?.nextPageUri;
95
+ if (link !== undefined && link !== null && link !== '') {
96
+ if (typeof link !== 'string') throw new IntegrationError('Invalid SEMP continuation');
97
+ const target = new URL(link, client.origin);
98
+ if (target.origin !== client.origin || target.username || target.password || target.hash || target.pathname !== `${root}${path}` || [...target.searchParams.keys()].some(key => !['count', 'cursor'].includes(key)) || target.searchParams.getAll('cursor').length !== 1 || !target.searchParams.get('cursor') || target.searchParams.getAll('count').length > 1 || target.searchParams.has('count') && target.searchParams.get('count') !== '100') throw new IntegrationError('SEMP continuation leaves the configured queue collection');
99
+ next = `${root}${path}?${new URLSearchParams({ $skiptoken: target.searchParams.get('cursor') })}`;
100
+ }
101
+ return { value: raw.data, ...(next ? { '@odata.nextLink': next } : { source_end: true }) };
102
+ } });
103
+ register('aem_list_queues', 'List broker-local SEMP queues',
104
+ 'Read configured VPN queue monitor metadata only, using the independent broker Basic client and private reused session. No messages, cloud-token fallback or SEMP proxy.',
105
+ paging, READ_ONLY_ANNOTATIONS, async args => {
106
+ try { return await pager.page({ path: collection(), ...args, project: row => ({ ...strings(row, ['queueName', 'msgVpnName']), ...numericBoolean(row, QUEUE_NUMBERS) }), endOfSource: source => source.source_end === true, fallback: false }); }
107
+ catch (error) {
108
+ if (error.status === 529 || error.status === 401 || error.status === 403) return { items: [], returned: 0, total: null, complete: false, next_cursor: null, stop_reason: 'source_limit', source_result: { ...failure(error), setup_status: error.status === 529 ? 'no_session_slots' : 'needs_admin_confirmation', required_setup: error.status === 529 ? 'Release broker management sessions or review session limits' : 'Confirm broker Basic authentication and VPN-scoped read-only management permissions; cloud credentials are not a fallback' } };
109
+ throw error;
110
+ }
111
+ });
112
+ }
113
+
114
+ export function registerMessagingTools(register, { event_mesh, aem, aem_semp } = {}, { sempVpnName } = {}) {
115
+ if (event_mesh) registerEventMeshTools(register, event_mesh);
116
+ if (aem) registerAemTools(register, aem);
117
+ if (aem_semp) registerAemSempTools(register, aem_semp, { sempVpnName });
118
+ }