@mcp-abap-adt/connection 4.0.0 → 5.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 (69) hide show
  1. package/CHANGELOG.md +274 -1
  2. package/README.md +60 -30
  3. package/dist/auth/IAuthProvider.d.ts +84 -0
  4. package/dist/auth/IAuthProvider.d.ts.map +1 -0
  5. package/dist/auth/IAuthProvider.js +21 -0
  6. package/dist/auth/providers.d.ts +95 -0
  7. package/dist/auth/providers.d.ts.map +1 -0
  8. package/dist/auth/providers.js +140 -0
  9. package/dist/connection/AbstractAbapConnection.d.ts +222 -17
  10. package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
  11. package/dist/connection/AbstractAbapConnection.js +555 -31
  12. package/dist/connection/AdtCloudConnector.d.ts +29 -0
  13. package/dist/connection/AdtCloudConnector.d.ts.map +1 -0
  14. package/dist/connection/AdtCloudConnector.js +31 -0
  15. package/dist/connection/AdtOnPremConnector.d.ts +30 -0
  16. package/dist/connection/AdtOnPremConnector.d.ts.map +1 -0
  17. package/dist/connection/AdtOnPremConnector.js +32 -0
  18. package/dist/connection/BaseAbapConnection.d.ts +7 -1
  19. package/dist/connection/BaseAbapConnection.d.ts.map +1 -1
  20. package/dist/connection/BaseAbapConnection.js +7 -1
  21. package/dist/connection/CertificateAbapConnection.d.ts +11 -1
  22. package/dist/connection/CertificateAbapConnection.d.ts.map +1 -1
  23. package/dist/connection/CertificateAbapConnection.js +13 -1
  24. package/dist/connection/CredentialAbapConnection.d.ts +88 -0
  25. package/dist/connection/CredentialAbapConnection.d.ts.map +1 -0
  26. package/dist/connection/CredentialAbapConnection.js +195 -0
  27. package/dist/connection/JwtAbapConnection.d.ts +23 -7
  28. package/dist/connection/JwtAbapConnection.d.ts.map +1 -1
  29. package/dist/connection/JwtAbapConnection.js +26 -8
  30. package/dist/connection/KerberosAbapConnection.d.ts +9 -1
  31. package/dist/connection/KerberosAbapConnection.d.ts.map +1 -1
  32. package/dist/connection/KerberosAbapConnection.js +9 -1
  33. package/dist/connection/RfcAbapConnection.d.ts +0 -5
  34. package/dist/connection/RfcAbapConnection.d.ts.map +1 -1
  35. package/dist/connection/RfcAbapConnection.js +0 -7
  36. package/dist/connection/SamlAbapConnection.d.ts +7 -1
  37. package/dist/connection/SamlAbapConnection.d.ts.map +1 -1
  38. package/dist/connection/SamlAbapConnection.js +7 -1
  39. package/dist/connection/connectionFactory.d.ts +16 -0
  40. package/dist/connection/connectionFactory.d.ts.map +1 -1
  41. package/dist/connection/connectionFactory.js +52 -0
  42. package/dist/index.d.ts +4 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +10 -1
  45. package/dist/session/CloudSecuritySessionStrategy.d.ts +32 -0
  46. package/dist/session/CloudSecuritySessionStrategy.d.ts.map +1 -0
  47. package/dist/session/CloudSecuritySessionStrategy.js +135 -0
  48. package/dist/session/IcfSessionStrategy.d.ts +27 -0
  49. package/dist/session/IcfSessionStrategy.d.ts.map +1 -0
  50. package/dist/session/IcfSessionStrategy.js +62 -0
  51. package/dist/session/SessionLifecycle.d.ts +17 -2
  52. package/dist/session/SessionLifecycle.d.ts.map +1 -1
  53. package/dist/session/SessionLifecycle.js +17 -2
  54. package/dist/session/SessionStrategy.d.ts +86 -0
  55. package/dist/session/SessionStrategy.d.ts.map +1 -0
  56. package/dist/session/SessionStrategy.js +33 -0
  57. package/dist/utils/cookies.d.ts +12 -0
  58. package/dist/utils/cookies.d.ts.map +1 -0
  59. package/dist/utils/cookies.js +24 -0
  60. package/dist/utils/timeouts.d.ts +16 -0
  61. package/dist/utils/timeouts.d.ts.map +1 -1
  62. package/dist/utils/timeouts.js +19 -0
  63. package/docs/INSTALLATION.md +6 -2
  64. package/docs/MIGRATION-2.0.md +1 -1
  65. package/docs/MIGRATION-5.0.md +116 -0
  66. package/docs/STATEFUL_SESSION_GUIDE.md +77 -11
  67. package/docs/USAGE.md +92 -22
  68. package/docs/superpowers/specs/2026-08-21-platform-connectors.md +108 -0
  69. package/package.json +1 -1
@@ -38,14 +38,50 @@ 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 IcfSessionStrategy_js_1 = require("../session/IcfSessionStrategy.js");
41
42
  const SessionLifecycle_js_1 = require("../session/SessionLifecycle.js");
43
+ const cookies_js_1 = require("../utils/cookies.js");
42
44
  const timeouts_js_1 = require("../utils/timeouts.js");
43
45
  const csrfConfig_js_1 = require("./csrfConfig.js");
46
+ /**
47
+ * The configured default release deadline, refused at construction if it is not
48
+ * a number. `parseInt` alone would not do: it answers `NaN` for `"abc"` and `5`
49
+ * for `"5s"`, and both used to travel all the way to a teardown — the first as
50
+ * a throw from every `disconnect()` in the process, the second as a silently
51
+ * wrong bound nobody asked for.
52
+ */
53
+ function readReleaseDeadline() {
54
+ const raw = process.env.SAP_RELEASE_DEADLINE_MS;
55
+ if (raw === undefined || raw.trim() === '') {
56
+ return (0, timeouts_js_1.getReleaseDeadline)();
57
+ }
58
+ const value = Number(raw);
59
+ if (!Number.isFinite(value) || value < 0) {
60
+ throw new TypeError(`SAP_RELEASE_DEADLINE_MS must be a finite, non-negative number of milliseconds, got ${JSON.stringify(raw)}`);
61
+ }
62
+ return value;
63
+ }
44
64
  /**
45
65
  * Declares the capabilities explicitly rather than satisfying them by accident.
46
66
  * `AbapConnection` is the base contract every transport honours; these two are
47
67
  * the HTTP session's own, and naming them means a signature that drifts from
48
68
  * the published contract fails to compile here instead of at the consumer.
69
+ *
70
+ * **This gives the consumer instruments; it does not decide for it.** `connect()`
71
+ * opens one session and `disconnect()` closes it. How many connections to hold,
72
+ * how long to hold them and when to let go stays with the caller — there are no
73
+ * thresholds here, no pooling and no eviction, because none of that is knowable
74
+ * from inside a single connection. A session this one did not open is not its
75
+ * business: the session limit is per user and the pool is shared, so a SAP GUI
76
+ * logon of the same user sits in the same list.
77
+ *
78
+ * **Nothing the server decides is treated as something to count on.** Whether it
79
+ * issues a session cookie, whether it still holds a session it issued, how many
80
+ * it will tolerate, how fast it answers a logoff — all of that is its own and
81
+ * may differ by system and release. So each is observed and reported, never
82
+ * relied upon: the logoff is best effort under a bound the caller sets, a
83
+ * missing session cookie is a warning rather than a rule, and no code here
84
+ * counts sessions or predicts the next answer from the last one.
49
85
  */
50
86
  class AbstractAbapConnection {
51
87
  config;
@@ -76,9 +112,63 @@ class AbstractAbapConnection {
76
112
  inCriticalSection = false;
77
113
  /** Reference count for nested beginCriticalSection()/endCriticalSection() pairs. */
78
114
  criticalSectionDepth = 0;
115
+ /** The default release deadline, validated once at construction. */
116
+ releaseDeadlineMs;
117
+ /**
118
+ * The logoff for the session this connection last held, while it is on its
119
+ * way. One, because a connection holds one session: `connect()` opens it and
120
+ * `disconnect()` closes it, and how many connections to run is the caller's
121
+ * business, not something to be tracked here.
122
+ *
123
+ * Carries the session id, not just the promise, so a release still in flight
124
+ * for an EARLIER session is recognised as not being this one's — reusing it
125
+ * was what left the second session of a reconnect never released at all.
126
+ *
127
+ * The id is the `SAP_SESSIONID` value — the ABAP session, the one locks are
128
+ * bound to — never the cookie header: that header also carries `sap-XSRF_*`,
129
+ * which rotates within one and the same session, so comparing headers made a
130
+ * session stop recognising itself after a token refresh.
131
+ */
132
+ /**
133
+ * How this server opens and gives back a session, decided by asking it rather
134
+ * than by guessing which system it is. Set at establishment; until then the
135
+ * on-prem mechanism, which is the one that needs no resource.
136
+ */
137
+ /**
138
+ * The application server this session lives on, as the server named it.
139
+ *
140
+ * A session belongs to ONE application server. On a multi-node system a
141
+ * request that lands on another gets another session — and a lock held on the
142
+ * first is then dead through nobody's fault and no inactivity. Eclipse pins
143
+ * itself with these headers; without them every request is a fresh throw of
144
+ * the dice.
145
+ *
146
+ * Learned from `sap-adt-saplb` on a response, sent back as `saplb`. Cleared
147
+ * with the rest of the session state: it names a server for a session that no
148
+ * longer exists.
149
+ */
150
+ appServer = null;
151
+ /**
152
+ * Whether the preflight opened a session of its own.
153
+ *
154
+ * Distinct from "there are cookies": a failed establishment often leaves a
155
+ * cookie from the 401 that rejected it, and that is debris, not a session.
156
+ * Only a preflight answered with a session address opened one, and only that
157
+ * is worth saying goodbye to when establishment then fails.
158
+ */
159
+ preflightOpenedSession = false;
160
+ sessionStrategy = new IcfSessionStrategy_js_1.IcfSessionStrategy(null);
161
+ pendingRelease = null;
79
162
  constructor(config, logger, sessionId, options) {
80
163
  this.config = config;
81
164
  this.logger = logger;
165
+ // Read and checked HERE, once, because the only other place it could be
166
+ // checked is disconnect() — and disconnect() belongs in a `finally`, where
167
+ // throwing replaces the error that sent the caller there. A misconfigured
168
+ // environment is a startup fault: it is the same on every call, it is not
169
+ // the caller's argument, and it is worth refusing a connection over rather
170
+ // than discovering at teardown.
171
+ this.releaseDeadlineMs = readReleaseDeadline();
82
172
  this.skipSessionType = options?.skipSessionType ?? false;
83
173
  // Generate sessionId (used for sap-adt-connection-id header)
84
174
  this.sessionId = sessionId || (0, node_crypto_1.randomUUID)();
@@ -174,6 +264,27 @@ class AbstractAbapConnection {
174
264
  getConfig() {
175
265
  return this.config;
176
266
  }
267
+ /**
268
+ * Gets the credential ready before anything is sent.
269
+ *
270
+ * A no-op for the auth types whose credential is already in hand — basic
271
+ * builds a header from the configuration, JWT carries a token it was given.
272
+ * It exists for the ones that have to fetch or load theirs, because the
273
+ * preflight now runs BEFORE `establishSession()` and needs a credential to
274
+ * go out with: a certificate connection reads its material there, and
275
+ * without this the preflight throws `certificate material not loaded` while
276
+ * assembling the transport — before a single request is made, on every
277
+ * system, cloud or on-prem.
278
+ *
279
+ * Must be idempotent: `establishSession()` may prepare the same credential
280
+ * again, and does.
281
+ *
282
+ * Kerberos deliberately does NOT implement it. Minting the SPNEGO token this
283
+ * early changes when the exchange happens, and that connection is not
284
+ * production-tested — its preflight fails the way it already did, is caught
285
+ * inside the strategy, and the connection falls back to ICF as before.
286
+ */
287
+ async prepareCredential() { }
177
288
  /**
178
289
  * Establishes the session, once, under the lifecycle.
179
290
  *
@@ -199,26 +310,194 @@ class AbstractAbapConnection {
199
310
  /**
200
311
  * Tears the session down. Never throws, and always settles.
201
312
  *
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.
313
+ * Tells the server the session is no longer needed, then clears the local
314
+ * state. **When the server actually reclaims it is the server's business** —
315
+ * possibly not until the next connect asks it for one — and nothing here
316
+ * waits for that or depends on it. What matters is that the session stops
317
+ * being counted against the user, which dropping the cookie alone does not
318
+ * achieve: the server keeps it until its own timeout, so a process that
319
+ * connects repeatedly leaves one behind every time. Measured on S/4HANA on-prem, 25 connects in a row: with the logoff,
320
+ * 24-25 of them were given a session; without it, 2. A connection that gets
321
+ * no session still answers `200` to a LOCK and hands back a handle the next
322
+ * request cannot use, so the leak surfaces as a half-written object rather
323
+ * than as anything about sessions.
206
324
  *
207
- * Requests already in flight run to completion untouched. Generation fencing
208
- * (see `SessionLifecycle.isCurrent`) keeps their results from reaching this
209
- * connection afterwards.
325
+ * **The logoff is the only thing waited for**, under `deadlineMs`, and
326
+ * deciding that bound is the caller's — see the parameter. Nothing else is
327
+ * waited for: finishing chains and releasing locks stay the caller's to do
328
+ * before calling, and waiting here on a request whose caller chose no timeout
329
+ * is what made a teardown unbounded, which blocks every later transition on
330
+ * the serialized tail.
210
331
  *
211
- * Sends no ADT session-close — see the design's D2.
332
+ * **Requests already in flight are not waited for, and the logoff ends the
333
+ * session they are running on** — so they will start failing against a
334
+ * session that no longer exists. That is the caller having asked to
335
+ * disconnect, not a race, and it is a change from the version that only
336
+ * dropped the cookie. Generation fencing (see `SessionLifecycle.isCurrent`)
337
+ * keeps their results from reaching this connection either way.
338
+ *
339
+ * @param options.deadlineMs How long to spend telling the server, measured
340
+ * from this call and including time spent queued behind another transition.
341
+ * **Defaults to `SAP_RELEASE_DEADLINE_MS`, which is `0` — do not wait.**
342
+ * Waiting is for steps whose successor needs the server to have caught up;
343
+ * a teardown has none. The logoff is still sent at `0`, because saying so
344
+ * is not conditional on caring when it lands; its outcome is logged when it
345
+ * arrives rather than awaited. Pass a positive value to bound a wait you
346
+ * have chosen to take. Anything that is not a finite, non-negative number
347
+ * is reported and the default used instead — this method is called from a
348
+ * `finally`, where throwing would replace the error that sent the caller
349
+ * there. The configured default is checked once, at construction, so a
350
+ * misconfigured `SAP_RELEASE_DEADLINE_MS` fails before a connection exists
351
+ * rather than at every teardown.
212
352
  */
213
- async disconnect() {
353
+ async disconnect(options) {
354
+ // Nothing here throws, including on a bad argument. This method's place is
355
+ // a `finally` — a connection that was connected must be disconnected — and
356
+ // an exception raised there replaces the error that sent the caller into it.
357
+ // A nonsense deadline is reported and the configured default used instead,
358
+ // because refusing to release the session is a worse answer to a bad number
359
+ // than releasing it on the default schedule.
360
+ const requested = options?.deadlineMs;
361
+ const valid = requested === undefined || (Number.isFinite(requested) && requested >= 0);
362
+ if (!valid) {
363
+ this.logger?.warn(`disconnect(): ignoring deadlineMs=${requested}, which is not a finite, non-negative number; using ${this.releaseDeadlineMs}`);
364
+ }
365
+ const deadlineMs = valid && requested !== undefined ? requested : this.releaseDeadlineMs;
366
+ // Started HERE, because the contract measures the deadline from the call
367
+ // and the transition below may sit in a queue first. A budget that started
368
+ // when the callback ran would give a queued teardown its full allowance
369
+ // again, which is the one thing the caller was bounding.
370
+ const startedAt = Date.now();
214
371
  // Synchronous, at the call: admission shuts and the generation moves before
215
372
  // anything is queued, so a caller who has asked to disconnect cannot have
216
373
  // requests still going through while this waits its turn.
217
374
  this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: false });
375
+ // Captured before the transition and before anything is cleared: a
376
+ // concurrent disconnect JOINS the transition and its callback is never run
377
+ // for the joiner, so a joiner would otherwise learn nothing about what it
378
+ // asked to release.
379
+ const session = this.getSessionIdentity();
218
380
  await this.lifecycle.transition('disconnect', async () => {
381
+ await this.releaseServerSession();
219
382
  this.clearSessionState();
220
383
  this.lifecycle.markDisconnected();
221
384
  });
385
+ // Its own release, and only that. Never another session's: an earlier one
386
+ // may never answer — the logoff carries no request timeout by design — and
387
+ // waiting on it would spend this caller's whole budget on a request nobody
388
+ // can finish.
389
+ const mine = session && this.pendingRelease?.id === session
390
+ ? this.pendingRelease.inFlight
391
+ : null;
392
+ await this.awaitReleaseWithin(Math.max(0, deadlineMs - (Date.now() - startedAt)), mine);
393
+ }
394
+ /**
395
+ * Tells the server this session is no longer needed. Best effort, and the
396
+ * answer is not depended on: whether and when the server frees it is its own
397
+ * affair, and this connection does not check afterwards.
398
+ *
399
+ * ICF rather than ADT because ADT publishes no such endpoint: its discovery
400
+ * document lists none on any reachable system — on-prem, cloud, or legacy —
401
+ * and the ADT logon is the discovery call itself. `/sap/public/bc/icf/logoff`
402
+ * is the platform's own, and answers `200` while expiring the session cookie.
403
+ *
404
+ * **One session, this connection's own.** A repeat `connect()` opens a NEW
405
+ * one, with a new `SAP_SESSIONID`, so an earlier session is not this
406
+ * connection's business any more: its logoff is already on the wire, or the
407
+ * system will time it out. Nothing here retries, counts, or keeps a list —
408
+ * how many connections to run and how carefully stays with the caller.
409
+ *
410
+ * **It ends the session, not this object's use of it.** The cookies are the
411
+ * only thing tying anyone to a session, so a second connection given the same
412
+ * cookies works in the same ABAP session and can use the locks taken in it —
413
+ * and this logoff closes that session for all of them at once. Whoever hands
414
+ * the cookies around owns that decision; this method cannot see the copies.
415
+ *
416
+ * Never throws. `disconnect()` must always settle, and a session we could not
417
+ * close is better than a teardown that does not finish; the local state is
418
+ * cleared either way.
419
+ */
420
+ async releaseServerSession() {
421
+ const id = this.getSessionIdentity();
422
+ const cookies = this.cookies;
423
+ if (!id || !cookies) {
424
+ // No session, or no cookie to prove it is ours. Holding the cookie is the
425
+ // whole permission to close it.
426
+ return;
427
+ }
428
+ // No critical-section guard here, deliberately. `beginTeardown()` above has
429
+ // already shut admission, so the unlock of a chain in flight is refused
430
+ // whether or not the server is told — skipping the goodbye does not rescue
431
+ // the chain, it only leaves the session open. And the depth is decremented
432
+ // by `endCriticalSection()` alone, so one caller forgetting its `finally`
433
+ // would silence every release on this connection for good: a caller's bug
434
+ // turned into the leak this whole change exists to stop. Not disconnecting
435
+ // mid-chain is the caller's to get right.
436
+ // Already on its way for THIS session: a second would tell the server the
437
+ // same thing twice.
438
+ if (this.pendingRelease?.id === id) {
439
+ return;
440
+ }
441
+ // Which mechanism this system publishes was settled at establishment: a
442
+ // session resource to DELETE on ABAP Cloud, the platform's ICF logoff on
443
+ // on-prem. Both say the same thing — we have finished with this session —
444
+ // and neither is asked what the server then did about it.
445
+ // Assembling it can throw before anything is sent — `sessionTransport()`
446
+ // builds the client, and a certificate connection whose material is not
447
+ // loaded throws there. `disconnect()` promises never to throw, and it is
448
+ // called from a `finally`, where an exception would replace the error that
449
+ // sent the caller into it.
450
+ let send;
451
+ try {
452
+ send = this.sessionStrategy.closeSession(this.sessionTransport());
453
+ }
454
+ catch (error) {
455
+ this.logger?.debug(`Could not tell the server the session is finished: ${error instanceof Error ? error.message : String(error)}`);
456
+ return;
457
+ }
458
+ // Reported when it lands, waited for or not: "nobody is waiting on it" is
459
+ // not "nobody wants to know". A rejection handler even though `closeSession`
460
+ // is meant never to throw: that is an invariant of another unit, and an
461
+ // unhandled rejection here would crash a process over a teardown the caller
462
+ // declined to wait for.
463
+ const settled = send.then(() => {
464
+ if (this.pendingRelease?.id === id) {
465
+ this.pendingRelease = null;
466
+ }
467
+ }, (error) => {
468
+ if (this.pendingRelease?.id === id) {
469
+ this.pendingRelease = null;
470
+ }
471
+ this.logger?.debug(`Could not tell the server the session is finished: ${error instanceof Error ? error.message : String(error)}`);
472
+ });
473
+ this.pendingRelease = { id, inFlight: settled };
474
+ }
475
+ /**
476
+ * Waits up to `budgetMs` for a release already on its way, then detaches.
477
+ *
478
+ * Detaching, not cancelling: when the budget runs out this stops waiting and
479
+ * the request carries on to the server. Each caller of `disconnect()` gets
480
+ * its own, so one caller's patience is never charged to another's.
481
+ */
482
+ async awaitReleaseWithin(budgetMs, release) {
483
+ if (!release || budgetMs === 0) {
484
+ return;
485
+ }
486
+ // Detaching, not cancelling: when the budget runs out this stops waiting and
487
+ // the request carries on to the server. `unref` so a process that is
488
+ // otherwise done does not stay alive for the timer, and cleared when the
489
+ // release wins the race.
490
+ let expire;
491
+ const deadline = new Promise((resolve) => {
492
+ expire = setTimeout(resolve, budgetMs);
493
+ expire.unref?.();
494
+ });
495
+ // `release` never rejects — its handlers are attached at dispatch — so this
496
+ // needs no catch to keep "never throws" true.
497
+ await Promise.race([release, deadline]);
498
+ if (expire) {
499
+ clearTimeout(expire);
500
+ }
222
501
  }
223
502
  isConnected() {
224
503
  return this.lifecycle.connected;
@@ -231,22 +510,11 @@ class AbstractAbapConnection {
231
510
  * session cookie. Use {@link isConnected} for connection state.
232
511
  *
233
512
  * 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.
513
+ * learned; only a CHANGED value means the session we had is gone.
235
514
  */
236
515
  getSessionIdentity() {
237
516
  return this.lifecycle.identity;
238
517
  }
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
- */
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
518
  /**
251
519
  * Re-establishes the session for a request that is recovering from a
252
520
  * credential renewal, then lets that request retry.
@@ -278,6 +546,105 @@ class AbstractAbapConnection {
278
546
  * Shared by connect() and recoverSession() rather than written twice —
279
547
  * the two drifted apart once already, and a third caller would drift again.
280
548
  */
549
+ /**
550
+ * What a session strategy is given: enough to make one request and to prove
551
+ * the session is ours, and nothing else. It cannot reach session state, so a
552
+ * strategy can neither mark this connection connected nor tear it down.
553
+ */
554
+ sessionTransport() {
555
+ // A SNAPSHOT, not a view. The close is dispatched without being awaited —
556
+ // a teardown does not wait for it — so `clearSessionState()` runs while the
557
+ // strategy is still suspended on its first `await`. Reading the connection
558
+ // then would find the axios instance already dropped and the cookies gone,
559
+ // and the request would go out through a freshly built client with no
560
+ // session on it, or not at all. Taken here, while they are still true.
561
+ const instance = this.getAxiosInstance();
562
+ const cookies = this.cookies;
563
+ const csrfToken = this.csrfToken;
564
+ return {
565
+ baseUrl: this.baseUrl,
566
+ // The affinity headers ride along with auth: the open must be answered by
567
+ // the server that will hold the session, and the close must reach the one
568
+ // that holds it.
569
+ authHeaders: async () => ({
570
+ ...(await this.getAuthHeaders()),
571
+ ...this.affinityHeaders(),
572
+ }),
573
+ cookies: () => cookies,
574
+ csrfToken: () => csrfToken,
575
+ send: async (request) => {
576
+ const response = await instance({
577
+ method: request.method,
578
+ url: request.url,
579
+ headers: request.headers,
580
+ ...(request.timeoutMs !== undefined
581
+ ? { timeout: request.timeoutMs }
582
+ : {}),
583
+ // A 404 is an answer — "this system has no session resource" — and a
584
+ // 403 on a close is the server declining a message. Both belong to
585
+ // the strategy to read, not to axios to throw over.
586
+ validateStatus: () => true,
587
+ });
588
+ // Only the open: its cookies ARE the session — SAP_SESSIONID arrives
589
+ // there — and they go through the same path every other response uses.
590
+ // No generation, because like the establishing CSRF fetch this is what
591
+ // creates the session a generation would be compared against. A close
592
+ // is deliberately not observed: its cookies would read as the session
593
+ // having been replaced under a connection that is being torn down.
594
+ if (request.adoptCookies) {
595
+ // Cookies adopted, verdict NOT asked for. The identity policy answers
596
+ // "did the server move us to a different session while we were
597
+ // working" — and this request is us opening one, with the
598
+ // establishing call still to come. Two requests we make ourselves,
599
+ // back to back, are one establishment; policing between them would
600
+ // read our own second call as somebody replacing our first.
601
+ const headers = response.headers;
602
+ // The open is the first answer that can name the application server,
603
+ // and every request after it should already be pinned there.
604
+ this.rememberAppServer(headers);
605
+ this.updateCookiesFromResponse(headers);
606
+ }
607
+ return {
608
+ status: response.status,
609
+ headers: response.headers,
610
+ data: response.data,
611
+ };
612
+ },
613
+ };
614
+ }
615
+ /**
616
+ * Ask the server to open a session before the establishing call needs one.
617
+ *
618
+ * The strategy is chosen by what the server publishes, not by which system we
619
+ * believe it to be: ABAP Cloud offers a session resource and issues
620
+ * `SAP_SESSIONID` to whoever asks for one — with `x-sap-security-session:
621
+ * create` — while on-prem has no such resource and its session arrives with
622
+ * the establishing request. Believing cloud simply "issues no SAP_SESSIONID"
623
+ * is what happens when nobody asks.
624
+ */
625
+ /**
626
+ * Which session management this system uses — decided by the connection, not
627
+ * discovered by asking.
628
+ *
629
+ * On-prem and cloud do not manage sessions the same way, and the two
630
+ * implementations exist for that reason. On-prem the session arrives with the
631
+ * establishing request and the platform's ICF logoff gives it back, exactly as
632
+ * it always has. Cloud opens a session resource and takes it back by DELETE on
633
+ * the address the server published.
634
+ *
635
+ * Probing was tried and is wrong: `/sap/bc/adt/core/http/sessions` answers on
636
+ * on-prem too — measured on S/4HANA, which publishes both the session resource
637
+ * and the ICF logoff in the same document — so a probe does not tell the two
638
+ * systems apart. It only tells whether an endpoint exists, and both have it.
639
+ */
640
+ createSessionStrategy() {
641
+ return new IcfSessionStrategy_js_1.IcfSessionStrategy(this.logger);
642
+ }
643
+ async openServerSession() {
644
+ this.sessionStrategy = this.createSessionStrategy();
645
+ this.preflightOpenedSession = await this.sessionStrategy.openSession(this.sessionTransport());
646
+ this.logger?.debug(`Session strategy: ${this.sessionStrategy.kind}`);
647
+ }
281
648
  async establishAndCommit(baselineEpoch) {
282
649
  if (this.lifecycle.teardownEpoch !== baselineEpoch) {
283
650
  throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested for this connection');
@@ -289,6 +656,21 @@ class AbstractAbapConnection {
289
656
  // makes the new fingerprint `established`, which is what it is.
290
657
  this.lifecycle.forgetIdentity();
291
658
  try {
659
+ // First, because the preflight below has to be able to authenticate: the
660
+ // credential of a certificate or Kerberos connection is not in hand until
661
+ // it is loaded or minted, and assembling a request without it throws.
662
+ await this.prepareCredential();
663
+ // Before the establishing call, because on a system that has one this is
664
+ // what creates the session the rest of the connection runs in — and the
665
+ // cookies it sets are the ones the establishing call must carry.
666
+ await this.openServerSession();
667
+ // Forgotten again, because the open was OURS. Establishment is now two
668
+ // requests where it used to be one, and the identity policy answers "did
669
+ // the server move us to a different session while we were working" — a
670
+ // question that has no meaning between two calls we make ourselves to set
671
+ // this session up. The identity that counts is taken at the end, from the
672
+ // cookies we finish with.
673
+ this.lifecycle.forgetIdentity();
292
674
  await this.establishSession();
293
675
  }
294
676
  catch (error) {
@@ -302,6 +684,24 @@ class AbstractAbapConnection {
302
684
  // Safe to clear here, unlike the abandonment path below: establishSession()
303
685
  // threw, so no session was published, and admission requires a connected
304
686
  // lifecycle — nothing can be in flight over what this drops.
687
+ //
688
+ // But say goodbye FIRST. The preflight may already have opened a session
689
+ // — on cloud it does, and the SAP_SESSIONID is in the cookies about to be
690
+ // dropped — and establishing can still fail after it, on a credential the
691
+ // preflight never used. Clearing without telling the server would leak
692
+ // exactly the session this release exists to stop leaking, and leave it
693
+ // unreachable: the connection is not connected, so disconnect() sends
694
+ // nothing, and the cookie that was the only permission to close it is
695
+ // gone. The transport is a snapshot, so this survives the clearing that
696
+ // follows it.
697
+ // Only when the preflight opened one. A cookie left by the 401 that
698
+ // rejected us is debris, not a session, and telling the server we are
699
+ // finished with something we never had sends a request nobody asked for
700
+ // — into the middle of an authentication exchange, in the case that found
701
+ // this.
702
+ const goodbye = this.preflightOpenedSession
703
+ ? this.sessionStrategy.closeSession(this.sessionTransport())
704
+ : Promise.resolve();
305
705
  this.invalidateSession();
306
706
  // And the identity with it. The rejecting response was still observed, so
307
707
  // its cookie was recorded as a session that had just been established —
@@ -309,6 +709,10 @@ class AbstractAbapConnection {
309
709
  // isConnected() says false. Two answers to one question is worse than
310
710
  // either.
311
711
  this.lifecycle.markDisconnected();
712
+ // Not awaited, for the same reason a teardown does not wait: the caller
713
+ // is owed the establishment error now, not after a round trip nobody is
714
+ // waiting on. closeSession never throws, so nothing here can go unhandled.
715
+ void goodbye;
312
716
  throw error;
313
717
  }
314
718
  if (this.lifecycle.teardownEpoch !== baselineEpoch) {
@@ -319,15 +723,79 @@ class AbstractAbapConnection {
319
723
  // from inside the guard meant to protect it.
320
724
  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
725
  }
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());
726
+ // The session IS the SAP_SESSIONID the server issued; our own session id is
727
+ // a conversation label we generate and says nothing about what exists on
728
+ // the other side. An empty fingerprint therefore means the server opened no
729
+ // session — on-prem it answers with `sap-XSRF_*` instead once enough
730
+ // sessions are already open for the user — and such a connection still gets
731
+ // `200` for a LOCK and hands back a handle the next request cannot use.
732
+ //
733
+ // SAP_SESSIONID names the ABAP session — the one locks are bound to. Its
734
+ // absence is therefore not a transport problem: the HTTP side is fine, the
735
+ // cookies are here, and stateless requests will work. What is missing is any
736
+ // ABAP session known to this connection, so there is nothing a lock could be
737
+ // bound to and every lock taken over it is dead the moment it is issued.
738
+ //
739
+ // Checked rather than assumed: a connection that got no cookie was held open
740
+ // against an on-prem system and the session list showed nothing for it,
741
+ // while one that got a cookie appeared there.
742
+ //
743
+ // Which is why this refuses to connect rather than warning. There is no
744
+ // count to plan around — the same system allowed 21 sessions one day and
745
+ // refused an eleventh the next — so a caller cannot avoid the condition by
746
+ // being frugal, and the only reliable signal is whether THIS connect got a
747
+ // session. Reported with its cause; recovering is the caller's call, and
748
+ // nothing here retries on anyone's behalf.
749
+ //
750
+ // Reported, not decided on. A session that was not opened is a condition on
751
+ // the server, and what to do about it — wait, retry, release sessions this
752
+ // user still holds, carry on read-only over a fresh connection — depends on
753
+ // things only the caller knows. So it is raised where the caller can catch
754
+ // it, with enough in the message to act on, and nothing is retried here.
755
+ //
756
+ // Every transport, not only basic: splitting by authentication type would
757
+ // encode a guess about cloud ABAP, whose ADT endpoint would not answer the
758
+ // bearer obtainable here, so the question stayed open. If a cloud system
759
+ // turns out to hold sessions without issuing this cookie, this is the rule
760
+ // to revisit — and it will say so loudly rather than fail quietly.
761
+ const fingerprint = this.sessionFingerprint();
762
+ if (fingerprint.size === 0 && !this.skipSessionType) {
763
+ // Goodbye first, for the same reason the catch above does it: the
764
+ // preflight may have opened a session — on cloud it does — and this path
765
+ // is about to drop the cookies that are the only permission to close it.
766
+ // Refusing to connect must not leak the session the refusal is about.
767
+ if (this.preflightOpenedSession) {
768
+ try {
769
+ void this.sessionStrategy.closeSession(this.sessionTransport());
770
+ }
771
+ catch (error) {
772
+ this.logger?.debug(`Could not tell the server the session is finished: ${error instanceof Error ? error.message : String(error)}`);
773
+ }
774
+ }
775
+ this.invalidateSession();
776
+ this.lifecycle.forgetIdentity();
777
+ this.lifecycle.markDisconnected();
778
+ throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'The server authenticated the request but opened no ABAP session: no SAP_SESSIONID cookie came back, so there is no session for a lock to be bound to. The HTTP side is fine — the cookies are here — which is why this is not a transport failure and does not look like one. ' +
779
+ 'Stateless reads would still work over it, but a lock, and any write under that lock, is dead the moment it is issued. ' +
780
+ 'The usual cause is the system declining to open another session for this user: they are limited per user, shared with every other tool logged on as them, and released either by disconnecting or by their own idle timeout. ' +
781
+ 'Whether to wait, retry, or release sessions this user still holds is yours to decide — this library does not retry on your behalf.');
782
+ }
783
+ this.lifecycle.markConnected(fingerprint);
326
784
  }
327
785
  /** The teardown epoch, for a recovery to capture before it starts. */
328
786
  get teardownEpoch() {
329
787
  return this.lifecycle.teardownEpoch;
330
788
  }
789
+ /**
790
+ * Which session the connection is on now.
791
+ *
792
+ * Moves whenever the session does. A response that comes back carrying an
793
+ * older one belongs to a session that has already been replaced, and must not
794
+ * be acted on as if it said something about the current one.
795
+ */
796
+ get sessionGeneration() {
797
+ return this.lifecycle.sessionGeneration;
798
+ }
331
799
  /**
332
800
  * Raises a session-lost teardown from inside request handling.
333
801
  *
@@ -398,8 +866,41 @@ class AbstractAbapConnection {
398
866
  this.logger?.debug('Ignoring a response from a previous session: its effects are fenced');
399
867
  return;
400
868
  }
869
+ this.rememberAppServer(headers);
401
870
  this.applyIdentityPolicy(this.updateCookiesFromResponse(headers));
402
871
  }
872
+ /**
873
+ * Take the application server's name from a response, if it named one.
874
+ *
875
+ * Only ever set from the server's own answer — never guessed, and never kept
876
+ * across a teardown.
877
+ */
878
+ rememberAppServer(headers) {
879
+ if (!headers)
880
+ return;
881
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === 'sap-adt-saplb');
882
+ const value = key ? headers[key] : undefined;
883
+ if (typeof value === 'string' && value && value !== this.appServer) {
884
+ this.appServer = value;
885
+ this.logger?.debug(`Session is on application server ${value}`);
886
+ }
887
+ }
888
+ /**
889
+ * Headers that keep this connection on the server its session lives on.
890
+ *
891
+ * `sap-adt-saplb: fetch` asks the server to name itself — it answers on every
892
+ * request, so the binding survives a restart that moves us. `saplb` is that
893
+ * name sent back. `REDISPATCH_ON_SHUTDOWN` is what Eclipse asks for: if the
894
+ * server is going down, send us elsewhere rather than fail.
895
+ */
896
+ affinityHeaders() {
897
+ return {
898
+ 'sap-adt-saplb': 'fetch',
899
+ ...(this.appServer
900
+ ? { saplb: this.appServer, 'saplb-options': 'REDISPATCH_ON_SHUTDOWN' }
901
+ : {}),
902
+ };
903
+ }
403
904
  /**
404
905
  * Acts on what a response said about the session identity.
405
906
  *
@@ -420,12 +921,14 @@ class AbstractAbapConnection {
420
921
  if (classification !== 'replaced')
421
922
  return;
422
923
  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');
924
+ throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session this connection was using is gone and the requests are now on a different one; anything held against the old session — a lock and any write under it — is dead. ' +
925
+ 'The server does not swap sessions on a whim: the usual causes are the session idling out (the timeout is idle-based, so a quiet connection loses it while a busy one does not) or a request landing on a different application server. ' +
926
+ 'What to do about it is yours: re-establish and redo the work, or fail the operation. Nothing is retried here.');
424
927
  }
425
928
  /**
426
929
  * Whether the server is telling us the session it was given no longer exists.
427
930
  *
428
- * The E19 shape was HTTP 400 with "Session not found", answered in ~60 ms
931
+ * One on-prem system answered HTTP 400 with "Session not found", answered in ~60 ms
429
932
  * with the cookie present — which is why identity comparison cannot see this:
430
933
  * the cookie, and therefore the fingerprint, is completely unchanged. The
431
934
  * exact match is landscape-specific and is one of the live probes this design
@@ -453,6 +956,9 @@ class AbstractAbapConnection {
453
956
  }
454
957
  this.csrfToken = null;
455
958
  this.cookies = null;
959
+ // Names a server for a session that no longer exists.
960
+ this.appServer = null;
961
+ this.preflightOpenedSession = false;
456
962
  this.cookieStore.clear();
457
963
  // Note: baseUrl is not reset as it's derived from immutable config
458
964
  }
@@ -536,6 +1042,11 @@ class AbstractAbapConnection {
536
1042
  if (this.sessionId) {
537
1043
  requestHeaders['sap-adt-connection-id'] = this.sessionId;
538
1044
  }
1045
+ // Keep this request on the server the session lives on. A session belongs
1046
+ // to one application server, so a request that lands elsewhere gets a
1047
+ // different session — and any lock held on the first one dies, with no
1048
+ // inactivity and nobody at fault.
1049
+ Object.assign(requestHeaders, this.affinityHeaders());
539
1050
  // Add stateful session headers if stateful mode is enabled
540
1051
  if (this.sessionMode === 'stateful') {
541
1052
  requestHeaders['x-sap-adt-sessiontype'] = 'stateful';
@@ -550,9 +1061,10 @@ class AbstractAbapConnection {
550
1061
  this.csrfToken) {
551
1062
  requestHeaders['x-csrf-token'] = this.csrfToken;
552
1063
  }
553
- // Add cookies LAST (MUST NOT be overridden by custom headers)
1064
+ // Add cookies LAST (MUST NOT be overridden by custom headers), MERGED with
1065
+ // whatever the auth headers already put there — see mergeCookieHeaders.
554
1066
  if (this.cookies) {
555
- requestHeaders.Cookie = this.cookies;
1067
+ requestHeaders.Cookie = (0, cookies_js_1.mergeCookieHeaders)(requestHeaders.Cookie, this.cookies);
556
1068
  this.logger?.debug(`[DEBUG] BaseAbapConnection - Adding cookies to request (first 100 chars): ${this.cookies.substring(0, 100)}...`);
557
1069
  }
558
1070
  else {
@@ -822,10 +1334,13 @@ class AbstractAbapConnection {
822
1334
  if (this.sessionId) {
823
1335
  headers['sap-adt-connection-id'] = this.sessionId;
824
1336
  }
1337
+ // Same reason as every other request: a token fetched from another
1338
+ // application server belongs to another session.
1339
+ Object.assign(headers, this.affinityHeaders());
825
1340
  // Always add cookies if available - they are needed for session continuity
826
1341
  // Even on first attempt, if we have cookies from previous session or error response, use them
827
1342
  if (this.cookies) {
828
- headers.Cookie = this.cookies;
1343
+ headers.Cookie = (0, cookies_js_1.mergeCookieHeaders)(headers.Cookie, this.cookies);
829
1344
  this.logger?.debug(`[DEBUG] BaseAbapConnection - Adding cookies to CSRF token request (attempt ${attempt + 1}, first 100 chars): ${this.cookies.substring(0, 100)}...`);
830
1345
  }
831
1346
  else {
@@ -1066,12 +1581,21 @@ class AbstractAbapConnection {
1066
1581
  * cookies (HTTP 401 on a mutation while a cached token exists). This forces the
1067
1582
  * next request path to fetch a fresh token and a fresh SAP_SESSIONID cookie.
1068
1583
  *
1069
- * Distinct from reset(): this leaves the axios instance and interceptors in place.
1584
+ * Distinct from disconnect(): this leaves the axios instance and interceptors
1585
+ * in place, and tells the server nothing — it is a request-level repair, not a
1586
+ * teardown.
1070
1587
  */
1071
1588
  invalidateSession() {
1072
1589
  this.setCsrfToken(null);
1073
1590
  this.cookies = null;
1074
1591
  this.cookieStore.clear();
1592
+ // Everything else that described THAT session. The application server named
1593
+ // a server for a session that is gone — sending it again would pin the next
1594
+ // connect, preflight included, to a dead one — and the preflight flag would
1595
+ // otherwise let a later failure send a goodbye to the previous session's
1596
+ // address.
1597
+ this.appServer = null;
1598
+ this.preflightOpenedSession = false;
1075
1599
  // And the tracked identity, because WE discarded the session. Without this
1076
1600
  // the cookie that arrives next reads as a foreign replacement — and since a
1077
1601
  // replacement is now always fatal, our own deliberate re-authentication