@mcp-abap-adt/connection 1.10.2 → 3.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 +827 -0
  2. package/README.md +44 -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 +180 -12
  10. package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
  11. package/dist/connection/AbstractAbapConnection.js +398 -19
  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 +50 -70
  30. package/dist/session/SessionLifecycle.d.ts.map +1 -1
  31. package/dist/session/SessionLifecycle.js +59 -158
  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 +114 -0
  36. package/docs/SCOPE.md +44 -0
  37. package/docs/STATEFUL_SESSION_GUIDE.md +122 -0
  38. package/docs/USAGE.md +745 -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,278 @@ 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, and always settles.
201
+ *
202
+ * It waits for NOTHING. Deciding when to disconnect is the caller's, and so is
203
+ * preparing for it — finishing chains, releasing locks. Waiting here on a
204
+ * request whose caller chose no timeout is what made a teardown unbounded, and
205
+ * an unbounded teardown blocks every later transition on the serialized tail.
206
+ *
207
+ * Requests already in flight run to completion untouched. Generation fencing
208
+ * (see `SessionLifecycle.isCurrent`) keeps their results from reaching this
209
+ * connection afterwards.
210
+ *
211
+ * Sends no ADT session-close — see the design's D2.
212
+ */
213
+ async disconnect() {
214
+ // Synchronous, at the call: admission shuts and the generation moves before
215
+ // anything is queued, so a caller who has asked to disconnect cannot have
216
+ // requests still going through while this waits its turn.
217
+ this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: false });
218
+ await this.lifecycle.transition('disconnect', async () => {
219
+ this.clearSessionState();
220
+ this.lifecycle.markDisconnected();
221
+ });
222
+ }
223
+ isConnected() {
224
+ return this.lifecycle.connected;
225
+ }
226
+ /**
227
+ * Fingerprint of the SAP-side session, or null when none is known.
228
+ *
229
+ * `null` is NOT a statement about the connection. Two situations produce it:
230
+ * no session exists, or the connection is live over a server that issued no
231
+ * session cookie. Use {@link isConnected} for connection state.
232
+ *
233
+ * It follows that null → non-null is not a replacement but an identity being
234
+ * learned; only a CHANGED value means the session was replaced.
235
+ */
236
+ getSessionIdentity() {
237
+ return this.lifecycle.identity;
238
+ }
239
+ /**
240
+ * Discards the session at a caller's request: cancels queued recoveries and
241
+ * queues the cleanup rather than tearing down under a live request.
242
+ */
164
243
  reset() {
244
+ this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: true });
245
+ void this.lifecycle.transition('cleanup', async () => {
246
+ this.clearSessionState();
247
+ this.lifecycle.markDisconnected();
248
+ });
249
+ }
250
+ /**
251
+ * Re-establishes the session for a request that is recovering from a
252
+ * credential renewal, then lets that request retry.
253
+ *
254
+ * Runs as its own `recover` transition, which never joins another: each
255
+ * recovery carries the baseline of its own request. It yields to a caller's
256
+ * teardown — if the epoch moved since `baselineEpoch`, someone asked to stop
257
+ * while this was being prepared, and a retry must not resurrect a session
258
+ * they discarded.
259
+ *
260
+ * The transition queues behind the cleanup that the renewal itself raised, so
261
+ * it never re-establishes on top of stale transport state.
262
+ */
263
+ async recoverSession(baselineEpoch) {
264
+ await this.lifecycle.transition('recover', async () => {
265
+ await this.establishAndCommit(baselineEpoch);
266
+ });
267
+ }
268
+ /**
269
+ * Establishes a session and publishes it — but only if nobody asked to stop
270
+ * meanwhile.
271
+ *
272
+ * The epoch is checked BEFORE, so a teardown already requested costs no round
273
+ * trip, and AFTER, because establishment takes time and a caller can ask to
274
+ * stop during it. Checking only before is the defect this exists to prevent:
275
+ * markConnected() would then clear the teardown state and hand back a session
276
+ * the caller had already discarded.
277
+ *
278
+ * Shared by connect() and recoverSession() rather than written twice —
279
+ * the two drifted apart once already, and a third caller would drift again.
280
+ */
281
+ async establishAndCommit(baselineEpoch) {
282
+ if (this.lifecycle.teardownEpoch !== baselineEpoch) {
283
+ throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested for this connection');
284
+ }
285
+ // Whatever session arrives from here is one we are deliberately
286
+ // establishing, so it must not read as a replacement: the identity policy
287
+ // treats a changed fingerprint as fatal, and it cannot tell our own
288
+ // re-establishment from a session taken out from under us. Forgetting first
289
+ // makes the new fingerprint `established`, which is what it is.
290
+ this.lifecycle.forgetIdentity();
291
+ try {
292
+ await this.establishSession();
293
+ }
294
+ catch (error) {
295
+ // A failed establishment leaves debris that poisons the next attempt: the
296
+ // 401 that rejected us may still have carried a Set-Cookie, and every
297
+ // subclass treats a cookie as proof that auth is already settled —
298
+ // buildAuthorizationHeader() returns '' once one exists. So the next
299
+ // connect() would go out with NO credentials at all, and be rejected for
300
+ // a reason that has nothing to do with why the first one failed.
301
+ //
302
+ // Safe to clear here, unlike the abandonment path below: establishSession()
303
+ // threw, so no session was published, and admission requires a connected
304
+ // lifecycle — nothing can be in flight over what this drops.
305
+ this.invalidateSession();
306
+ // And the identity with it. The rejecting response was still observed, so
307
+ // its cookie was recorded as a session that had just been established —
308
+ // leaving getSessionIdentity() naming a session that never existed while
309
+ // isConnected() says false. Two answers to one question is worse than
310
+ // either.
311
+ this.lifecycle.markDisconnected();
312
+ throw error;
313
+ }
314
+ if (this.lifecycle.teardownEpoch !== baselineEpoch) {
315
+ // Abandon WITHOUT clearing: the teardown that bumped the epoch is already
316
+ // queued, and it clears after draining. Clearing here would pull cookies,
317
+ // the CSRF token and the axios instance out from under a request that is
318
+ // still in flight — breaking the guarantee this whole change rests on,
319
+ // from inside the guard meant to protect it.
320
+ throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested while it was in flight');
321
+ }
322
+ // establishSession() throws on failure, so reaching here means a session
323
+ // exists. There is no third outcome: no "connected but unusable", no
324
+ // resolved promise over an empty jar.
325
+ this.lifecycle.markConnected(this.sessionFingerprint());
326
+ }
327
+ /** The teardown epoch, for a recovery to capture before it starts. */
328
+ get teardownEpoch() {
329
+ return this.lifecycle.teardownEpoch;
330
+ }
331
+ /**
332
+ * Raises a session-lost teardown from inside request handling.
333
+ *
334
+ * There are exactly three things that can cost us the ABAP session, and they
335
+ * were found one at a time precisely because they were written apart. They go
336
+ * through here so a fourth joins the list instead of inventing its own
337
+ * sequence:
338
+ *
339
+ * - the credential was renewed (the injected auth says so);
340
+ * - the server says the session is gone (a dead-session response);
341
+ * - the tracked cookie changed under us while a lock was held.
342
+ *
343
+ * `internal` origin, so it does not cancel the recovery that raised it, and
344
+ * `sessionLost`, so admission shuts at once and the identity is dropped
345
+ * immediately — a later comparison must see the change, and on a dead session
346
+ * the cookie is unchanged, so only the state can tell.
347
+ *
348
+ * Does not await: it is called from inside a request, and the cleanup must not
349
+ * wait for the very request that raised it.
350
+ */
351
+ raiseSessionLost(reason) {
352
+ this.logger?.warn(`Session lost: ${reason}`);
353
+ this.lifecycle.beginTeardown({ origin: 'internal', sessionLost: true });
354
+ void this.lifecycle.transition('cleanup', async () => {
355
+ this.clearSessionState();
356
+ this.lifecycle.markDisconnected();
357
+ });
358
+ }
359
+ /** The credential-renewal raiser; see raiseSessionLost(). */
360
+ discardSession() {
361
+ this.raiseSessionLost('the credential backing it was renewed');
362
+ }
363
+ /**
364
+ * Whether an error is this connection's own verdict about the session rather
365
+ * than something the server said about a request.
366
+ *
367
+ * A retry path that swallows one of these and rethrows the original error
368
+ * turns "your lock is dead" back into "your request 403'd", which is the very
369
+ * information the caller needs and the only one it cannot recover itself.
370
+ */
371
+ isSessionVerdict(error) {
372
+ const code = error?.code;
373
+ return (code === interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED ||
374
+ code === interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED ||
375
+ code === interfaces_1.ADT_SESSION_ERROR.RELEASE_PENDING);
376
+ }
377
+ /**
378
+ * Folds a response into the session state AND acts on what it means, in one
379
+ * step.
380
+ *
381
+ * Never call updateCookiesFromResponse() directly: it MUTATES the fingerprint,
382
+ * so discarding its classification absorbs a replacement silently and every
383
+ * later check reads `unchanged`. That is one call site forgetting, and it
384
+ * happened — on the error path and on every retry response.
385
+ */
386
+ observeResponse(headers, generation) {
387
+ // Fenced by SESSION GENERATION, not by the teardown epoch. Only a
388
+ // caller-initiated teardown moves the epoch — a recovery deliberately does
389
+ // not — so after a session loss and a successful recovery, a request from
390
+ // the dead session carries the same epoch as the new one and would sail
391
+ // straight through. The generation moves whenever the current session does.
392
+ //
393
+ // `undefined` means "not issued against a session": the CSRF fetch during
394
+ // connect() has no lease and must apply, since it is establishing the very
395
+ // session this would compare against.
396
+ if (generation !== undefined &&
397
+ generation !== this.lifecycle.sessionGeneration) {
398
+ this.logger?.debug('Ignoring a response from a previous session: its effects are fenced');
399
+ return;
400
+ }
401
+ this.applyIdentityPolicy(this.updateCookiesFromResponse(headers));
402
+ }
403
+ /**
404
+ * Acts on what a response said about the session identity.
405
+ *
406
+ * A replacement is always fatal, and that is a narrowing: an earlier version
407
+ * tolerated it "when no lock is held", deciding from the connection's own
408
+ * lock windows. Those are gone, and rightly — this layer does not know that a
409
+ * lock exists, what object it covers or what would release it. Locks are
410
+ * tracked a layer up, per object, by the code that took them.
411
+ *
412
+ * So the rule is written from what this layer CAN know: the ABAP session we
413
+ * were speaking to is not the one we are speaking to now. Anything the caller
414
+ * held against the old one is dead, and continuing quietly would hand them a
415
+ * session they never opened — the failure this whole design exists to prevent.
416
+ * Being wrong in this direction costs a reconnect; being wrong the other way
417
+ * costs a lock nobody can find.
418
+ */
419
+ applyIdentityPolicy(classification) {
420
+ if (classification !== 'replaced')
421
+ return;
422
+ this.raiseSessionLost('the session cookie changed under us');
423
+ throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session was replaced; anything held against the previous one is dead');
424
+ }
425
+ /**
426
+ * Whether the server is telling us the session it was given no longer exists.
427
+ *
428
+ * The E19 shape was HTTP 400 with "Session not found", answered in ~60 ms
429
+ * with the cookie present — which is why identity comparison cannot see this:
430
+ * the cookie, and therefore the fingerprint, is completely unchanged. The
431
+ * exact match is landscape-specific and is one of the live probes this design
432
+ * still owes.
433
+ */
434
+ isDeadSessionResponse(error) {
435
+ if (!(error instanceof axios_1.AxiosError) || !error.response)
436
+ return false;
437
+ if (error.response.status !== 400)
438
+ return false;
439
+ const text = [
440
+ error.response.statusText,
441
+ typeof error.response.data === 'string' ? error.response.data : '',
442
+ ]
443
+ .join(' ')
444
+ .toLowerCase();
445
+ return text.includes('session not found');
446
+ }
447
+ /** Drops everything that described the session. Not a lifecycle transition. */
448
+ clearSessionState() {
165
449
  if (this.axiosInstance) {
166
450
  this.axiosInstance.interceptors.request.clear();
167
451
  this.axiosInstance.interceptors.response.clear();
@@ -172,6 +456,23 @@ class AbstractAbapConnection {
172
456
  this.cookieStore.clear();
173
457
  // Note: baseUrl is not reset as it's derived from immutable config
174
458
  }
459
+ /**
460
+ * The session-bearing cookies, and only those.
461
+ *
462
+ * `sap-XSRF_*` is excluded deliberately: it changes on a token refresh WITHIN
463
+ * the same session, so including it would report an ordinary refresh as a new
464
+ * session and fail exactly where nothing is wrong. `sap-usercontext` is ours,
465
+ * overwritten on every response.
466
+ */
467
+ sessionFingerprint() {
468
+ const fingerprint = new Map();
469
+ for (const [name, value] of this.cookieStore) {
470
+ if (name.startsWith('SAP_SESSIONID')) {
471
+ fingerprint.set(name, value);
472
+ }
473
+ }
474
+ return fingerprint;
475
+ }
175
476
  async getBaseUrl() {
176
477
  return this.baseUrl;
177
478
  }
@@ -187,6 +488,20 @@ class AbstractAbapConnection {
187
488
  return headers;
188
489
  }
189
490
  async makeAdtRequest(options) {
491
+ // Admission first, synchronously, before any await: the check and the
492
+ // count must happen in one step, or a request could be admitted and still
493
+ // be invisible to a teardown draining at that instant. Throws
494
+ // NOT_CONNECTED when the caller never connected, or when a teardown has
495
+ // shut the door.
496
+ const lease = this.lifecycle.admitRequest();
497
+ try {
498
+ return await this.performRequest(options, lease);
499
+ }
500
+ finally {
501
+ lease.release();
502
+ }
503
+ }
504
+ async performRequest(options, lease) {
190
505
  const { url: endpoint, method, timeout, data, params, headers: customHeaders, } = options;
191
506
  const normalizedMethod = method.toUpperCase();
192
507
  // Build full URL: baseUrl + endpoint
@@ -282,7 +597,7 @@ class AbstractAbapConnection {
282
597
  });
283
598
  try {
284
599
  const response = await this.getAxiosInstance()(requestConfig);
285
- this.updateCookiesFromResponse(response.headers);
600
+ this.observeResponse(response.headers, lease.generation);
286
601
  this.logger?.debug(`Request succeeded with status ${response.status}`, {
287
602
  type: 'REQUEST_SUCCESS',
288
603
  status: response.status,
@@ -292,6 +607,20 @@ class AbstractAbapConnection {
292
607
  return response;
293
608
  }
294
609
  catch (error) {
610
+ // FENCE FIRST, before anything reads or writes shared state.
611
+ //
612
+ // Fencing observeResponse() alone was not enough, and the gap was wide:
613
+ // everything below acts on this connection, not on the request. A late
614
+ // 400 "session not found" would call raiseSessionLost() and tear down the
615
+ // healthy session established since; a late 401/403 would call
616
+ // invalidateSession(), write a fresh CSRF token, and RETRY — replaying a
617
+ // mutation from a dead session inside the live one.
618
+ //
619
+ // A stale request gets its error back and nothing else happens.
620
+ if (!this.lifecycle.isCurrent(lease)) {
621
+ this.logger?.debug('A request from a previous session failed; its recovery is fenced');
622
+ throw error;
623
+ }
295
624
  const errorDetails = {
296
625
  type: 'REQUEST_ERROR',
297
626
  message: error instanceof Error ? error.message : String(error),
@@ -305,7 +634,17 @@ class AbstractAbapConnection {
305
634
  typeof error.response.data === 'string'
306
635
  ? error.response.data.slice(0, 200)
307
636
  : JSON.stringify(error.response.data).slice(0, 200);
308
- this.updateCookiesFromResponse(error.response.headers);
637
+ this.observeResponse(error.response.headers, lease.generation);
638
+ }
639
+ // The server telling us the session is gone is invisible to the identity
640
+ // comparison: the cookie, and therefore the fingerprint, is unchanged.
641
+ // Only the state can see it, and it must say so at once — otherwise a
642
+ // later unlockAll() finds a match and unlocks over a dead session.
643
+ if (this.isDeadSessionResponse(error)) {
644
+ this.raiseSessionLost('the server reports the session no longer exists');
645
+ // No internal retry: a blind retry here is what produced further locks
646
+ // in the field. The caller decides.
647
+ 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
648
  }
310
649
  // Check if this is a network error (connection refused, timeout, DNS, etc.)
311
650
  // Don't retry for network errors - these indicate infrastructure/VPN issues
@@ -347,7 +686,7 @@ class AbstractAbapConnection {
347
686
  delete requestHeaders.cookie;
348
687
  }
349
688
  try {
350
- this.setCsrfToken(await this.fetchCsrfToken(requestUrl, 5, 2000));
689
+ this.setCsrfToken(await this.fetchCsrfToken(requestUrl, 5, 2000, lease.generation));
351
690
  const refreshedToken = this.getCsrfToken();
352
691
  if (refreshedToken) {
353
692
  requestHeaders['x-csrf-token'] = refreshedToken;
@@ -357,10 +696,16 @@ class AbstractAbapConnection {
357
696
  requestHeaders.Cookie = refreshedCookies;
358
697
  }
359
698
  const retryResponse = await this.getAxiosInstance()(requestConfig);
360
- this.updateCookiesFromResponse(retryResponse.headers);
699
+ this.observeResponse(retryResponse.headers, lease.generation);
361
700
  return retryResponse;
362
701
  }
363
702
  catch (retryError) {
703
+ // A session verdict outranks the error that started the retry: the
704
+ // caller can retry a 403 itself, but it cannot discover that its lock
705
+ // handle is dead from a 403.
706
+ if (this.isSessionVerdict(retryError)) {
707
+ throw retryError;
708
+ }
364
709
  this.logger?.debug(`CSRF retry failed; rethrowing original error: ${retryError instanceof Error
365
710
  ? retryError.message
366
711
  : String(retryError)}`);
@@ -379,23 +724,26 @@ class AbstractAbapConnection {
379
724
  this.logger?.debug(`[DEBUG] BaseAbapConnection - 401 on GET request, retrying with cookies from error response`);
380
725
  requestHeaders.Cookie = this.cookies;
381
726
  const retryResponse = await this.getAxiosInstance()(requestConfig);
382
- this.updateCookiesFromResponse(retryResponse.headers);
727
+ this.observeResponse(retryResponse.headers, lease.generation);
383
728
  return retryResponse;
384
729
  }
385
730
  // If no cookies, try to get them via CSRF token fetch
386
731
  this.logger?.debug(`[DEBUG] BaseAbapConnection - 401 on GET request, attempting to get cookies via CSRF token fetch`);
387
732
  try {
388
733
  // Try to get CSRF token (this will also get cookies)
389
- this.csrfToken = await this.fetchCsrfToken(requestUrl, 3, 1000);
734
+ this.csrfToken = await this.fetchCsrfToken(requestUrl, 3, 1000, lease.generation);
390
735
  if (this.cookies) {
391
736
  requestHeaders.Cookie = this.cookies;
392
737
  this.logger?.debug(`[DEBUG] BaseAbapConnection - Retrying GET request with cookies from CSRF fetch`);
393
738
  const retryResponse = await this.getAxiosInstance()(requestConfig);
394
- this.updateCookiesFromResponse(retryResponse.headers);
739
+ this.observeResponse(retryResponse.headers, lease.generation);
395
740
  return retryResponse;
396
741
  }
397
742
  }
398
743
  catch (csrfError) {
744
+ if (this.isSessionVerdict(csrfError)) {
745
+ throw csrfError;
746
+ }
399
747
  this.logger?.debug(`[DEBUG] BaseAbapConnection - Failed to get CSRF token for 401 retry: ${csrfError instanceof Error ? csrfError.message : String(csrfError)}`);
400
748
  // Fall through to throw original error
401
749
  }
@@ -407,7 +755,9 @@ class AbstractAbapConnection {
407
755
  * Fetch CSRF token from SAP system
408
756
  * Protected method for use by concrete implementations in their connect() method
409
757
  */
410
- async fetchCsrfToken(url, retryCount = csrfConfig_js_1.CSRF_CONFIG.RETRY_COUNT, retryDelay = csrfConfig_js_1.CSRF_CONFIG.RETRY_DELAY) {
758
+ async fetchCsrfToken(url, retryCount = csrfConfig_js_1.CSRF_CONFIG.RETRY_COUNT, retryDelay = csrfConfig_js_1.CSRF_CONFIG.RETRY_DELAY,
759
+ /** Fences the response effects; omitted during connect(), which has no lease. */
760
+ generation) {
411
761
  // Try primary endpoint first, then fallback for older systems
412
762
  const baseUrl = url.includes('/sap/bc/adt/')
413
763
  ? url.split('/sap/bc/adt')[0]
@@ -431,9 +781,16 @@ class AbstractAbapConnection {
431
781
  let lastError;
432
782
  for (const csrfUrl of endpoints) {
433
783
  try {
434
- return await this.fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay);
784
+ return await this.fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay, generation);
435
785
  }
436
786
  catch (error) {
787
+ // Third layer with a catch on this path, and the last one that could
788
+ // bury a verdict: falling through to the fallback endpoint would open
789
+ // ANOTHER session, and by then the teardown has cleared the fingerprint
790
+ // so the new one reads as `established` and the loss disappears.
791
+ if (this.isSessionVerdict(error)) {
792
+ throw error;
793
+ }
437
794
  lastError = error instanceof Error ? error : new Error(String(error));
438
795
  this.logger?.debug(`CSRF token not available from ${csrfUrl}, trying next endpoint...`);
439
796
  }
@@ -444,7 +801,7 @@ class AbstractAbapConnection {
444
801
  /**
445
802
  * Fetch CSRF token from a specific endpoint with retries
446
803
  */
447
- async fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay) {
804
+ async fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay, generation) {
448
805
  this.logger?.debug(`Fetching CSRF token from: ${csrfUrl}`);
449
806
  for (let attempt = 0; attempt <= retryCount; attempt++) {
450
807
  try {
@@ -482,7 +839,7 @@ class AbstractAbapConnection {
482
839
  headers,
483
840
  timeout: (0, timeouts_js_1.getTimeout)('csrf'),
484
841
  });
485
- this.updateCookiesFromResponse(response.headers);
842
+ this.observeResponse(response.headers, generation);
486
843
  const token = response.headers['x-csrf-token'];
487
844
  if (!token) {
488
845
  this.logger?.error('No CSRF token in response headers', {
@@ -496,7 +853,7 @@ class AbstractAbapConnection {
496
853
  throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
497
854
  }
498
855
  if (response.headers['set-cookie']) {
499
- this.updateCookiesFromResponse(response.headers);
856
+ this.observeResponse(response.headers, generation);
500
857
  if (this.cookies) {
501
858
  this.logger?.debug(`[DEBUG] BaseAbapConnection - Cookies received from CSRF response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
502
859
  this.logger?.debug('Cookies extracted from response', {
@@ -508,11 +865,19 @@ class AbstractAbapConnection {
508
865
  return token;
509
866
  }
510
867
  catch (error) {
868
+ // A session verdict is not a failed token fetch and must not be
869
+ // retried into silence: the retry would observe the SAME new session,
870
+ // read it as `unchanged`, and the replacement this raised would be gone
871
+ // for good. It leaves immediately, past the loop and past the caller's
872
+ // recovery.
873
+ if (this.isSessionVerdict(error)) {
874
+ throw error;
875
+ }
511
876
  if (error instanceof axios_1.AxiosError) {
512
877
  // Always try to extract cookies from error response, even on 401
513
878
  // This ensures cookies are available for subsequent requests
514
879
  if (error.response?.headers) {
515
- this.updateCookiesFromResponse(error.response.headers);
880
+ this.observeResponse(error.response.headers, generation);
516
881
  if (this.cookies) {
517
882
  this.logger?.debug('Cookies extracted from error response', {
518
883
  status: error.response.status,
@@ -531,14 +896,14 @@ class AbstractAbapConnection {
531
896
  this.logger?.debug('CSRF: SAP returned 405 (Method Not Allowed) — not critical, token found in header');
532
897
  const token = error.response.headers['x-csrf-token'];
533
898
  if (token) {
534
- this.updateCookiesFromResponse(error.response.headers);
899
+ this.observeResponse(error.response.headers, generation);
535
900
  return token;
536
901
  }
537
902
  }
538
903
  if (error.response?.headers['x-csrf-token']) {
539
904
  this.logger?.debug(`Got CSRF token despite error (status: ${error.response?.status})`);
540
905
  const token = error.response.headers['x-csrf-token'];
541
- this.updateCookiesFromResponse(error.response.headers);
906
+ this.observeResponse(error.response.headers, generation);
542
907
  return token;
543
908
  }
544
909
  if (error.response) {
@@ -597,13 +962,19 @@ class AbstractAbapConnection {
597
962
  setInitialCookies(cookies) {
598
963
  this.cookies = cookies;
599
964
  }
965
+ /**
966
+ * Folds a response's cookies into the jar and classifies what that means for
967
+ * the session identity. Returns the classification rather than acting on it:
968
+ * cookie parsing stays free of policy, and no exception fires in the middle
969
+ * of a state update. The caller decides.
970
+ */
600
971
  updateCookiesFromResponse(headers) {
601
972
  if (!headers) {
602
- return;
973
+ return 'unchanged';
603
974
  }
604
975
  const setCookie = headers['set-cookie'];
605
976
  if (!setCookie) {
606
- return;
977
+ return 'unchanged';
607
978
  }
608
979
  const cookiesArray = Array.isArray(setCookie) ? setCookie : [setCookie];
609
980
  for (const entry of cookiesArray) {
@@ -633,16 +1004,17 @@ class AbstractAbapConnection {
633
1004
  this.cookieStore.set('sap-usercontext', `sap-client=${this.config.client}`);
634
1005
  }
635
1006
  if (this.cookieStore.size === 0) {
636
- return;
1007
+ return 'unchanged';
637
1008
  }
638
1009
  const combined = Array.from(this.cookieStore.entries())
639
1010
  .map(([name, value]) => (value ? `${name}=${value}` : name))
640
1011
  .join('; ');
641
1012
  if (!combined) {
642
- return;
1013
+ return 'unchanged';
643
1014
  }
644
1015
  this.cookies = combined;
645
1016
  this.logger?.debug(`[DEBUG] BaseAbapConnection - Updated cookies from response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
1017
+ return this.lifecycle.observe(this.sessionFingerprint());
646
1018
  }
647
1019
  /**
648
1020
  * Subclasses override to inject extra https.Agent options (e.g. mTLS cert/key/pfx).
@@ -700,6 +1072,13 @@ class AbstractAbapConnection {
700
1072
  this.setCsrfToken(null);
701
1073
  this.cookies = null;
702
1074
  this.cookieStore.clear();
1075
+ // And the tracked identity, because WE discarded the session. Without this
1076
+ // the cookie that arrives next reads as a foreign replacement — and since a
1077
+ // replacement is now always fatal, our own deliberate re-authentication
1078
+ // would tear the connection down. The distinction that matters is not
1079
+ // "was a lock held" but "did we cause this": what we discarded on purpose
1080
+ // is not a session taken from under us.
1081
+ this.lifecycle.forgetIdentity();
703
1082
  }
704
1083
  shouldRetryCsrf(error) {
705
1084
  if (!(error instanceof axios_1.AxiosError)) {
@@ -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"}