sap-ai-dev-toolkit 0.9.9918 → 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.
- package/.github/agents/sap-integration-architect.agent.md +32 -18
- package/.github/agents/sap-integration-developer.agent.md +28 -12
- package/.github/skills/integration-suite-delivery/SKILL.md +94 -37
- package/.github/skills/sap-integration-architect/SKILL.md +49 -25
- package/README.md +72 -15
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/inventory.md +97 -57
- package/package.json +1 -1
- package/scripts/check-live-integration.mjs +151 -0
- package/scripts/install-local.mjs +11 -4
- package/scripts/run-live-integration.mjs +48 -56
- package/src/apim-bundle.mjs +416 -13
- package/src/apim-cleanup-tools.mjs +145 -0
- package/src/apim-content.mjs +157 -0
- package/src/apim-delete-tools.mjs +139 -0
- package/src/apim-documentation.mjs +162 -0
- package/src/apim-inspection.mjs +141 -0
- package/src/apim-monitoring.mjs +140 -0
- package/src/apim-policy-steps.mjs +31 -0
- package/src/apim-runtime.mjs +262 -0
- package/src/apim-tools.mjs +202 -109
- package/src/cf-integration.mjs +46 -7
- package/src/cpi-async-monitoring.mjs +177 -0
- package/src/cpi-content.mjs +92 -0
- package/src/cpi-monitoring.mjs +225 -0
- package/src/cpi-operational.mjs +129 -0
- package/src/cpi-release.mjs +273 -0
- package/src/cpi-security.mjs +48 -0
- package/src/cpi-tools.mjs +184 -319
- package/src/devhub-retirement.mjs +345 -0
- package/src/devhub-tools.mjs +177 -0
- package/src/edge-monitoring.mjs +101 -0
- package/src/iflow-bundle.mjs +295 -7
- package/src/integration-alerts.mjs +248 -0
- package/src/integration-analysis.mjs +488 -0
- package/src/integration-capabilities.mjs +181 -0
- package/src/integration-collections.mjs +203 -0
- package/src/integration-common.mjs +140 -35
- package/src/integration-config.mjs +20 -14
- package/src/integration-credentials.mjs +16 -10
- package/src/integration-http.mjs +255 -67
- package/src/integration-messaging.mjs +118 -0
- package/src/integration-optional-config.mjs +122 -0
- package/src/integration-optional-tools.mjs +153 -0
- package/src/integration-release-store.mjs +410 -0
- package/src/integration-server.mjs +34 -16
- package/src/integration-state.mjs +31 -0
- package/src/integration-tools.mjs +44 -8
- package/src/integration-transport.mjs +184 -0
- package/src/integration-validation.mjs +26 -0
- package/src/integration-verify.mjs +143 -62
- package/src/open-connectors-tools.mjs +143 -0
- package/src/setup.mjs +66 -44
- package/tools.md +193 -26
package/src/integration-http.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
92
|
+
function setCookieValues(headers) {
|
|
93
|
+
return typeof headers.getSetCookie === 'function'
|
|
78
94
|
? headers.getSetCookie()
|
|
79
|
-
:
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
268
|
+
signal,
|
|
136
269
|
...transportOptions
|
|
137
|
-
});
|
|
138
|
-
} catch {
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
307
|
+
signal,
|
|
168
308
|
...transportOptions
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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 =>
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
269
|
-
const responseText = new TextDecoder().decode(await
|
|
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
|
+
}
|