@abapify/adt-client 0.4.1 → 0.4.3
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/README.md +2 -0
- package/dist/index.d.mts +120 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +702 -118
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -4
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { exception } from "@abapify/adt-schemas";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { Agent, fetch } from "undici";
|
|
2
4
|
import { adtContract, createAdtClient as createAdtClient$1 } from "@abapify/adt-contracts";
|
|
3
|
-
import { mkdirSync, writeFileSync } from "fs";
|
|
4
|
-
import { dirname, join } from "path";
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
5
7
|
|
|
6
8
|
//#region src/utils/session.ts
|
|
7
9
|
/**
|
|
@@ -183,13 +185,17 @@ var ETagManager = class {
|
|
|
183
185
|
/**
|
|
184
186
|
* Session Manager - Orchestrates cookies and CSRF tokens
|
|
185
187
|
*/
|
|
186
|
-
var SessionManager = class {
|
|
188
|
+
var SessionManager = class SessionManager {
|
|
189
|
+
logger;
|
|
190
|
+
fetchFn;
|
|
191
|
+
static SESSION_PATH_PREFIX = "/sap/bc/adt/core/http/sessions/";
|
|
187
192
|
cookieStore = new CookieStore();
|
|
188
193
|
csrfManager = new CsrfTokenManager();
|
|
189
194
|
etagManager = new ETagManager();
|
|
190
195
|
securitySessionActive = false;
|
|
191
|
-
constructor(logger) {
|
|
196
|
+
constructor(logger, fetchFn = globalThis.fetch) {
|
|
192
197
|
this.logger = logger;
|
|
198
|
+
this.fetchFn = fetchFn;
|
|
193
199
|
}
|
|
194
200
|
/**
|
|
195
201
|
* Process response to update session state
|
|
@@ -266,6 +272,31 @@ var SessionManager = class {
|
|
|
266
272
|
this.etagManager.clear(url);
|
|
267
273
|
}
|
|
268
274
|
/**
|
|
275
|
+
* Reject session paths that could send cleanup requests off-target.
|
|
276
|
+
* Only relative paths under the ADT sessions endpoint (or same-origin
|
|
277
|
+
* absolute URLs with that path) are accepted.
|
|
278
|
+
*/
|
|
279
|
+
isTrustedSessionPath({ baseUrl, sessionPath }) {
|
|
280
|
+
try {
|
|
281
|
+
const parsed = new URL(sessionPath, baseUrl);
|
|
282
|
+
const base = new URL(baseUrl);
|
|
283
|
+
return parsed.origin === base.origin && parsed.pathname.startsWith(SessionManager.SESSION_PATH_PREFIX);
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
extractTrustedSessionPath({ baseUrl, body }) {
|
|
289
|
+
const sessionPath = body.match(/href="([^"]*\/sessions\/[^"]*)"/)?.[1];
|
|
290
|
+
if (!sessionPath || !this.isTrustedSessionPath({
|
|
291
|
+
baseUrl,
|
|
292
|
+
sessionPath
|
|
293
|
+
})) {
|
|
294
|
+
this.logger?.warn("Session: Created security session returned an untrusted path");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
return sessionPath;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
269
300
|
* Initialize CSRF token using the Eclipse ADT security session flow:
|
|
270
301
|
*
|
|
271
302
|
* 1. GET /sessions + x-sap-security-session: create → create security session
|
|
@@ -275,86 +306,205 @@ var SessionManager = class {
|
|
|
275
306
|
* The CSRF token survives the session deletion and remains valid for
|
|
276
307
|
* all subsequent lock/unlock operations. Deleting the session frees
|
|
277
308
|
* the slot — SAP allows only one security session per user.
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
Accept: "application/vnd.sap.adt.core.http.session.v3+xml",
|
|
292
|
-
"X-sap-adt-sessiontype": "stateful"
|
|
293
|
-
};
|
|
294
|
-
if (authHeader) h.Authorization = authHeader;
|
|
295
|
-
const cookie = this.cookieStore.getCookieHeader();
|
|
296
|
-
if (cookie) h.Cookie = cookie;
|
|
297
|
-
return h;
|
|
309
|
+
*/
|
|
310
|
+
resolveInitializeCsrfArgs(...args) {
|
|
311
|
+
if (args.length === 1) {
|
|
312
|
+
const first = args[0];
|
|
313
|
+
if (first !== null) {
|
|
314
|
+
if (typeof first === "object") return first;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
authHeader: typeof args[0] === "string" ? args[0] : void 0,
|
|
319
|
+
client: typeof args[1] === "string" ? args[1] : void 0,
|
|
320
|
+
language: typeof args[2] === "string" ? args[2] : void 0,
|
|
321
|
+
signal: args[3] instanceof AbortSignal ? args[3] : void 0
|
|
298
322
|
};
|
|
323
|
+
}
|
|
324
|
+
async initializeCsrf(baseUrl, ...args) {
|
|
325
|
+
const { authHeader, client, language, signal } = this.resolveInitializeCsrfArgs(...args);
|
|
326
|
+
signal?.throwIfAborted();
|
|
327
|
+
const sessionsUrl = this.buildSessionsUrl(baseUrl, client, language);
|
|
328
|
+
let sessionPath;
|
|
299
329
|
try {
|
|
300
|
-
this.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
330
|
+
sessionPath = await this.createSecuritySession(baseUrl, sessionsUrl, authHeader, signal);
|
|
331
|
+
if (!sessionPath) return false;
|
|
332
|
+
if (!await this.acquireCsrfToken(sessionsUrl, authHeader, signal)) {
|
|
333
|
+
this.deleteSecuritySessionWithFallback({
|
|
334
|
+
sessionPath,
|
|
335
|
+
baseUrl,
|
|
336
|
+
authHeader,
|
|
337
|
+
client,
|
|
338
|
+
language
|
|
339
|
+
}).catch(() => void 0);
|
|
310
340
|
return false;
|
|
311
341
|
}
|
|
312
|
-
this.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
342
|
+
const acquiredCsrfToken = this.csrfManager.getCached();
|
|
343
|
+
if (acquiredCsrfToken) await this.deleteSecuritySession({
|
|
344
|
+
sessionPath,
|
|
345
|
+
csrfToken: acquiredCsrfToken,
|
|
346
|
+
baseUrl,
|
|
347
|
+
authHeader,
|
|
348
|
+
client,
|
|
349
|
+
signal
|
|
350
|
+
});
|
|
351
|
+
signal?.throwIfAborted();
|
|
352
|
+
return true;
|
|
353
|
+
} catch (error) {
|
|
354
|
+
return this.handleCsrfInitializationError(error, {
|
|
355
|
+
baseUrl,
|
|
356
|
+
authHeader,
|
|
357
|
+
client,
|
|
358
|
+
language,
|
|
359
|
+
sessionPath,
|
|
360
|
+
signal
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
buildSessionsUrl(baseUrl, client, language) {
|
|
365
|
+
const url = new URL("/sap/bc/adt/core/http/sessions", baseUrl);
|
|
366
|
+
if (client) url.searchParams.append("sap-client", client);
|
|
367
|
+
if (language) url.searchParams.append("sap-language", language);
|
|
368
|
+
return url;
|
|
369
|
+
}
|
|
370
|
+
buildSessionHeaders(authHeader) {
|
|
371
|
+
const headers = {
|
|
372
|
+
Accept: "application/vnd.sap.adt.core.http.session.v3+xml",
|
|
373
|
+
"X-sap-adt-sessiontype": "stateful"
|
|
374
|
+
};
|
|
375
|
+
if (authHeader) headers.Authorization = authHeader;
|
|
376
|
+
const cookie = this.cookieStore.getCookieHeader();
|
|
377
|
+
if (cookie) headers.Cookie = cookie;
|
|
378
|
+
return headers;
|
|
379
|
+
}
|
|
380
|
+
async createSecuritySession(baseUrl, sessionsUrl, authHeader, signal) {
|
|
381
|
+
this.logger?.debug("Session: Creating security session");
|
|
382
|
+
const response = await this.fetchFn(sessionsUrl, {
|
|
383
|
+
method: "GET",
|
|
384
|
+
headers: {
|
|
385
|
+
...this.buildSessionHeaders(authHeader),
|
|
386
|
+
"x-sap-security-session": "create"
|
|
387
|
+
},
|
|
388
|
+
signal
|
|
389
|
+
});
|
|
390
|
+
signal?.throwIfAborted();
|
|
391
|
+
if (!response.ok) {
|
|
392
|
+
this.logger?.warn(`Session: Security session creation failed with status ${response.status}`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
this.processResponse(response);
|
|
396
|
+
const body = await response.text();
|
|
397
|
+
signal?.throwIfAborted();
|
|
398
|
+
return this.extractTrustedSessionPath({
|
|
399
|
+
baseUrl,
|
|
400
|
+
body
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
async acquireCsrfToken(sessionsUrl, authHeader, signal) {
|
|
404
|
+
this.logger?.debug("Session: Fetching CSRF token");
|
|
405
|
+
const response = await this.fetchFn(sessionsUrl, {
|
|
406
|
+
method: "GET",
|
|
407
|
+
headers: {
|
|
408
|
+
...this.buildSessionHeaders(authHeader),
|
|
409
|
+
"x-sap-security-session": "use",
|
|
410
|
+
"x-csrf-token": "Fetch"
|
|
411
|
+
},
|
|
412
|
+
signal
|
|
413
|
+
});
|
|
414
|
+
signal?.throwIfAborted();
|
|
415
|
+
if (!response.ok) {
|
|
416
|
+
this.logger?.warn(`Session: CSRF fetch failed with status ${response.status}`);
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
this.processResponse(response);
|
|
420
|
+
if (!this.csrfManager.hasCached()) {
|
|
421
|
+
this.logger?.warn("Session: CSRF fetch succeeded but no token found in response");
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
this.securitySessionActive = true;
|
|
425
|
+
this.logger?.debug("Session: CSRF token acquired");
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
async deleteSecuritySession({ sessionPath, csrfToken, baseUrl, authHeader, client, signal }) {
|
|
429
|
+
if (!this.isTrustedSessionPath({
|
|
430
|
+
baseUrl,
|
|
431
|
+
sessionPath
|
|
432
|
+
})) {
|
|
433
|
+
this.logger?.warn("Session: Refusing to delete security session with untrusted path");
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const deleteUrl = new URL(sessionPath, baseUrl);
|
|
437
|
+
if (client) deleteUrl.searchParams.append("sap-client", client);
|
|
438
|
+
try {
|
|
439
|
+
this.logger?.debug(`Session: Deleting security session ${sessionPath}`);
|
|
440
|
+
await this.fetchFn(deleteUrl, {
|
|
441
|
+
method: "DELETE",
|
|
442
|
+
headers: {
|
|
443
|
+
...this.buildSessionHeaders(authHeader),
|
|
444
|
+
"x-sap-security-session": "use",
|
|
445
|
+
"x-csrf-token": csrfToken
|
|
446
|
+
},
|
|
447
|
+
signal: signal ?? AbortSignal.timeout(5e3)
|
|
448
|
+
});
|
|
449
|
+
this.logger?.debug("Session: Security session deleted");
|
|
450
|
+
} catch {
|
|
451
|
+
this.logger?.debug("Session: Failed to delete security session (will expire)");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async fetchSecuritySessionCsrfToken({ baseUrl, authHeader, client, language, signal }) {
|
|
455
|
+
const sessionsUrl = this.buildSessionsUrl(baseUrl, client, language);
|
|
456
|
+
try {
|
|
457
|
+
this.logger?.debug("Session: Fetching CSRF token for security session cleanup");
|
|
458
|
+
const response = await this.fetchFn(sessionsUrl, {
|
|
316
459
|
method: "GET",
|
|
317
460
|
headers: {
|
|
318
|
-
...
|
|
461
|
+
...this.buildSessionHeaders(authHeader),
|
|
319
462
|
"x-sap-security-session": "use",
|
|
320
463
|
"x-csrf-token": "Fetch"
|
|
321
|
-
}
|
|
464
|
+
},
|
|
465
|
+
signal: signal ?? AbortSignal.timeout(5e3)
|
|
322
466
|
});
|
|
323
|
-
if (!
|
|
324
|
-
this.logger?.
|
|
325
|
-
return
|
|
326
|
-
}
|
|
327
|
-
this.processResponse(csrfResponse);
|
|
328
|
-
if (!this.csrfManager.hasCached()) {
|
|
329
|
-
this.logger?.warn("Session: CSRF fetch succeeded but no token found in response");
|
|
330
|
-
return false;
|
|
331
|
-
}
|
|
332
|
-
this.securitySessionActive = true;
|
|
333
|
-
this.logger?.debug("Session: CSRF token acquired");
|
|
334
|
-
if (sessionPath) {
|
|
335
|
-
const deleteUrl = new URL(sessionPath, baseUrl);
|
|
336
|
-
if (client) deleteUrl.searchParams.append("sap-client", client);
|
|
337
|
-
try {
|
|
338
|
-
this.logger?.debug(`Session: Deleting security session ${sessionPath}`);
|
|
339
|
-
await fetch(deleteUrl.toString(), {
|
|
340
|
-
method: "DELETE",
|
|
341
|
-
headers: {
|
|
342
|
-
...baseHeaders(),
|
|
343
|
-
"x-sap-security-session": "use",
|
|
344
|
-
"x-csrf-token": this.csrfManager.getCached()
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
this.logger?.debug("Session: Security session deleted");
|
|
348
|
-
} catch {
|
|
349
|
-
this.logger?.debug("Session: Failed to delete security session (will expire)");
|
|
350
|
-
}
|
|
467
|
+
if (!response.ok) {
|
|
468
|
+
this.logger?.debug(`Session: Cleanup CSRF fetch failed with status ${response.status}`);
|
|
469
|
+
return;
|
|
351
470
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
this.
|
|
355
|
-
|
|
471
|
+
const setCookieHeader = response.headers.get("set-cookie");
|
|
472
|
+
if (setCookieHeader) this.cookieStore.parseCookies(setCookieHeader);
|
|
473
|
+
return this.csrfManager.extractFromHeader(response.headers.get("x-csrf-token"));
|
|
474
|
+
} catch {
|
|
475
|
+
this.logger?.debug("Session: Failed to fetch cleanup CSRF token");
|
|
476
|
+
return;
|
|
356
477
|
}
|
|
357
478
|
}
|
|
479
|
+
async deleteSecuritySessionWithFallback({ sessionPath, baseUrl, authHeader, client, language, signal }) {
|
|
480
|
+
const token = await this.fetchSecuritySessionCsrfToken({
|
|
481
|
+
baseUrl,
|
|
482
|
+
authHeader,
|
|
483
|
+
client,
|
|
484
|
+
language,
|
|
485
|
+
signal
|
|
486
|
+
});
|
|
487
|
+
if (token) await this.deleteSecuritySession({
|
|
488
|
+
sessionPath,
|
|
489
|
+
csrfToken: token,
|
|
490
|
+
baseUrl,
|
|
491
|
+
authHeader,
|
|
492
|
+
client,
|
|
493
|
+
signal
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
handleCsrfInitializationError(error, { baseUrl, authHeader, client, language, sessionPath, signal }) {
|
|
497
|
+
if (sessionPath) this.deleteSecuritySessionWithFallback({
|
|
498
|
+
sessionPath,
|
|
499
|
+
baseUrl,
|
|
500
|
+
authHeader,
|
|
501
|
+
client,
|
|
502
|
+
language
|
|
503
|
+
}).catch(() => void 0);
|
|
504
|
+
if (signal?.aborted) throw error;
|
|
505
|
+
this.logger?.error(`Session: CSRF initialization error: ${error instanceof Error ? error.message : String(error)}`);
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
358
508
|
/**
|
|
359
509
|
* Clear all session state (cookies and CSRF)
|
|
360
510
|
*/
|
|
@@ -498,35 +648,166 @@ function parseAdtException(xml) {
|
|
|
498
648
|
* Create an AdtError from HTTP response details
|
|
499
649
|
*/
|
|
500
650
|
function createAdtError(status, statusText, url, method, rawBody) {
|
|
651
|
+
const exception = parseAdtException(rawBody);
|
|
501
652
|
return new AdtError({
|
|
502
653
|
status,
|
|
503
654
|
statusText,
|
|
504
655
|
url,
|
|
505
656
|
method,
|
|
506
657
|
rawBody,
|
|
507
|
-
exception
|
|
658
|
+
exception
|
|
508
659
|
});
|
|
509
660
|
}
|
|
510
661
|
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region src/cancellation.ts
|
|
664
|
+
const adtAbortSignal = new AsyncLocalStorage();
|
|
665
|
+
/**
|
|
666
|
+
* Runs an ADT operation with an execution-scoped abort signal. Async local
|
|
667
|
+
* storage keeps concurrent clients and invocations isolated without mutating
|
|
668
|
+
* shared client state.
|
|
669
|
+
*/
|
|
670
|
+
async function runWithAdtAbortSignal(signal, operation) {
|
|
671
|
+
signal.throwIfAborted();
|
|
672
|
+
return await adtAbortSignal.run(signal, operation);
|
|
673
|
+
}
|
|
674
|
+
function activeAdtAbortSignal() {
|
|
675
|
+
return adtAbortSignal.getStore();
|
|
676
|
+
}
|
|
677
|
+
|
|
511
678
|
//#endregion
|
|
512
679
|
//#region src/adapter.ts
|
|
680
|
+
/**
|
|
681
|
+
* A response exceeded an explicit byte cap before its body was retained.
|
|
682
|
+
* Deliberately carries size metadata only, never response text.
|
|
683
|
+
*/
|
|
684
|
+
var AdtResponseTooLargeError = class extends Error {
|
|
685
|
+
maxBytes;
|
|
686
|
+
receivedBytes;
|
|
687
|
+
code = "ADT_RESPONSE_TOO_LARGE";
|
|
688
|
+
constructor(maxBytes, receivedBytes) {
|
|
689
|
+
super(`ADT response exceeds the ${maxBytes}-byte limit.`);
|
|
690
|
+
this.maxBytes = maxBytes;
|
|
691
|
+
this.receivedBytes = receivedBytes;
|
|
692
|
+
this.name = "AdtResponseTooLargeError";
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
function contentLengthExceedsLimit(contentLength, maxBytes) {
|
|
696
|
+
if (!contentLength || !/^\d+$/.test(contentLength)) return void 0;
|
|
697
|
+
const parsed = Number(contentLength);
|
|
698
|
+
return Number.isSafeInteger(parsed) && parsed > maxBytes ? parsed : void 0;
|
|
699
|
+
}
|
|
700
|
+
async function abortResponse(response, abortController) {
|
|
701
|
+
abortController.abort();
|
|
702
|
+
try {
|
|
703
|
+
await response.body?.cancel();
|
|
704
|
+
} catch {}
|
|
705
|
+
}
|
|
706
|
+
async function readResponseTextBounded(response, maxBytes, abortController) {
|
|
707
|
+
const declaredBytes = contentLengthExceedsLimit(response.headers.get("content-length"), maxBytes);
|
|
708
|
+
if (declaredBytes !== void 0) {
|
|
709
|
+
await abortResponse(response, abortController);
|
|
710
|
+
throw new AdtResponseTooLargeError(maxBytes, declaredBytes);
|
|
711
|
+
}
|
|
712
|
+
if (!response.body) return "";
|
|
713
|
+
const reader = response.body.getReader();
|
|
714
|
+
const chunks = [];
|
|
715
|
+
let receivedBytes = 0;
|
|
716
|
+
try {
|
|
717
|
+
while (true) {
|
|
718
|
+
const { done, value } = await reader.read();
|
|
719
|
+
if (done) break;
|
|
720
|
+
if (!value) continue;
|
|
721
|
+
const nextReceivedBytes = receivedBytes + value.byteLength;
|
|
722
|
+
if (nextReceivedBytes > maxBytes) {
|
|
723
|
+
abortController.abort();
|
|
724
|
+
try {
|
|
725
|
+
await reader.cancel();
|
|
726
|
+
} catch {}
|
|
727
|
+
throw new AdtResponseTooLargeError(maxBytes, nextReceivedBytes);
|
|
728
|
+
}
|
|
729
|
+
chunks.push(value);
|
|
730
|
+
receivedBytes = nextReceivedBytes;
|
|
731
|
+
}
|
|
732
|
+
} finally {
|
|
733
|
+
reader.releaseLock();
|
|
734
|
+
}
|
|
735
|
+
const combined = new Uint8Array(receivedBytes);
|
|
736
|
+
let offset = 0;
|
|
737
|
+
for (const chunk of chunks) {
|
|
738
|
+
combined.set(chunk, offset);
|
|
739
|
+
offset += chunk.byteLength;
|
|
740
|
+
}
|
|
741
|
+
return new TextDecoder().decode(combined);
|
|
742
|
+
}
|
|
743
|
+
function executionAbortController() {
|
|
744
|
+
const abortController = new AbortController();
|
|
745
|
+
const executionSignal = activeAdtAbortSignal();
|
|
746
|
+
if (!executionSignal) return {
|
|
747
|
+
abortController,
|
|
748
|
+
dispose: () => void 0
|
|
749
|
+
};
|
|
750
|
+
const abortFromExecution = () => abortController.abort(executionSignal.reason);
|
|
751
|
+
if (executionSignal.aborted) abortFromExecution();
|
|
752
|
+
else executionSignal.addEventListener("abort", abortFromExecution, { once: true });
|
|
753
|
+
return {
|
|
754
|
+
abortController,
|
|
755
|
+
dispose: () => executionSignal.removeEventListener("abort", abortFromExecution)
|
|
756
|
+
};
|
|
757
|
+
}
|
|
513
758
|
function createAdtAdapter(config) {
|
|
514
|
-
const { baseUrl, username, password, cookieHeader, authorizationHeader, client, language, logger, plugins = [], onSessionExpired } = config;
|
|
759
|
+
const { baseUrl, username, password, cookieHeader, authorizationHeader, client, language, logger, plugins = [], onSessionExpired, headersTimeoutMs } = config;
|
|
760
|
+
const dispatcher = headersTimeoutMs === void 0 ? void 0 : new Agent({ headersTimeout: headersTimeoutMs });
|
|
761
|
+
/** Merge the optional Undici dispatcher into a fetch RequestInit. */
|
|
762
|
+
function withDispatcher(init) {
|
|
763
|
+
return dispatcher ? {
|
|
764
|
+
...init,
|
|
765
|
+
dispatcher
|
|
766
|
+
} : init;
|
|
767
|
+
}
|
|
768
|
+
/** Keep a configured Agent paired with the Undici fetch ABI that created it. */
|
|
769
|
+
async function requestFetch(input, init) {
|
|
770
|
+
if (!dispatcher) return globalThis.fetch(input, init);
|
|
771
|
+
return await fetch(input, init);
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Route a session-manager request through the same paired Undici
|
|
775
|
+
* fetch/dispatcher used for the final adapter request, so the
|
|
776
|
+
* configured headersTimeout applies to the entire write flow
|
|
777
|
+
* (CSRF handshake included), not just the last hop.
|
|
778
|
+
*/
|
|
779
|
+
async function sessionFetch(input, init) {
|
|
780
|
+
return requestFetch(input.toString(), withDispatcher(init ?? {}));
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Resolve `options.url` against the configured `baseUrl` and reject
|
|
784
|
+
* absolute URLs that would send credentials to a different origin.
|
|
785
|
+
*/
|
|
786
|
+
function resolveAdtUrl(requestUrl) {
|
|
787
|
+
const resolved = new URL(requestUrl, baseUrl);
|
|
788
|
+
const baseOrigin = new URL(baseUrl).origin;
|
|
789
|
+
if (resolved.origin !== baseOrigin) throw new RangeError(`Refusing to fetch ${resolved.origin} — ADT requests must target the configured base origin ${baseOrigin}`);
|
|
790
|
+
return resolved;
|
|
791
|
+
}
|
|
515
792
|
const isSamlAuth = !!cookieHeader;
|
|
516
793
|
const basicCredentials = `${username}:${password}`;
|
|
517
794
|
const authHeader = authorizationHeader ?? (isSamlAuth ? void 0 : `Basic ${Buffer.from(basicCredentials).toString("base64")}`);
|
|
518
|
-
const sessionManager = new SessionManager(logger);
|
|
795
|
+
const sessionManager = new SessionManager(logger, sessionFetch);
|
|
519
796
|
if (cookieHeader) sessionManager.injectCookie(cookieHeader);
|
|
520
797
|
return {
|
|
521
798
|
async request(options) {
|
|
799
|
+
const executionSignal = activeAdtAbortSignal();
|
|
800
|
+
executionSignal?.throwIfAborted();
|
|
522
801
|
logger?.debug("=== ADAPTER REQUEST START ===");
|
|
523
802
|
logger?.debug("options.url:", options.url);
|
|
524
803
|
logger?.debug("options.method:", options.method);
|
|
525
804
|
logger?.debug("options.body:", options.body ? "present" : "undefined");
|
|
526
805
|
logger?.debug("options.bodySchema:", options.bodySchema ? "present" : "undefined");
|
|
527
|
-
const url =
|
|
806
|
+
const url = resolveAdtUrl(options.url);
|
|
528
807
|
if (options.query) Object.entries(options.query).forEach(([key, value]) => {
|
|
529
|
-
|
|
808
|
+
(Array.isArray(value) ? value : [value]).forEach((entry) => {
|
|
809
|
+
if (entry !== void 0) url.searchParams.append(key, String(entry));
|
|
810
|
+
});
|
|
530
811
|
});
|
|
531
812
|
if (client) url.searchParams.append("sap-client", client);
|
|
532
813
|
if (language) url.searchParams.append("sap-language", language);
|
|
@@ -537,7 +818,12 @@ function createAdtAdapter(config) {
|
|
|
537
818
|
"PATCH"
|
|
538
819
|
].includes(options.method.toUpperCase()) && !sessionManager.hasCsrfToken()) {
|
|
539
820
|
logger?.debug("Adapter: Initializing CSRF token before write operation");
|
|
540
|
-
await sessionManager.initializeCsrf(baseUrl,
|
|
821
|
+
await sessionManager.initializeCsrf(baseUrl, {
|
|
822
|
+
authHeader,
|
|
823
|
+
client,
|
|
824
|
+
language,
|
|
825
|
+
signal: executionSignal
|
|
826
|
+
});
|
|
541
827
|
}
|
|
542
828
|
const headers = {
|
|
543
829
|
"X-sap-adt-sessiontype": sessionManager.getSessionTypeHeader(),
|
|
@@ -568,41 +854,39 @@ function createAdtAdapter(config) {
|
|
|
568
854
|
logger?.debug("bodySerializableSchema: " + (bodySerializableSchema ? "present" : "undefined"));
|
|
569
855
|
logger?.debug("Body type: " + typeof body);
|
|
570
856
|
logger?.debug("Body value: " + (body ? JSON.stringify(body).substring(0, 500) : "UNDEFINED/NULL"));
|
|
571
|
-
if (body !== void 0 && body !== null)
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
857
|
+
if (body !== void 0 && body !== null) {
|
|
858
|
+
if (typeof body === "string") {
|
|
859
|
+
requestBody = body;
|
|
860
|
+
logger?.debug("Body: using raw string");
|
|
861
|
+
logger?.debug("Body content (first 200 chars):", requestBody.substring(0, 200));
|
|
862
|
+
} else if (bodySerializableSchema) try {
|
|
863
|
+
logger?.debug("Calling bodySerializableSchema.build with body: " + JSON.stringify(body).substring(0, 300));
|
|
864
|
+
requestBody = bodySerializableSchema.build(body);
|
|
865
|
+
logger?.debug("Body: serialized using Serializable.build()");
|
|
866
|
+
logger?.debug("Body output type: " + typeof requestBody);
|
|
867
|
+
logger?.debug("Body output length: " + requestBody?.length);
|
|
868
|
+
logger?.debug("Body content (first 500 chars): " + requestBody?.substring(0, 500));
|
|
869
|
+
} catch (buildError) {
|
|
870
|
+
logger?.error("Schema build() threw error: " + (buildError instanceof Error ? buildError.message : String(buildError)));
|
|
871
|
+
logger?.error("Stack: " + (buildError instanceof Error ? buildError.stack : "N/A"));
|
|
872
|
+
throw buildError;
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
requestBody = JSON.stringify(body);
|
|
876
|
+
logger?.debug("Body: serialized as JSON");
|
|
877
|
+
}
|
|
590
878
|
}
|
|
591
879
|
logger?.debug(`HTTP ${options.method} ${url.toString()}`);
|
|
592
880
|
{
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
...headers.Authorization ? { Authorization: headers.Authorization.substring(0, 10) + "***" } : {},
|
|
596
|
-
...headers.Cookie ? { Cookie: "***" } : {},
|
|
597
|
-
...headers["x-csrf-token"] ? { "x-csrf-token": "***" } : {}
|
|
598
|
-
};
|
|
599
|
-
logger?.debug("Request headers: " + JSON.stringify(safeHeaders, null, 2));
|
|
881
|
+
const headerNames = Object.keys(headers).sort();
|
|
882
|
+
logger?.debug("Request headers (names only): " + JSON.stringify(headerNames));
|
|
600
883
|
}
|
|
601
|
-
const response = await
|
|
884
|
+
const response = await requestFetch(url.toString(), withDispatcher({
|
|
602
885
|
method: options.method,
|
|
603
886
|
headers,
|
|
604
|
-
body: requestBody
|
|
605
|
-
|
|
887
|
+
body: requestBody,
|
|
888
|
+
signal: executionSignal
|
|
889
|
+
}));
|
|
606
890
|
sessionManager.processResponse(response, url.pathname);
|
|
607
891
|
logger?.debug(`Response: ${response.status} ${response.statusText}`);
|
|
608
892
|
logger?.debug("Response headers: " + JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2));
|
|
@@ -622,10 +906,12 @@ function createAdtAdapter(config) {
|
|
|
622
906
|
logger?.error(`Request failed - ${adtError.message} (${options.method} ${url.toString()})`);
|
|
623
907
|
if (adtError.exception) logger?.error(`ADT Exception: namespace=${adtError.exception.namespace}, type=${adtError.exception.type}`);
|
|
624
908
|
if (errorBody && !adtError.exception) logger?.error(`Response body: ${errorBody}`);
|
|
625
|
-
if (response.status === 403)
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
909
|
+
if (response.status === 403) {
|
|
910
|
+
if (!(adtError.exception?.type === "ExceptionResourceNoAccess" && (adtError.message.includes("currently editing") || adtError.message.includes("is being edited")))) {
|
|
911
|
+
sessionManager.clear();
|
|
912
|
+
logger?.warn("Session cleared due to 403 Forbidden response");
|
|
913
|
+
} else logger?.warn("Lock conflict (403) — session preserved (object locked by another session)");
|
|
914
|
+
}
|
|
629
915
|
throw adtError;
|
|
630
916
|
}
|
|
631
917
|
const contentType = response.headers.get("content-type") || "";
|
|
@@ -649,9 +935,10 @@ function createAdtAdapter(config) {
|
|
|
649
935
|
}
|
|
650
936
|
let data;
|
|
651
937
|
if (contentType.includes("application/json") || contentType.includes("+json")) data = JSON.parse(rawText);
|
|
652
|
-
else if (contentType.includes("text/") || contentType.includes("xml"))
|
|
653
|
-
|
|
654
|
-
|
|
938
|
+
else if (contentType.includes("text/") || contentType.includes("xml")) {
|
|
939
|
+
if (serializableSchema && contentType.includes("xml")) data = serializableSchema.parse(rawText);
|
|
940
|
+
else data = rawText;
|
|
941
|
+
} else data = rawText;
|
|
655
942
|
if (plugins.length > 0) {
|
|
656
943
|
const context = {
|
|
657
944
|
rawText,
|
|
@@ -672,6 +959,32 @@ function createAdtAdapter(config) {
|
|
|
672
959
|
},
|
|
673
960
|
clearETag(url) {
|
|
674
961
|
sessionManager.clearETag(url);
|
|
962
|
+
},
|
|
963
|
+
async readTextBounded(options, maxBytes) {
|
|
964
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new RangeError("maxBytes must be a non-negative safe integer.");
|
|
965
|
+
const url = resolveAdtUrl(options.url);
|
|
966
|
+
if (client) url.searchParams.append("sap-client", client);
|
|
967
|
+
if (language) url.searchParams.append("sap-language", language);
|
|
968
|
+
const headers = {
|
|
969
|
+
"X-sap-adt-sessiontype": sessionManager.getSessionTypeHeader(),
|
|
970
|
+
...sessionManager.getRequestHeaders("GET", url.pathname),
|
|
971
|
+
...options.headers
|
|
972
|
+
};
|
|
973
|
+
if (authHeader) headers.Authorization = authHeader;
|
|
974
|
+
const { abortController, dispose } = executionAbortController();
|
|
975
|
+
try {
|
|
976
|
+
const response = await requestFetch(url.toString(), withDispatcher({
|
|
977
|
+
method: "GET",
|
|
978
|
+
headers,
|
|
979
|
+
signal: abortController.signal
|
|
980
|
+
}));
|
|
981
|
+
sessionManager.processResponse(response, url.pathname);
|
|
982
|
+
const text = await readResponseTextBounded(response, maxBytes, abortController);
|
|
983
|
+
if (!response.ok) throw createAdtError(response.status, response.statusText, url.toString(), "GET", text);
|
|
984
|
+
return text;
|
|
985
|
+
} finally {
|
|
986
|
+
dispose();
|
|
987
|
+
}
|
|
675
988
|
}
|
|
676
989
|
};
|
|
677
990
|
}
|
|
@@ -682,6 +995,7 @@ function createAdtAdapter(config) {
|
|
|
682
995
|
* Transport Service - orchestrates CTS operations
|
|
683
996
|
*/
|
|
684
997
|
var TransportService = class {
|
|
998
|
+
adt;
|
|
685
999
|
constructor(adt) {
|
|
686
1000
|
this.adt = adt;
|
|
687
1001
|
}
|
|
@@ -698,7 +1012,8 @@ var TransportService = class {
|
|
|
698
1012
|
async get(trkorr) {
|
|
699
1013
|
const data = await this.adt.cts.transportrequests.get(trkorr);
|
|
700
1014
|
const unwrapped = data?.root && typeof data.root === "object" ? data.root : data;
|
|
701
|
-
const
|
|
1015
|
+
const wanted = trkorr.trim().toUpperCase();
|
|
1016
|
+
const request = this.findUnit(unwrapped?.task, wanted) ?? this.findUnit(unwrapped?.request, wanted) ?? unwrapped?.request ?? unwrapped?.task ?? unwrapped;
|
|
702
1017
|
return this.mapToTransport(request);
|
|
703
1018
|
}
|
|
704
1019
|
/**
|
|
@@ -727,6 +1042,17 @@ var TransportService = class {
|
|
|
727
1042
|
throw new Error("Transport release via service layer not yet implemented.");
|
|
728
1043
|
}
|
|
729
1044
|
/**
|
|
1045
|
+
* Find a request/task unit whose number matches the wanted transport id.
|
|
1046
|
+
* Handles both single-object and array shapes from ts-xsd.
|
|
1047
|
+
*/
|
|
1048
|
+
findUnit(node, wanted) {
|
|
1049
|
+
if (!node) return void 0;
|
|
1050
|
+
return (Array.isArray(node) ? node : [node]).find((item) => {
|
|
1051
|
+
const u = item;
|
|
1052
|
+
return String(u?.trkorr ?? u?.number ?? "").toUpperCase() === wanted;
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
730
1056
|
* Extract requests array from various response formats
|
|
731
1057
|
*/
|
|
732
1058
|
extractRequests(response) {
|
|
@@ -750,6 +1076,8 @@ var TransportService = class {
|
|
|
750
1076
|
status: data.trstatus ?? data.status,
|
|
751
1077
|
type: data.trfunction ?? data.type,
|
|
752
1078
|
target: data.tarsystem ?? data.target,
|
|
1079
|
+
parent: data.parent,
|
|
1080
|
+
lastChangedTimestamp: data.lastchanged_timestamp ?? data.lastChangedTimestamp,
|
|
753
1081
|
tasks: this.extractTasks(data)
|
|
754
1082
|
};
|
|
755
1083
|
}
|
|
@@ -764,7 +1092,11 @@ var TransportService = class {
|
|
|
764
1092
|
return {
|
|
765
1093
|
number: String(t.trkorr ?? t.number ?? ""),
|
|
766
1094
|
owner: t.as4user ?? t.owner,
|
|
767
|
-
status: t.trstatus ?? t.status
|
|
1095
|
+
status: t.trstatus ?? t.status,
|
|
1096
|
+
desc: t.as4text ?? t.desc,
|
|
1097
|
+
type: t.trfunction ?? t.type,
|
|
1098
|
+
parent: t.parent,
|
|
1099
|
+
lastChangedTimestamp: t.lastchanged_timestamp ?? t.lastChangedTimestamp
|
|
768
1100
|
};
|
|
769
1101
|
});
|
|
770
1102
|
}
|
|
@@ -798,6 +1130,7 @@ function toUserInfoList(entries) {
|
|
|
798
1130
|
* User Service - orchestrates user lookup operations
|
|
799
1131
|
*/
|
|
800
1132
|
var UserService = class {
|
|
1133
|
+
adt;
|
|
801
1134
|
constructor(adt) {
|
|
802
1135
|
this.adt = adt;
|
|
803
1136
|
}
|
|
@@ -832,6 +1165,224 @@ function createUserService(adt) {
|
|
|
832
1165
|
return new UserService(adt);
|
|
833
1166
|
}
|
|
834
1167
|
|
|
1168
|
+
//#endregion
|
|
1169
|
+
//#region src/services/source-history.ts
|
|
1170
|
+
var SourceHistoryProtocolError = class extends Error {
|
|
1171
|
+
code;
|
|
1172
|
+
ordinal;
|
|
1173
|
+
constructor(code, message, ordinal) {
|
|
1174
|
+
super(message);
|
|
1175
|
+
this.code = code;
|
|
1176
|
+
this.ordinal = ordinal;
|
|
1177
|
+
this.name = "SourceHistoryProtocolError";
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
/**
|
|
1181
|
+
* An immutable source body crossed its configured byte limit.
|
|
1182
|
+
* This error intentionally does not retain the source URI or any source text.
|
|
1183
|
+
*/
|
|
1184
|
+
var SourceVersionTooLargeError = class extends Error {
|
|
1185
|
+
maxBytes;
|
|
1186
|
+
receivedBytes;
|
|
1187
|
+
code = "SOURCE_VERSION_TOO_LARGE";
|
|
1188
|
+
constructor(maxBytes, receivedBytes) {
|
|
1189
|
+
super(`Source version exceeds the ${maxBytes}-byte limit.`);
|
|
1190
|
+
this.maxBytes = maxBytes;
|
|
1191
|
+
this.receivedBytes = receivedBytes;
|
|
1192
|
+
this.name = "SourceVersionTooLargeError";
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
const ADT_PATH_PREFIX = "/sap/bc/adt/";
|
|
1196
|
+
const TRAVERSAL_SEGMENT = /(?:^|\/)(?:\.{1,2}|%2e(?:%2e)?)(?:\/|$)/i;
|
|
1197
|
+
const TRANSPORT_NUMBER = /^[A-Z0-9]{10}$/;
|
|
1198
|
+
function asOptionalString(value) {
|
|
1199
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1200
|
+
}
|
|
1201
|
+
function asArray(value) {
|
|
1202
|
+
if (value === void 0) return [];
|
|
1203
|
+
return Array.isArray(value) ? value : [value];
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Validate a server-relative ADT URI before it reaches the authenticated
|
|
1207
|
+
* adapter. This deliberately rejects absolute and protocol-relative URLs so a
|
|
1208
|
+
* source-history link cannot redirect credentials to another origin.
|
|
1209
|
+
*/
|
|
1210
|
+
function isUnsafeAdtUri(uri) {
|
|
1211
|
+
if (uri !== uri.trim()) return true;
|
|
1212
|
+
if (!uri.startsWith(ADT_PATH_PREFIX)) return true;
|
|
1213
|
+
if (uri.startsWith("//")) return true;
|
|
1214
|
+
if (uri.includes("#")) return true;
|
|
1215
|
+
if (uri.includes("\\")) return true;
|
|
1216
|
+
return TRAVERSAL_SEGMENT.test(uri);
|
|
1217
|
+
}
|
|
1218
|
+
function assertAdtUri(uri) {
|
|
1219
|
+
if (isUnsafeAdtUri(uri)) throw new SourceHistoryProtocolError("SOURCE_HISTORY_URI_UNSAFE", "Source-history URI must be a server-relative SAP ADT path.");
|
|
1220
|
+
return uri;
|
|
1221
|
+
}
|
|
1222
|
+
function isUnsafeRelativeHref(href) {
|
|
1223
|
+
if (href.startsWith("//")) return true;
|
|
1224
|
+
if (href.includes("\\")) return true;
|
|
1225
|
+
if (href.includes(":")) return true;
|
|
1226
|
+
return TRAVERSAL_SEGMENT.test(href);
|
|
1227
|
+
}
|
|
1228
|
+
function resolveRelativeAdtUri(href, versionsUri) {
|
|
1229
|
+
const validatedBase = assertAdtUri(versionsUri);
|
|
1230
|
+
const collectionBase = validatedBase.endsWith("/") ? validatedBase : `${validatedBase}/`;
|
|
1231
|
+
const resolved = new URL(href, `https://adt.invalid${collectionBase}`);
|
|
1232
|
+
return assertAdtUri(`${resolved.pathname}${resolved.search}${resolved.hash}`);
|
|
1233
|
+
}
|
|
1234
|
+
function resolveAdtUri(href, versionsUri) {
|
|
1235
|
+
if (href.startsWith("/")) return assertAdtUri(href);
|
|
1236
|
+
if (isUnsafeRelativeHref(href)) throw new SourceHistoryProtocolError("SOURCE_HISTORY_URI_UNSAFE", "Source-history link must stay within the SAP ADT origin.");
|
|
1237
|
+
return resolveRelativeAdtUri(href, versionsUri);
|
|
1238
|
+
}
|
|
1239
|
+
function normalizeLinks(value) {
|
|
1240
|
+
return asArray(value).filter((link) => Boolean(link) && typeof link === "object");
|
|
1241
|
+
}
|
|
1242
|
+
function isContentLink(link) {
|
|
1243
|
+
const rel = asOptionalString(link.rel)?.toLowerCase();
|
|
1244
|
+
return Boolean(rel?.endsWith("/content") || rel === "content");
|
|
1245
|
+
}
|
|
1246
|
+
function isPlainTextContentLink(link) {
|
|
1247
|
+
const contentType = asOptionalString(link.type)?.toLowerCase();
|
|
1248
|
+
return Boolean(contentType?.startsWith("text/plain"));
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Some SAP source-version feeds represent the immutable source body with the
|
|
1252
|
+
* standard Atom `content` element rather than an Atom link. Treat it exactly
|
|
1253
|
+
* like a content link, but only when it is explicitly plain text.
|
|
1254
|
+
*/
|
|
1255
|
+
function contentElementAsLink(entry) {
|
|
1256
|
+
const content = entry.content;
|
|
1257
|
+
if (!content) return void 0;
|
|
1258
|
+
if (typeof content !== "object") return void 0;
|
|
1259
|
+
if (Array.isArray(content)) return void 0;
|
|
1260
|
+
const src = asOptionalString(content.src);
|
|
1261
|
+
if (!src) return void 0;
|
|
1262
|
+
const type = asOptionalString(content.type);
|
|
1263
|
+
if (!type) return void 0;
|
|
1264
|
+
if (!type.toLowerCase().startsWith("text/plain")) return void 0;
|
|
1265
|
+
return {
|
|
1266
|
+
href: src,
|
|
1267
|
+
type
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
function isTransportRel(rel) {
|
|
1271
|
+
return rel.endsWith("/transport") || rel.endsWith("/transport/request");
|
|
1272
|
+
}
|
|
1273
|
+
function transportFromTitle(link) {
|
|
1274
|
+
const title = asOptionalString(link.title)?.toUpperCase();
|
|
1275
|
+
if (!title) return void 0;
|
|
1276
|
+
if (TRANSPORT_NUMBER.test(title)) return title;
|
|
1277
|
+
}
|
|
1278
|
+
function transportFromHref(link) {
|
|
1279
|
+
const href = asOptionalString(link.href);
|
|
1280
|
+
if (!href) return void 0;
|
|
1281
|
+
const lastSegment = (href.split(/[?#]/, 1)[0]?.split("/").filter((segment) => segment !== ""))?.pop();
|
|
1282
|
+
if (!lastSegment) return void 0;
|
|
1283
|
+
try {
|
|
1284
|
+
const candidate = decodeURIComponent(lastSegment).toUpperCase();
|
|
1285
|
+
return TRANSPORT_NUMBER.test(candidate) ? candidate : void 0;
|
|
1286
|
+
} catch {
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
function transportFromLink(link) {
|
|
1291
|
+
const rel = asOptionalString(link.rel)?.toLowerCase();
|
|
1292
|
+
if (!rel || !isTransportRel(rel)) return void 0;
|
|
1293
|
+
return transportFromTitle(link) ?? transportFromHref(link);
|
|
1294
|
+
}
|
|
1295
|
+
function authorName(value) {
|
|
1296
|
+
return asOptionalString(asArray(value).find((candidate) => Boolean(candidate) && typeof candidate === "object")?.name);
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Normalize the parsed Atom versions feed while preserving SAP's feed order.
|
|
1300
|
+
* The result contains references and provenance only; source bodies are fetched
|
|
1301
|
+
* separately and are never retained by this function.
|
|
1302
|
+
*/
|
|
1303
|
+
function assertSourceVersionPayload(payload) {
|
|
1304
|
+
if (!payload || typeof payload !== "object") throw new SourceHistoryProtocolError("SOURCE_VERSION_FEED_INVALID", "SAP ADT source-history response is not an Atom feed.");
|
|
1305
|
+
if (!("feed" in payload)) throw new SourceHistoryProtocolError("SOURCE_VERSION_FEED_INVALID", "SAP ADT source-history response is not an Atom feed.");
|
|
1306
|
+
return payload;
|
|
1307
|
+
}
|
|
1308
|
+
function assertSourceVersionFeed(feed) {
|
|
1309
|
+
if (!feed) throw invalidFeedError();
|
|
1310
|
+
if (typeof feed !== "object") throw invalidFeedError();
|
|
1311
|
+
if (Array.isArray(feed)) throw invalidFeedError();
|
|
1312
|
+
return feed;
|
|
1313
|
+
}
|
|
1314
|
+
function invalidFeedError() {
|
|
1315
|
+
return new SourceHistoryProtocolError("SOURCE_VERSION_FEED_INVALID", "SAP ADT source-history feed has an invalid root.");
|
|
1316
|
+
}
|
|
1317
|
+
function readSourceVersionEntries(feed) {
|
|
1318
|
+
if (feed.entry === void 0) return [];
|
|
1319
|
+
const entries = asArray(feed.entry);
|
|
1320
|
+
if (entries.some((entry) => !entry || typeof entry !== "object")) throw new SourceHistoryProtocolError("SOURCE_VERSION_FEED_INVALID", "SAP ADT source-history feed contains an invalid entry.");
|
|
1321
|
+
return entries;
|
|
1322
|
+
}
|
|
1323
|
+
function extractContentLink(entry, ordinal) {
|
|
1324
|
+
const links = normalizeLinks(entry.link);
|
|
1325
|
+
const contentLink = links.find(isContentLink) ?? links.find(isPlainTextContentLink) ?? contentElementAsLink(entry);
|
|
1326
|
+
const href = contentLink && asOptionalString(contentLink.href);
|
|
1327
|
+
if (!contentLink || !href) throw new SourceHistoryProtocolError("SOURCE_VERSION_CONTENT_LINK_MISSING", `Source-history entry ${ordinal} has no immutable content link.`, ordinal);
|
|
1328
|
+
return {
|
|
1329
|
+
...contentLink,
|
|
1330
|
+
href
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
function extractEntryTransports(links) {
|
|
1334
|
+
return [...new Set(links.map(transportFromLink).filter((transport) => Boolean(transport)))];
|
|
1335
|
+
}
|
|
1336
|
+
function sourceVersionRefFromEntry(entry, ordinal, versionsUri) {
|
|
1337
|
+
const id = asOptionalString(entry.id);
|
|
1338
|
+
if (!id) throw new SourceHistoryProtocolError("SOURCE_VERSION_ID_MISSING", `Source-history entry ${ordinal} has no stable version id.`, ordinal);
|
|
1339
|
+
const contentLink = extractContentLink(entry, ordinal);
|
|
1340
|
+
return {
|
|
1341
|
+
id,
|
|
1342
|
+
ordinal,
|
|
1343
|
+
title: asOptionalString(entry.title),
|
|
1344
|
+
sourceUri: resolveAdtUri(contentLink.href, versionsUri),
|
|
1345
|
+
contentType: asOptionalString(contentLink.type),
|
|
1346
|
+
etag: asOptionalString(contentLink.etag),
|
|
1347
|
+
updatedAt: asOptionalString(entry.updated),
|
|
1348
|
+
author: authorName(entry.author),
|
|
1349
|
+
transports: extractEntryTransports(normalizeLinks(entry.link))
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
function normalizeSourceVersionFeed(payload, versionsUri) {
|
|
1353
|
+
assertAdtUri(versionsUri);
|
|
1354
|
+
return readSourceVersionEntries(assertSourceVersionFeed(assertSourceVersionPayload(payload).feed)).map((entry, ordinal) => sourceVersionRefFromEntry(entry, ordinal, versionsUri));
|
|
1355
|
+
}
|
|
1356
|
+
var SourceHistoryService = class {
|
|
1357
|
+
adt;
|
|
1358
|
+
boundedSourceReader;
|
|
1359
|
+
constructor(adt, boundedSourceReader) {
|
|
1360
|
+
this.adt = adt;
|
|
1361
|
+
this.boundedSourceReader = boundedSourceReader;
|
|
1362
|
+
}
|
|
1363
|
+
async listVersions(versionsUri) {
|
|
1364
|
+
assertAdtUri(versionsUri);
|
|
1365
|
+
return normalizeSourceVersionFeed(await this.adt.repository.sourceversions.list({ versionsUri }), versionsUri);
|
|
1366
|
+
}
|
|
1367
|
+
async readVersionSource(sourceUri) {
|
|
1368
|
+
assertAdtUri(sourceUri);
|
|
1369
|
+
return this.adt.repository.sourceversions.get({ sourceUri });
|
|
1370
|
+
}
|
|
1371
|
+
async readVersionSourceBounded(sourceUri, maxBytes) {
|
|
1372
|
+
assertAdtUri(sourceUri);
|
|
1373
|
+
if (!this.boundedSourceReader) throw new Error("Bounded source reads are not configured.");
|
|
1374
|
+
try {
|
|
1375
|
+
return await this.boundedSourceReader(sourceUri, maxBytes);
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
if (error instanceof AdtResponseTooLargeError) throw new SourceVersionTooLargeError(error.maxBytes, error.receivedBytes);
|
|
1378
|
+
throw error;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
function createSourceHistoryService(adt, boundedSourceReader) {
|
|
1383
|
+
return new SourceHistoryService(adt, boundedSourceReader);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
835
1386
|
//#endregion
|
|
836
1387
|
//#region src/client.ts
|
|
837
1388
|
/**
|
|
@@ -859,10 +1410,41 @@ function createAdtClient(config) {
|
|
|
859
1410
|
};
|
|
860
1411
|
return adapter.request(requestOptions);
|
|
861
1412
|
};
|
|
1413
|
+
const readTextBounded = async (url, maxBytes, options) => adapter.readTextBounded({
|
|
1414
|
+
url,
|
|
1415
|
+
headers: options?.headers
|
|
1416
|
+
}, maxBytes);
|
|
862
1417
|
return {
|
|
1418
|
+
/**
|
|
1419
|
+
* Typed ADT REST contracts
|
|
1420
|
+
* Direct access to speci-generated client methods
|
|
1421
|
+
*/
|
|
863
1422
|
adt: adtClient,
|
|
864
|
-
|
|
1423
|
+
/**
|
|
1424
|
+
* Services layer - business logic orchestration
|
|
1425
|
+
* Higher-level operations that combine multiple contract calls
|
|
1426
|
+
*/
|
|
1427
|
+
services: {
|
|
1428
|
+
transports: createTransportService(adtClient),
|
|
1429
|
+
sourceHistory: createSourceHistoryService(adtClient, (sourceUri, maxBytes) => readTextBounded(sourceUri, maxBytes, { headers: { Accept: "text/plain" } }))
|
|
1430
|
+
},
|
|
1431
|
+
/**
|
|
1432
|
+
* Generic fetch utility for arbitrary ADT endpoints
|
|
1433
|
+
* Useful for debugging, testing, or accessing undocumented APIs
|
|
1434
|
+
*
|
|
1435
|
+
* @param url - The ADT endpoint path (e.g., '/sap/bc/adt/core/http/sessions')
|
|
1436
|
+
* @param options - Request options (method, headers, body)
|
|
1437
|
+
* @returns Raw response
|
|
1438
|
+
*/
|
|
865
1439
|
fetch: fetchFn,
|
|
1440
|
+
/**
|
|
1441
|
+
* Read an authenticated plain-text endpoint while enforcing a byte limit.
|
|
1442
|
+
*/
|
|
1443
|
+
readTextBounded,
|
|
1444
|
+
/**
|
|
1445
|
+
* Clear cached ETag for a specific URL, or all ETags if no URL given.
|
|
1446
|
+
* Use after operations that may invalidate server-side ETags.
|
|
1447
|
+
*/
|
|
866
1448
|
clearETag: (url) => adapter.clearETag(url)
|
|
867
1449
|
};
|
|
868
1450
|
}
|
|
@@ -873,6 +1455,7 @@ function createAdtClient(config) {
|
|
|
873
1455
|
* Logging plugin - logs requests and responses
|
|
874
1456
|
*/
|
|
875
1457
|
var LoggingPlugin = class {
|
|
1458
|
+
logger;
|
|
876
1459
|
name = "logging";
|
|
877
1460
|
constructor(logger = console.log) {
|
|
878
1461
|
this.logger = logger;
|
|
@@ -897,6 +1480,7 @@ var LoggingPlugin = class {
|
|
|
897
1480
|
* Similar to v1's FileLogger functionality
|
|
898
1481
|
*/
|
|
899
1482
|
var FileLoggingPlugin = class {
|
|
1483
|
+
config;
|
|
900
1484
|
name = "file-logging";
|
|
901
1485
|
constructor(config) {
|
|
902
1486
|
this.config = config;
|
|
@@ -965,5 +1549,5 @@ var FileLoggingPlugin = class {
|
|
|
965
1549
|
};
|
|
966
1550
|
|
|
967
1551
|
//#endregion
|
|
968
|
-
export { CookieStore, CsrfTokenManager, FileLoggingPlugin, LoggingPlugin, SessionManager, TransportService, UserService, adtContract, createAdtAdapter, createAdtClient, createTransportService, createUserService };
|
|
1552
|
+
export { AdtResponseTooLargeError, CookieStore, CsrfTokenManager, FileLoggingPlugin, LoggingPlugin, SessionManager, SourceHistoryProtocolError, SourceHistoryService, SourceVersionTooLargeError, TransportService, UserService, adtContract, assertAdtUri, createAdtAdapter, createAdtClient, createSourceHistoryService, createTransportService, createUserService, normalizeSourceVersionFeed, runWithAdtAbortSignal };
|
|
969
1553
|
//# sourceMappingURL=index.mjs.map
|