@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/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
- * @param baseUrl - SAP system base URL
280
- * @param authHeader - Authorization header (Basic/Bearer), or undefined for cookie auth
281
- * @param client - SAP client number
282
- * @param language - SAP language
283
- */
284
- async initializeCsrf(baseUrl, authHeader, client, language) {
285
- const sessionsUrl = new URL("/sap/bc/adt/core/http/sessions", baseUrl);
286
- if (client) sessionsUrl.searchParams.append("sap-client", client);
287
- if (language) sessionsUrl.searchParams.append("sap-language", language);
288
- /** Build common headers, optionally including Authorization and cookies */
289
- const baseHeaders = () => {
290
- const h = {
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.logger?.debug("Session: Creating security session");
301
- const createResponse = await fetch(sessionsUrl.toString(), {
302
- method: "GET",
303
- headers: {
304
- ...baseHeaders(),
305
- "x-sap-security-session": "create"
306
- }
307
- });
308
- if (!createResponse.ok) {
309
- this.logger?.warn(`Session: Security session creation failed with status ${createResponse.status}`);
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.processResponse(createResponse);
313
- const sessionPath = (await createResponse.text()).match(/href="([^"]*\/sessions\/[^"]*)"/)?.[1];
314
- this.logger?.debug("Session: Fetching CSRF token");
315
- const csrfResponse = await fetch(sessionsUrl.toString(), {
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
- ...baseHeaders(),
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 (!csrfResponse.ok) {
324
- this.logger?.warn(`Session: CSRF fetch failed with status ${csrfResponse.status}`);
325
- return false;
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
- return true;
353
- } catch (error) {
354
- this.logger?.error(`Session: CSRF initialization error: ${error instanceof Error ? error.message : String(error)}`);
355
- return false;
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: parseAdtException(rawBody)
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 = new URL(options.url, baseUrl);
806
+ const url = resolveAdtUrl(options.url);
528
807
  if (options.query) Object.entries(options.query).forEach(([key, value]) => {
529
- url.searchParams.append(key, String(value));
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, authHeader, client, language);
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) if (typeof body === "string") {
572
- requestBody = body;
573
- logger?.debug("Body: using raw string");
574
- logger?.debug("Body content (first 200 chars):", requestBody.substring(0, 200));
575
- } else if (bodySerializableSchema) try {
576
- logger?.debug("Calling bodySerializableSchema.build with body: " + JSON.stringify(body).substring(0, 300));
577
- requestBody = bodySerializableSchema.build(body);
578
- logger?.debug("Body: serialized using Serializable.build()");
579
- logger?.debug("Body output type: " + typeof requestBody);
580
- logger?.debug("Body output length: " + requestBody?.length);
581
- logger?.debug("Body content (first 500 chars): " + requestBody?.substring(0, 500));
582
- } catch (buildError) {
583
- logger?.error("Schema build() threw error: " + (buildError instanceof Error ? buildError.message : String(buildError)));
584
- logger?.error("Stack: " + (buildError instanceof Error ? buildError.stack : "N/A"));
585
- throw buildError;
586
- }
587
- else {
588
- requestBody = JSON.stringify(body);
589
- logger?.debug("Body: serialized as JSON");
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 safeHeaders = {
594
- ...headers,
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 fetch(url.toString(), {
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) if (!(adtError.exception?.type === "ExceptionResourceNoAccess" && (adtError.message.includes("currently editing") || adtError.message.includes("is being edited")))) {
626
- sessionManager.clear();
627
- logger?.warn("Session cleared due to 403 Forbidden response");
628
- } else logger?.warn("Lock conflict (403) — session preserved (object locked by another session)");
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")) if (serializableSchema && contentType.includes("xml")) data = serializableSchema.parse(rawText);
653
- else data = rawText;
654
- else data = rawText;
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 request = unwrapped?.request ?? unwrapped;
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
- services: { transports: createTransportService(adtClient) },
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