@mcp-abap-adt/connection 1.10.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +758 -0
  2. package/README.md +42 -11
  3. package/dist/__tests__/helpers/session.d.ts +15 -0
  4. package/dist/__tests__/helpers/session.d.ts.map +1 -0
  5. package/dist/__tests__/helpers/session.js +19 -0
  6. package/dist/auth/ntlm.d.ts +15 -0
  7. package/dist/auth/ntlm.d.ts.map +1 -1
  8. package/dist/auth/ntlm.js +38 -0
  9. package/dist/connection/AbstractAbapConnection.d.ts +163 -11
  10. package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
  11. package/dist/connection/AbstractAbapConnection.js +351 -14
  12. package/dist/connection/BaseAbapConnection.d.ts +5 -1
  13. package/dist/connection/BaseAbapConnection.d.ts.map +1 -1
  14. package/dist/connection/BaseAbapConnection.js +11 -1
  15. package/dist/connection/CertificateAbapConnection.d.ts +5 -1
  16. package/dist/connection/CertificateAbapConnection.d.ts.map +1 -1
  17. package/dist/connection/CertificateAbapConnection.js +11 -1
  18. package/dist/connection/JwtAbapConnection.d.ts +3 -2
  19. package/dist/connection/JwtAbapConnection.d.ts.map +1 -1
  20. package/dist/connection/JwtAbapConnection.js +19 -8
  21. package/dist/connection/KerberosAbapConnection.d.ts +5 -1
  22. package/dist/connection/KerberosAbapConnection.d.ts.map +1 -1
  23. package/dist/connection/KerberosAbapConnection.js +54 -3
  24. package/dist/connection/SamlAbapConnection.d.ts +5 -1
  25. package/dist/connection/SamlAbapConnection.d.ts.map +1 -1
  26. package/dist/connection/SamlAbapConnection.js +11 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +4 -0
  29. package/dist/session/SessionLifecycle.d.ts +131 -0
  30. package/dist/session/SessionLifecycle.d.ts.map +1 -0
  31. package/dist/session/SessionLifecycle.js +301 -0
  32. package/docs/INDEX.md +106 -0
  33. package/docs/INSTALLATION.md +304 -0
  34. package/docs/JWT_AUTH_TOOLS.md +142 -0
  35. package/docs/MIGRATION-2.0.md +125 -0
  36. package/docs/SCOPE.md +44 -0
  37. package/docs/STATEFUL_SESSION_GUIDE.md +121 -0
  38. package/docs/USAGE.md +749 -0
  39. package/examples/README.md +112 -0
  40. package/examples/basic-connection.js +55 -0
  41. package/examples/jwt-with-token-refresh.js +87 -0
  42. package/examples/saml-connection.js +52 -0
  43. package/examples/websocket-transport.js +87 -0
  44. package/package.json +11 -4
@@ -38,11 +38,24 @@ const node_crypto_1 = require("node:crypto");
38
38
  const node_https_1 = require("node:https");
39
39
  const interfaces_1 = require("@mcp-abap-adt/interfaces");
40
40
  const axios_1 = __importStar(require("axios"));
41
+ const SessionLifecycle_js_1 = require("../session/SessionLifecycle.js");
41
42
  const timeouts_js_1 = require("../utils/timeouts.js");
42
43
  const csrfConfig_js_1 = require("./csrfConfig.js");
44
+ /**
45
+ * Declares the capabilities explicitly rather than satisfying them by accident.
46
+ * `AbapConnection` is the base contract every transport honours; these two are
47
+ * the HTTP session's own, and naming them means a signature that drifts from
48
+ * the published contract fails to compile here instead of at the consumer.
49
+ */
43
50
  class AbstractAbapConnection {
44
51
  config;
45
52
  logger;
53
+ /**
54
+ * Owns session state, admission and teardown ordering. Composed rather than
55
+ * inherited: RfcAbapConnection implements the interface directly, so the two
56
+ * transports share this unit instead of a base class.
57
+ */
58
+ lifecycle = new SessionLifecycle_js_1.SessionLifecycle();
46
59
  axiosInstance = null;
47
60
  csrfToken = null;
48
61
  cookies = null;
@@ -161,7 +174,259 @@ class AbstractAbapConnection {
161
174
  getConfig() {
162
175
  return this.config;
163
176
  }
177
+ /**
178
+ * Establishes the session, once, under the lifecycle.
179
+ *
180
+ * Idempotent, and concurrent callers share one establishment: the transition
181
+ * joins the tail of its own kind. A teardown requested while establishment
182
+ * was in flight means the caller asked to stop, so usability is NOT published
183
+ * and what was established is released — otherwise a slow connect would hand
184
+ * back a session someone had already discarded.
185
+ */
186
+ async connect() {
187
+ // Captured HERE, not inside the transition: the callback runs when this
188
+ // reaches the front of the queue, by which time a teardown the caller
189
+ // requested afterwards has already bumped the epoch — and comparing it
190
+ // against itself would let the connect publish a session the caller had
191
+ // asked to stop. The baseline is "when the caller asked to connect".
192
+ const baselineEpoch = this.lifecycle.teardownEpoch;
193
+ await this.lifecycle.transition('connect', async () => {
194
+ if (this.lifecycle.connected)
195
+ return;
196
+ await this.establishAndCommit(baselineEpoch);
197
+ });
198
+ }
199
+ /**
200
+ * Tears the session down. Never throws: the report says what did not finish
201
+ * rather than failing. Sends no ADT session-close — see the design's D2.
202
+ */
203
+ async disconnect() {
204
+ this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: false });
205
+ // The report travels THROUGH the transition, so a joining caller gets the
206
+ // same one. Building it outside would leave every caller but the first
207
+ // reporting an empty teardown — the abandoned locks reported to nobody.
208
+ return this.lifecycle.transition('disconnect', async () => {
209
+ const drained = await this.lifecycle.drain();
210
+ this.clearSessionState();
211
+ this.lifecycle.markDisconnected();
212
+ return {
213
+ abandonedWindows: drained.abandonedWindows,
214
+ releasePending: false,
215
+ };
216
+ });
217
+ }
218
+ isConnected() {
219
+ return this.lifecycle.connected;
220
+ }
221
+ /**
222
+ * Fingerprint of the SAP-side session, or null when none is known.
223
+ *
224
+ * `null` is NOT a statement about the connection. Two situations produce it:
225
+ * no session exists, or the connection is live over a server that issued no
226
+ * session cookie. Use {@link isConnected} for connection state.
227
+ *
228
+ * It follows that null → non-null is not a replacement but an identity being
229
+ * learned; only a CHANGED value means the session was replaced.
230
+ */
231
+ getSessionIdentity() {
232
+ return this.lifecycle.identity;
233
+ }
234
+ /** Opens a lock window. See the design: a lock outlives its request. */
235
+ beginWindow(label) {
236
+ return this.lifecycle.beginWindow(label);
237
+ }
238
+ endWindow(token) {
239
+ this.lifecycle.endWindow(token);
240
+ }
241
+ /**
242
+ * Discards the session at a caller's request: cancels queued recoveries and
243
+ * queues the cleanup rather than tearing down under a live request.
244
+ */
164
245
  reset() {
246
+ this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: true });
247
+ void this.lifecycle.transition('cleanup', async () => {
248
+ await this.lifecycle.drain();
249
+ this.clearSessionState();
250
+ this.lifecycle.markDisconnected();
251
+ });
252
+ }
253
+ /**
254
+ * Re-establishes the session for a request that is recovering from a
255
+ * credential renewal, then lets that request retry.
256
+ *
257
+ * Runs as its own `recover` transition, which never joins another: each
258
+ * recovery carries the baseline of its own request. It yields to a caller's
259
+ * teardown — if the epoch moved since `baselineEpoch`, someone asked to stop
260
+ * while this was being prepared, and a retry must not resurrect a session
261
+ * they discarded.
262
+ *
263
+ * The transition queues behind the cleanup that the renewal itself raised, so
264
+ * it never re-establishes on top of stale transport state.
265
+ */
266
+ async recoverSession(baselineEpoch) {
267
+ await this.lifecycle.transition('recover', async () => {
268
+ await this.establishAndCommit(baselineEpoch);
269
+ });
270
+ }
271
+ /**
272
+ * Establishes a session and publishes it — but only if nobody asked to stop
273
+ * meanwhile.
274
+ *
275
+ * The epoch is checked BEFORE, so a teardown already requested costs no round
276
+ * trip, and AFTER, because establishment takes time and a caller can ask to
277
+ * stop during it. Checking only before is the defect this exists to prevent:
278
+ * markConnected() would then clear the teardown state and hand back a session
279
+ * the caller had already discarded.
280
+ *
281
+ * Shared by connect() and recoverSession() rather than written twice —
282
+ * the two drifted apart once already, and a third caller would drift again.
283
+ */
284
+ async establishAndCommit(baselineEpoch) {
285
+ if (this.lifecycle.teardownEpoch !== baselineEpoch) {
286
+ throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested for this connection');
287
+ }
288
+ try {
289
+ await this.establishSession();
290
+ }
291
+ catch (error) {
292
+ // A failed establishment leaves debris that poisons the next attempt: the
293
+ // 401 that rejected us may still have carried a Set-Cookie, and every
294
+ // subclass treats a cookie as proof that auth is already settled —
295
+ // buildAuthorizationHeader() returns '' once one exists. So the next
296
+ // connect() would go out with NO credentials at all, and be rejected for
297
+ // a reason that has nothing to do with why the first one failed.
298
+ //
299
+ // Safe to clear here, unlike the abandonment path below: establishSession()
300
+ // threw, so no session was published, and admission requires a connected
301
+ // lifecycle — nothing can be in flight over what this drops.
302
+ this.invalidateSession();
303
+ // And the identity with it. The rejecting response was still observed, so
304
+ // its cookie was recorded as a session that had just been established —
305
+ // leaving getSessionIdentity() naming a session that never existed while
306
+ // isConnected() says false. Two answers to one question is worse than
307
+ // either.
308
+ this.lifecycle.markDisconnected();
309
+ throw error;
310
+ }
311
+ if (this.lifecycle.teardownEpoch !== baselineEpoch) {
312
+ // Abandon WITHOUT clearing: the teardown that bumped the epoch is already
313
+ // queued, and it clears after draining. Clearing here would pull cookies,
314
+ // the CSRF token and the axios instance out from under a request that is
315
+ // still in flight — breaking the guarantee this whole change rests on,
316
+ // from inside the guard meant to protect it.
317
+ throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested while it was in flight');
318
+ }
319
+ // establishSession() throws on failure, so reaching here means a session
320
+ // exists. There is no third outcome: no "connected but unusable", no
321
+ // resolved promise over an empty jar.
322
+ this.lifecycle.markConnected(this.sessionFingerprint());
323
+ }
324
+ /** The teardown epoch, for a recovery to capture before it starts. */
325
+ get teardownEpoch() {
326
+ return this.lifecycle.teardownEpoch;
327
+ }
328
+ /**
329
+ * Raises a session-lost teardown from inside request handling.
330
+ *
331
+ * There are exactly three things that can cost us the ABAP session, and they
332
+ * were found one at a time precisely because they were written apart. They go
333
+ * through here so a fourth joins the list instead of inventing its own
334
+ * sequence:
335
+ *
336
+ * - the credential was renewed (the injected auth says so);
337
+ * - the server says the session is gone (a dead-session response);
338
+ * - the tracked cookie changed under us while a lock was held.
339
+ *
340
+ * `internal` origin, so it does not cancel the recovery that raised it, and
341
+ * `sessionLost`, so admission shuts at once and the identity is dropped
342
+ * immediately — a later comparison must see the change, and on a dead session
343
+ * the cookie is unchanged, so only the state can tell.
344
+ *
345
+ * Does not await: it is called from inside a request, and the queued cleanup
346
+ * drains that very request.
347
+ */
348
+ raiseSessionLost(reason) {
349
+ this.logger?.warn(`Session lost: ${reason}`);
350
+ this.lifecycle.beginTeardown({ origin: 'internal', sessionLost: true });
351
+ void this.lifecycle.transition('cleanup', async () => {
352
+ await this.lifecycle.drain();
353
+ this.clearSessionState();
354
+ this.lifecycle.markDisconnected();
355
+ });
356
+ }
357
+ /** The credential-renewal raiser; see raiseSessionLost(). */
358
+ discardSession() {
359
+ this.raiseSessionLost('the credential backing it was renewed');
360
+ }
361
+ /**
362
+ * Whether an error is this connection's own verdict about the session rather
363
+ * than something the server said about a request.
364
+ *
365
+ * A retry path that swallows one of these and rethrows the original error
366
+ * turns "your lock is dead" back into "your request 403'd", which is the very
367
+ * information the caller needs and the only one it cannot recover itself.
368
+ */
369
+ isSessionVerdict(error) {
370
+ const code = error?.code;
371
+ return (code === interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED ||
372
+ code === interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED ||
373
+ code === interfaces_1.ADT_SESSION_ERROR.RELEASE_PENDING);
374
+ }
375
+ /**
376
+ * Folds a response into the session state AND acts on what it means, in one
377
+ * step.
378
+ *
379
+ * Never call updateCookiesFromResponse() directly: it MUTATES the fingerprint,
380
+ * so discarding its classification absorbs a replacement silently and every
381
+ * later check reads `unchanged`. That is one call site forgetting, and it
382
+ * happened — on the error path and on every retry response.
383
+ */
384
+ observeResponse(headers) {
385
+ this.applyIdentityPolicy(this.updateCookiesFromResponse(headers));
386
+ }
387
+ /**
388
+ * Acts on what a response said about the session identity.
389
+ *
390
+ * A replacement is fatal only while a lock is held — and "a lock is held"
391
+ * means an open window, not `sessionMode`: a mode flag cannot represent
392
+ * windows, another handler can flip it back, and a batch never sets it.
393
+ * With no lock held the same replacement is not a loss: nothing was being
394
+ * held, so the new identity simply becomes the current one.
395
+ */
396
+ applyIdentityPolicy(classification) {
397
+ if (classification !== 'replaced')
398
+ return;
399
+ if (this.lifecycle.openWindows.length === 0) {
400
+ this.logger?.debug('Session identity changed with no lock held; continuing on the new one');
401
+ return;
402
+ }
403
+ this.raiseSessionLost('the session cookie changed while a lock was held');
404
+ throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session was replaced while a lock was held; the lock handle is dead');
405
+ }
406
+ /**
407
+ * Whether the server is telling us the session it was given no longer exists.
408
+ *
409
+ * The E19 shape was HTTP 400 with "Session not found", answered in ~60 ms
410
+ * with the cookie present — which is why identity comparison cannot see this:
411
+ * the cookie, and therefore the fingerprint, is completely unchanged. The
412
+ * exact match is landscape-specific and is one of the live probes this design
413
+ * still owes.
414
+ */
415
+ isDeadSessionResponse(error) {
416
+ if (!(error instanceof axios_1.AxiosError) || !error.response)
417
+ return false;
418
+ if (error.response.status !== 400)
419
+ return false;
420
+ const text = [
421
+ error.response.statusText,
422
+ typeof error.response.data === 'string' ? error.response.data : '',
423
+ ]
424
+ .join(' ')
425
+ .toLowerCase();
426
+ return text.includes('session not found');
427
+ }
428
+ /** Drops everything that described the session. Not a lifecycle transition. */
429
+ clearSessionState() {
165
430
  if (this.axiosInstance) {
166
431
  this.axiosInstance.interceptors.request.clear();
167
432
  this.axiosInstance.interceptors.response.clear();
@@ -172,6 +437,23 @@ class AbstractAbapConnection {
172
437
  this.cookieStore.clear();
173
438
  // Note: baseUrl is not reset as it's derived from immutable config
174
439
  }
440
+ /**
441
+ * The session-bearing cookies, and only those.
442
+ *
443
+ * `sap-XSRF_*` is excluded deliberately: it changes on a token refresh WITHIN
444
+ * the same session, so including it would report an ordinary refresh as a new
445
+ * session and fail exactly where nothing is wrong. `sap-usercontext` is ours,
446
+ * overwritten on every response.
447
+ */
448
+ sessionFingerprint() {
449
+ const fingerprint = new Map();
450
+ for (const [name, value] of this.cookieStore) {
451
+ if (name.startsWith('SAP_SESSIONID')) {
452
+ fingerprint.set(name, value);
453
+ }
454
+ }
455
+ return fingerprint;
456
+ }
175
457
  async getBaseUrl() {
176
458
  return this.baseUrl;
177
459
  }
@@ -187,6 +469,20 @@ class AbstractAbapConnection {
187
469
  return headers;
188
470
  }
189
471
  async makeAdtRequest(options) {
472
+ // Admission first, synchronously, before any await: the check and the
473
+ // count must happen in one step, or a request could be admitted and still
474
+ // be invisible to a teardown draining at that instant. Throws
475
+ // NOT_CONNECTED when the caller never connected, or when a teardown has
476
+ // shut the door.
477
+ const lease = this.lifecycle.admitRequest();
478
+ try {
479
+ return await this.performRequest(options);
480
+ }
481
+ finally {
482
+ lease.release();
483
+ }
484
+ }
485
+ async performRequest(options) {
190
486
  const { url: endpoint, method, timeout, data, params, headers: customHeaders, } = options;
191
487
  const normalizedMethod = method.toUpperCase();
192
488
  // Build full URL: baseUrl + endpoint
@@ -282,7 +578,7 @@ class AbstractAbapConnection {
282
578
  });
283
579
  try {
284
580
  const response = await this.getAxiosInstance()(requestConfig);
285
- this.updateCookiesFromResponse(response.headers);
581
+ this.observeResponse(response.headers);
286
582
  this.logger?.debug(`Request succeeded with status ${response.status}`, {
287
583
  type: 'REQUEST_SUCCESS',
288
584
  status: response.status,
@@ -305,7 +601,17 @@ class AbstractAbapConnection {
305
601
  typeof error.response.data === 'string'
306
602
  ? error.response.data.slice(0, 200)
307
603
  : JSON.stringify(error.response.data).slice(0, 200);
308
- this.updateCookiesFromResponse(error.response.headers);
604
+ this.observeResponse(error.response.headers);
605
+ }
606
+ // The server telling us the session is gone is invisible to the identity
607
+ // comparison: the cookie, and therefore the fingerprint, is unchanged.
608
+ // Only the state can see it, and it must say so at once — otherwise a
609
+ // later unlockAll() finds a match and unlocks over a dead session.
610
+ if (this.isDeadSessionResponse(error)) {
611
+ this.raiseSessionLost('the server reports the session no longer exists');
612
+ // No internal retry: a blind retry here is what produced further locks
613
+ // in the field. The caller decides.
614
+ throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session no longer exists; any lock handle from it is dead');
309
615
  }
310
616
  // Check if this is a network error (connection refused, timeout, DNS, etc.)
311
617
  // Don't retry for network errors - these indicate infrastructure/VPN issues
@@ -357,10 +663,16 @@ class AbstractAbapConnection {
357
663
  requestHeaders.Cookie = refreshedCookies;
358
664
  }
359
665
  const retryResponse = await this.getAxiosInstance()(requestConfig);
360
- this.updateCookiesFromResponse(retryResponse.headers);
666
+ this.observeResponse(retryResponse.headers);
361
667
  return retryResponse;
362
668
  }
363
669
  catch (retryError) {
670
+ // A session verdict outranks the error that started the retry: the
671
+ // caller can retry a 403 itself, but it cannot discover that its lock
672
+ // handle is dead from a 403.
673
+ if (this.isSessionVerdict(retryError)) {
674
+ throw retryError;
675
+ }
364
676
  this.logger?.debug(`CSRF retry failed; rethrowing original error: ${retryError instanceof Error
365
677
  ? retryError.message
366
678
  : String(retryError)}`);
@@ -379,7 +691,7 @@ class AbstractAbapConnection {
379
691
  this.logger?.debug(`[DEBUG] BaseAbapConnection - 401 on GET request, retrying with cookies from error response`);
380
692
  requestHeaders.Cookie = this.cookies;
381
693
  const retryResponse = await this.getAxiosInstance()(requestConfig);
382
- this.updateCookiesFromResponse(retryResponse.headers);
694
+ this.observeResponse(retryResponse.headers);
383
695
  return retryResponse;
384
696
  }
385
697
  // If no cookies, try to get them via CSRF token fetch
@@ -391,11 +703,14 @@ class AbstractAbapConnection {
391
703
  requestHeaders.Cookie = this.cookies;
392
704
  this.logger?.debug(`[DEBUG] BaseAbapConnection - Retrying GET request with cookies from CSRF fetch`);
393
705
  const retryResponse = await this.getAxiosInstance()(requestConfig);
394
- this.updateCookiesFromResponse(retryResponse.headers);
706
+ this.observeResponse(retryResponse.headers);
395
707
  return retryResponse;
396
708
  }
397
709
  }
398
710
  catch (csrfError) {
711
+ if (this.isSessionVerdict(csrfError)) {
712
+ throw csrfError;
713
+ }
399
714
  this.logger?.debug(`[DEBUG] BaseAbapConnection - Failed to get CSRF token for 401 retry: ${csrfError instanceof Error ? csrfError.message : String(csrfError)}`);
400
715
  // Fall through to throw original error
401
716
  }
@@ -434,6 +749,13 @@ class AbstractAbapConnection {
434
749
  return await this.fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay);
435
750
  }
436
751
  catch (error) {
752
+ // Third layer with a catch on this path, and the last one that could
753
+ // bury a verdict: falling through to the fallback endpoint would open
754
+ // ANOTHER session, and by then the teardown has cleared the fingerprint
755
+ // so the new one reads as `established` and the loss disappears.
756
+ if (this.isSessionVerdict(error)) {
757
+ throw error;
758
+ }
437
759
  lastError = error instanceof Error ? error : new Error(String(error));
438
760
  this.logger?.debug(`CSRF token not available from ${csrfUrl}, trying next endpoint...`);
439
761
  }
@@ -482,7 +804,7 @@ class AbstractAbapConnection {
482
804
  headers,
483
805
  timeout: (0, timeouts_js_1.getTimeout)('csrf'),
484
806
  });
485
- this.updateCookiesFromResponse(response.headers);
807
+ this.observeResponse(response.headers);
486
808
  const token = response.headers['x-csrf-token'];
487
809
  if (!token) {
488
810
  this.logger?.error('No CSRF token in response headers', {
@@ -496,7 +818,7 @@ class AbstractAbapConnection {
496
818
  throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
497
819
  }
498
820
  if (response.headers['set-cookie']) {
499
- this.updateCookiesFromResponse(response.headers);
821
+ this.observeResponse(response.headers);
500
822
  if (this.cookies) {
501
823
  this.logger?.debug(`[DEBUG] BaseAbapConnection - Cookies received from CSRF response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
502
824
  this.logger?.debug('Cookies extracted from response', {
@@ -508,11 +830,19 @@ class AbstractAbapConnection {
508
830
  return token;
509
831
  }
510
832
  catch (error) {
833
+ // A session verdict is not a failed token fetch and must not be
834
+ // retried into silence: the retry would observe the SAME new session,
835
+ // read it as `unchanged`, and the replacement this raised would be gone
836
+ // for good. It leaves immediately, past the loop and past the caller's
837
+ // recovery.
838
+ if (this.isSessionVerdict(error)) {
839
+ throw error;
840
+ }
511
841
  if (error instanceof axios_1.AxiosError) {
512
842
  // Always try to extract cookies from error response, even on 401
513
843
  // This ensures cookies are available for subsequent requests
514
844
  if (error.response?.headers) {
515
- this.updateCookiesFromResponse(error.response.headers);
845
+ this.observeResponse(error.response.headers);
516
846
  if (this.cookies) {
517
847
  this.logger?.debug('Cookies extracted from error response', {
518
848
  status: error.response.status,
@@ -531,14 +861,14 @@ class AbstractAbapConnection {
531
861
  this.logger?.debug('CSRF: SAP returned 405 (Method Not Allowed) — not critical, token found in header');
532
862
  const token = error.response.headers['x-csrf-token'];
533
863
  if (token) {
534
- this.updateCookiesFromResponse(error.response.headers);
864
+ this.observeResponse(error.response.headers);
535
865
  return token;
536
866
  }
537
867
  }
538
868
  if (error.response?.headers['x-csrf-token']) {
539
869
  this.logger?.debug(`Got CSRF token despite error (status: ${error.response?.status})`);
540
870
  const token = error.response.headers['x-csrf-token'];
541
- this.updateCookiesFromResponse(error.response.headers);
871
+ this.observeResponse(error.response.headers);
542
872
  return token;
543
873
  }
544
874
  if (error.response) {
@@ -597,13 +927,19 @@ class AbstractAbapConnection {
597
927
  setInitialCookies(cookies) {
598
928
  this.cookies = cookies;
599
929
  }
930
+ /**
931
+ * Folds a response's cookies into the jar and classifies what that means for
932
+ * the session identity. Returns the classification rather than acting on it:
933
+ * cookie parsing stays free of policy, and no exception fires in the middle
934
+ * of a state update. The caller decides.
935
+ */
600
936
  updateCookiesFromResponse(headers) {
601
937
  if (!headers) {
602
- return;
938
+ return 'unchanged';
603
939
  }
604
940
  const setCookie = headers['set-cookie'];
605
941
  if (!setCookie) {
606
- return;
942
+ return 'unchanged';
607
943
  }
608
944
  const cookiesArray = Array.isArray(setCookie) ? setCookie : [setCookie];
609
945
  for (const entry of cookiesArray) {
@@ -633,16 +969,17 @@ class AbstractAbapConnection {
633
969
  this.cookieStore.set('sap-usercontext', `sap-client=${this.config.client}`);
634
970
  }
635
971
  if (this.cookieStore.size === 0) {
636
- return;
972
+ return 'unchanged';
637
973
  }
638
974
  const combined = Array.from(this.cookieStore.entries())
639
975
  .map(([name, value]) => (value ? `${name}=${value}` : name))
640
976
  .join('; ');
641
977
  if (!combined) {
642
- return;
978
+ return 'unchanged';
643
979
  }
644
980
  this.cookies = combined;
645
981
  this.logger?.debug(`[DEBUG] BaseAbapConnection - Updated cookies from response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
982
+ return this.lifecycle.observe(this.sessionFingerprint());
646
983
  }
647
984
  /**
648
985
  * Subclasses override to inject extra https.Agent options (e.g. mTLS cert/key/pfx).
@@ -12,7 +12,11 @@ export declare class BaseAbapConnection extends AbstractAbapConnection {
12
12
  * Connect to SAP system with Basic Auth
13
13
  * Fetches CSRF token which also establishes session cookies
14
14
  */
15
- connect(): Promise<void>;
15
+ /**
16
+ * Establishes the session for this auth type. Called by
17
+ * AbstractAbapConnection.connect(), which owns the lifecycle around it.
18
+ */
19
+ protected establishSession(): Promise<void>;
16
20
  protected buildAuthorizationHeader(): string;
17
21
  private static validateConfig;
18
22
  }
@@ -1 +1 @@
1
- {"version":3,"file":"BaseAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/BaseAbapConnection.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,sBAAsB;gBAE1D,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAA;KAAE;IAMzC;;;OAGG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAsC9B,SAAS,CAAC,wBAAwB,IAAI,MAAM;IAU5C,OAAO,CAAC,MAAM,CAAC,cAAc;CAmB9B"}
1
+ {"version":3,"file":"BaseAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/BaseAbapConnection.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,sBAAsB;gBAE1D,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAA;KAAE;IAMzC;;;OAGG;IACH;;;OAGG;cACa,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IA6CjD,SAAS,CAAC,wBAAwB,IAAI,MAAM;IAU5C,OAAO,CAAC,MAAM,CAAC,cAAc;CAmB9B"}
@@ -15,7 +15,11 @@ class BaseAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnect
15
15
  * Connect to SAP system with Basic Auth
16
16
  * Fetches CSRF token which also establishes session cookies
17
17
  */
18
- async connect() {
18
+ /**
19
+ * Establishes the session for this auth type. Called by
20
+ * AbstractAbapConnection.connect(), which owns the lifecycle around it.
21
+ */
22
+ async establishSession() {
19
23
  const baseUrl = await this.getBaseUrl();
20
24
  const discoveryUrl = `${baseUrl}/sap/bc/adt/discovery`;
21
25
  this.logger?.debug(`[DEBUG] BaseAbapConnection - Connecting to SAP system: ${discoveryUrl}`);
@@ -41,6 +45,12 @@ class BaseAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnect
41
45
  this.logger?.debug(`[DEBUG] BaseAbapConnection - Cookies extracted from error response during connect (first 100 chars): ${this.getCookies()?.substring(0, 100)}...`);
42
46
  }
43
47
  }
48
+ // Rethrow: a resolved connect() must mean a usable session exists. This
49
+ // used to swallow and resolve anyway, deferring establishment to the
50
+ // first request — coherent only while that lazy path existed. Without it,
51
+ // swallowing would leave a connection that reports success, holds
52
+ // nothing, and refuses every request.
53
+ throw error;
44
54
  }
45
55
  }
46
56
  buildAuthorizationHeader() {
@@ -14,7 +14,11 @@ export declare class CertificateAbapConnection extends AbstractAbapConnection {
14
14
  * Loads certificate material and primes the session. MUST be called before the first
15
15
  * request — the TLS agent is built lazily and needs the client cert present.
16
16
  */
17
- connect(): Promise<void>;
17
+ /**
18
+ * Establishes the session for this auth type. Called by
19
+ * AbstractAbapConnection.connect(), which owns the lifecycle around it.
20
+ */
21
+ protected establishSession(): Promise<void>;
18
22
  protected getHttpsAgentOptions(): AgentOptions;
19
23
  protected buildAuthorizationHeader(): string;
20
24
  }
@@ -1 +1 @@
1
- {"version":3,"file":"CertificateAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/CertificateAbapConnection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAEV,0BAA0B,EAC3B,MAAM,0BAA0B,CAAC;AAGlC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE,mFAAmF;AACnF,qBAAa,yBAA0B,SAAQ,sBAAsB;IACnE,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,QAAQ,CAAqC;gBAGnD,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,MAAM,CAAC,EAAE,0BAA0B;IAOrC,OAAO,CAAC,MAAM,CAAC,cAAc;cAyBb,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAM/C;;;OAGG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAsB9B,SAAS,CAAC,oBAAoB,IAAI,YAAY;IAU9C,SAAS,CAAC,wBAAwB,IAAI,MAAM;CAG7C"}
1
+ {"version":3,"file":"CertificateAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/CertificateAbapConnection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAEV,0BAA0B,EAC3B,MAAM,0BAA0B,CAAC;AAGlC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE,mFAAmF;AACnF,qBAAa,yBAA0B,SAAQ,sBAAsB;IACnE,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,QAAQ,CAAqC;gBAGnD,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,MAAM,CAAC,EAAE,0BAA0B;IAOrC,OAAO,CAAC,MAAM,CAAC,cAAc;cAyBb,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAM/C;;;OAGG;IACH;;;OAGG;cACa,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IA6BjD,SAAS,CAAC,oBAAoB,IAAI,YAAY;IAU9C,SAAS,CAAC,wBAAwB,IAAI,MAAM;CAG7C"}
@@ -38,7 +38,11 @@ class CertificateAbapConnection extends AbstractAbapConnection_js_1.AbstractAbap
38
38
  * Loads certificate material and primes the session. MUST be called before the first
39
39
  * request — the TLS agent is built lazily and needs the client cert present.
40
40
  */
41
- async connect() {
41
+ /**
42
+ * Establishes the session for this auth type. Called by
43
+ * AbstractAbapConnection.connect(), which owns the lifecycle around it.
44
+ */
45
+ async establishSession() {
42
46
  await this.ensureMaterial();
43
47
  const baseUrl = await this.getBaseUrl();
44
48
  const discoveryUrl = `${baseUrl}/sap/bc/adt/discovery`;
@@ -53,6 +57,12 @@ class CertificateAbapConnection extends AbstractAbapConnection_js_1.AbstractAbap
53
57
  this.logger?.debug(`[DEBUG] CertificateAbapConnection - Cookies extracted from error response during connect (first 100 chars): ${this.getCookies()?.substring(0, 100)}...`);
54
58
  }
55
59
  }
60
+ // Rethrow: a resolved connect() must mean a usable session exists. This
61
+ // used to swallow and resolve anyway, deferring establishment to the
62
+ // first request — coherent only while that lazy path existed. Without it,
63
+ // swallowing would leave a connection that reports success, holds
64
+ // nothing, and refuses every request.
65
+ throw error;
56
66
  }
57
67
  }
58
68
  getHttpsAgentOptions() {
@@ -21,9 +21,10 @@ export declare class JwtAbapConnection extends AbstractAbapConnection {
21
21
  */
22
22
  private tryRefreshToken;
23
23
  /**
24
- * Override connect to handle JWT token refresh on errors
24
+ * Establishes the session for this auth type. Called by
25
+ * AbstractAbapConnection.connect(), which owns the lifecycle around it.
25
26
  */
26
- connect(): Promise<void>;
27
+ protected establishSession(): Promise<void>;
27
28
  /**
28
29
  * Override makeAdtRequest to handle JWT auth errors with automatic token refresh
29
30
  */
@@ -1 +1 @@
1
- {"version":3,"file":"JwtAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/JwtAbapConnection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAE9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE;;;;;;GAMG;AACH,qBAAa,iBAAkB,SAAQ,sBAAsB;IAC3D,OAAO,CAAC,cAAc,CAAC,CAAkB;IACzC,OAAO,CAAC,YAAY,CAAS;gBAG3B,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,cAAc,CAAC,EAAE,eAAe;IAWlC,SAAS,CAAC,wBAAwB,IAAI,MAAM;IAW5C;;;OAGG;YACW,eAAe;IA0B7B;;OAEG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAyD9B;;OAEG;IACG,cAAc,CAAC,CAAC,GAAG,GAAG,EAAE,CAAC,GAAG,GAAG,EACnC,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAyD9B;;OAEG;cACa,cAAc,CAC5B,GAAG,EAAE,MAAM,EACX,UAAU,SAAI,EACd,UAAU,SAAO,GAChB,OAAO,CAAC,MAAM,CAAC;IA2ClB,OAAO,CAAC,MAAM,CAAC,cAAc;CAa9B"}
1
+ {"version":3,"file":"JwtAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/JwtAbapConnection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAE9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE;;;;;;GAMG;AACH,qBAAa,iBAAkB,SAAQ,sBAAsB;IAC3D,OAAO,CAAC,cAAc,CAAC,CAAkB;IACzC,OAAO,CAAC,YAAY,CAAS;gBAG3B,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,cAAc,CAAC,EAAE,eAAe;IAWlC,SAAS,CAAC,wBAAwB,IAAI,MAAM;IAW5C;;;OAGG;YACW,eAAe;IA0B7B;;;OAGG;cACa,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IA2DjD;;OAEG;IACG,cAAc,CAAC,CAAC,GAAG,GAAG,EAAE,CAAC,GAAG,GAAG,EACnC,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAiE9B;;OAEG;cACa,cAAc,CAC5B,GAAG,EAAE,MAAM,EACX,UAAU,SAAI,EACd,UAAU,SAAO,GAChB,OAAO,CAAC,MAAM,CAAC;IA2ClB,OAAO,CAAC,MAAM,CAAC,cAAc;CAa9B"}