@mcp-abap-adt/connection 5.0.0 → 6.0.1

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 (92) hide show
  1. package/CHANGELOG.md +243 -1
  2. package/README.md +204 -48
  3. package/bin/sap-abap-auth.js +101 -13
  4. package/dist/auth/providers.d.ts +38 -9
  5. package/dist/auth/providers.d.ts.map +1 -1
  6. package/dist/auth/providers.js +46 -3
  7. package/dist/connection/AbstractAbapConnection.d.ts +83 -136
  8. package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
  9. package/dist/connection/AbstractAbapConnection.js +241 -763
  10. package/dist/connection/AdtCloudConnector.d.ts +12 -7
  11. package/dist/connection/AdtCloudConnector.d.ts.map +1 -1
  12. package/dist/connection/AdtCloudConnector.js +2 -6
  13. package/dist/connection/AdtOnPremConnector.d.ts +20 -7
  14. package/dist/connection/AdtOnPremConnector.d.ts.map +1 -1
  15. package/dist/connection/AdtOnPremConnector.js +2 -6
  16. package/dist/connection/CloudHttpTransport.d.ts +37 -0
  17. package/dist/connection/CloudHttpTransport.d.ts.map +1 -0
  18. package/dist/{session/CloudSecuritySessionStrategy.js → connection/CloudHttpTransport.js} +59 -49
  19. package/dist/connection/CredentialAbapConnection.d.ts +8 -41
  20. package/dist/connection/CredentialAbapConnection.d.ts.map +1 -1
  21. package/dist/connection/CredentialAbapConnection.js +44 -111
  22. package/dist/connection/HttpTransport.d.ts +178 -0
  23. package/dist/connection/HttpTransport.d.ts.map +1 -0
  24. package/dist/connection/HttpTransport.js +402 -0
  25. package/dist/connection/IAdtTransport.d.ts +232 -0
  26. package/dist/connection/IAdtTransport.d.ts.map +1 -0
  27. package/dist/connection/IAdtTransport.js +28 -0
  28. package/dist/connection/LegacyOnPremHttpTransport.d.ts +40 -0
  29. package/dist/connection/LegacyOnPremHttpTransport.d.ts.map +1 -0
  30. package/dist/connection/LegacyOnPremHttpTransport.js +57 -0
  31. package/dist/connection/OnPremHttpTransport.d.ts +45 -0
  32. package/dist/connection/OnPremHttpTransport.d.ts.map +1 -0
  33. package/dist/connection/OnPremHttpTransport.js +91 -0
  34. package/dist/connection/RfcTransport.d.ts +89 -0
  35. package/dist/connection/RfcTransport.d.ts.map +1 -0
  36. package/dist/connection/RfcTransport.js +270 -0
  37. package/dist/connection/rfcConversation.d.ts +44 -0
  38. package/dist/connection/rfcConversation.d.ts.map +1 -0
  39. package/dist/connection/rfcConversation.js +71 -0
  40. package/dist/index.d.ts +7 -8
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +21 -18
  43. package/dist/utils/timeouts.d.ts +6 -19
  44. package/dist/utils/timeouts.d.ts.map +1 -1
  45. package/dist/utils/timeouts.js +6 -22
  46. package/docs/INDEX.md +5 -2
  47. package/docs/INSTALLATION.md +28 -10
  48. package/docs/JWT_AUTH_TOOLS.md +20 -4
  49. package/docs/MIGRATION-6.0.md +359 -0
  50. package/docs/SCOPE.md +1 -1
  51. package/docs/STATEFUL_SESSION_GUIDE.md +86 -17
  52. package/docs/USAGE.md +260 -119
  53. package/examples/basic-connection.js +15 -3
  54. package/examples/jwt-with-token-refresh.js +15 -7
  55. package/examples/saml-connection.js +15 -2
  56. package/package.json +12 -11
  57. package/dist/__tests__/helpers/session.d.ts +0 -15
  58. package/dist/__tests__/helpers/session.d.ts.map +0 -1
  59. package/dist/__tests__/helpers/session.js +0 -19
  60. package/dist/auth/IAuthProvider.d.ts +0 -84
  61. package/dist/auth/IAuthProvider.d.ts.map +0 -1
  62. package/dist/auth/IAuthProvider.js +0 -21
  63. package/dist/connection/BaseAbapConnection.d.ts +0 -29
  64. package/dist/connection/BaseAbapConnection.d.ts.map +0 -1
  65. package/dist/connection/BaseAbapConnection.js +0 -81
  66. package/dist/connection/CertificateAbapConnection.d.ts +0 -35
  67. package/dist/connection/CertificateAbapConnection.d.ts.map +0 -1
  68. package/dist/connection/CertificateAbapConnection.js +0 -91
  69. package/dist/connection/JwtAbapConnection.d.ts +0 -131
  70. package/dist/connection/JwtAbapConnection.d.ts.map +0 -1
  71. package/dist/connection/JwtAbapConnection.js +0 -376
  72. package/dist/connection/KerberosAbapConnection.d.ts +0 -32
  73. package/dist/connection/KerberosAbapConnection.d.ts.map +0 -1
  74. package/dist/connection/KerberosAbapConnection.js +0 -128
  75. package/dist/connection/RfcAbapConnection.d.ts +0 -44
  76. package/dist/connection/RfcAbapConnection.d.ts.map +0 -1
  77. package/dist/connection/RfcAbapConnection.js +0 -324
  78. package/dist/connection/SamlAbapConnection.d.ts +0 -31
  79. package/dist/connection/SamlAbapConnection.d.ts.map +0 -1
  80. package/dist/connection/SamlAbapConnection.js +0 -81
  81. package/dist/connection/connectionFactory.d.ts +0 -25
  82. package/dist/connection/connectionFactory.d.ts.map +0 -1
  83. package/dist/connection/connectionFactory.js +0 -84
  84. package/dist/session/CloudSecuritySessionStrategy.d.ts +0 -32
  85. package/dist/session/CloudSecuritySessionStrategy.d.ts.map +0 -1
  86. package/dist/session/IcfSessionStrategy.d.ts +0 -27
  87. package/dist/session/IcfSessionStrategy.d.ts.map +0 -1
  88. package/dist/session/IcfSessionStrategy.js +0 -62
  89. package/dist/session/SessionStrategy.d.ts +0 -86
  90. package/dist/session/SessionStrategy.d.ts.map +0 -1
  91. package/dist/session/SessionStrategy.js +0 -33
  92. package/docs/superpowers/specs/2026-08-21-platform-connectors.md +0 -108
@@ -1,66 +1,12 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.AbstractAbapConnection = void 0;
37
4
  const node_crypto_1 = require("node:crypto");
38
- const node_https_1 = require("node:https");
39
5
  const interfaces_1 = require("@mcp-abap-adt/interfaces");
40
- const axios_1 = __importStar(require("axios"));
41
- const IcfSessionStrategy_js_1 = require("../session/IcfSessionStrategy.js");
42
6
  const SessionLifecycle_js_1 = require("../session/SessionLifecycle.js");
43
- const cookies_js_1 = require("../utils/cookies.js");
44
7
  const timeouts_js_1 = require("../utils/timeouts.js");
45
8
  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
- }
9
+ const IAdtTransport_js_1 = require("./IAdtTransport.js");
64
10
  /**
65
11
  * Declares the capabilities explicitly rather than satisfying them by accident.
66
12
  * `AbapConnection` is the base contract every transport honours; these two are
@@ -85,6 +31,7 @@ function readReleaseDeadline() {
85
31
  */
86
32
  class AbstractAbapConnection {
87
33
  config;
34
+ transport;
88
35
  logger;
89
36
  /**
90
37
  * Owns session state, admission and teardown ordering. Composed rather than
@@ -92,20 +39,17 @@ class AbstractAbapConnection {
92
39
  * transports share this unit instead of a base class.
93
40
  */
94
41
  lifecycle = new SessionLifecycle_js_1.SessionLifecycle();
95
- axiosInstance = null;
96
- csrfToken = null;
97
- cookies = null;
98
- cookieStore = new Map();
99
42
  baseUrl;
100
43
  sessionId = null;
101
44
  sessionMode = 'stateless';
102
- skipSessionType;
103
45
  /**
104
46
  * When true, requests are treated as part of an uninterruptible critical
105
47
  * section (e.g. a lock → modify → unlock chain). In this state a short
106
- * per-request timeout must NOT abort the request mid-flight, because
107
- * aborting the socket drops the stateful ADT session and orphans the lock
108
- * handle (leaving the object locked and inactive). While set, makeAdtRequest
48
+ * per-request timeout must NOT abort the request mid-flight: the abort ends
49
+ * nothing server-side — only the server ends an ABAP session, on an idle
50
+ * timeout this side cannot see — it ends what this side KNOWS. Whether the
51
+ * change was applied becomes unknowable, and the handle `unlock` needs is
52
+ * lost while the lock lives on in that session. While set, makeAdtRequest
109
53
  * raises the effective timeout to CRITICAL_SECTION_TIMEOUT (a large ceiling)
110
54
  * so the request runs to completion instead of being interrupted.
111
55
  */
@@ -113,7 +57,6 @@ class AbstractAbapConnection {
113
57
  /** Reference count for nested beginCriticalSection()/endCriticalSection() pairs. */
114
58
  criticalSectionDepth = 0;
115
59
  /** The default release deadline, validated once at construction. */
116
- releaseDeadlineMs;
117
60
  /**
118
61
  * The logoff for the session this connection last held, while it is on its
119
62
  * way. One, because a connection holds one session: `connect()` opens it and
@@ -147,7 +90,6 @@ class AbstractAbapConnection {
147
90
  * with the rest of the session state: it names a server for a session that no
148
91
  * longer exists.
149
92
  */
150
- appServer = null;
151
93
  /**
152
94
  * Whether the preflight opened a session of its own.
153
95
  *
@@ -156,20 +98,20 @@ class AbstractAbapConnection {
156
98
  * Only a preflight answered with a session address opened one, and only that
157
99
  * is worth saying goodbye to when establishment then fails.
158
100
  */
159
- preflightOpenedSession = false;
160
- sessionStrategy = new IcfSessionStrategy_js_1.IcfSessionStrategy(null);
161
- pendingRelease = null;
162
- constructor(config, logger, sessionId, options) {
101
+ constructor(config,
102
+ /**
103
+ * Required, and constructed by the caller.
104
+ *
105
+ * There is no default to fall back to and nothing is worked out from the
106
+ * config: the wire is a fact about the deployment, and a library that
107
+ * picked one would be guessing at the thing this design exists to stop
108
+ * guessing at. It also carries what the caller alone can wire — the
109
+ * credential's TLS material, the client — which is why it arrives built.
110
+ */
111
+ transport, logger, sessionId) {
163
112
  this.config = config;
113
+ this.transport = transport;
164
114
  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();
172
- this.skipSessionType = options?.skipSessionType ?? false;
173
115
  // Generate sessionId (used for sap-adt-connection-id header)
174
116
  this.sessionId = sessionId || (0, node_crypto_1.randomUUID)();
175
117
  // Initialize baseUrl from config (required, will throw if invalid)
@@ -188,16 +130,12 @@ class AbstractAbapConnection {
188
130
  * - stateful: SAP maintains session state between requests (locks, transactions)
189
131
  * - stateless: Each request is independent
190
132
  *
191
- * When skipSessionType is enabled (via constructor options), this is a no-op:
192
- * the x-sap-adt-sessiontype header will never be sent. This is needed for
193
- * older BASIS versions (e.g. 7.40) where the stateful header causes locks
194
- * to be stored in ABAP session memory instead of the global enqueue table,
195
- * resulting in HTTP 423 on subsequent PUT requests.
133
+ * Whether the header actually goes out is the WIRE's: a system where the
134
+ * stateful header makes the server store locks in session memory instead of
135
+ * the enqueue table — BASIS 7.40 — is a different deployment, and a
136
+ * deployment is a transport, not a flag on the connection.
196
137
  */
197
138
  setSessionType(type) {
198
- if (this.skipSessionType) {
199
- return;
200
- }
201
139
  this.sessionMode = type;
202
140
  this.logger?.debug(`Session type set to: ${type}`, {
203
141
  sessionId: this.sessionId?.substring(0, 8),
@@ -216,9 +154,11 @@ class AbstractAbapConnection {
216
154
  * in a finally, AFTER unlocking). While in a critical section, a short
217
155
  * per-request timeout is not applied — makeAdtRequest uses a large ceiling
218
156
  * (CRITICAL_SECTION_TIMEOUT, env SAP_TIMEOUT_CRITICAL) instead — so a slow
219
- * PUT/activate/unlock is not aborted mid-flight. Aborting mid-flight tears
220
- * down the socket, which drops the stateful ADT session and orphans the
221
- * lock handle, leaving the object locked and inactive.
157
+ * PUT/activate/unlock is not aborted mid-flight. An abort does not end the
158
+ * ABAP session — that session is the server's, and only the server ends it,
159
+ * on an idle timeout this side cannot influence — it strands the caller:
160
+ * the outcome is unknown and the handle `unlock` needs is gone, while the
161
+ * lock survives inside the ABAP session that owns it.
222
162
  *
223
163
  * Nesting is reference-counted so nested begin/end pairs are safe.
224
164
  */
@@ -304,6 +244,11 @@ class AbstractAbapConnection {
304
244
  await this.lifecycle.transition('connect', async () => {
305
245
  if (this.lifecycle.connected)
306
246
  return;
247
+ // The wire gets a session in whatever way it has one: an RFC
248
+ // conversation opened, a cloud session resource asked for, nothing at
249
+ // all on a wire whose session arrives with the establishing call. Which
250
+ // of those it is belongs to the transport the caller handed in.
251
+ await this.transport.open(this.sessionContext());
307
252
  await this.establishAndCommit(baselineEpoch);
308
253
  });
309
254
  }
@@ -322,12 +267,12 @@ class AbstractAbapConnection {
322
267
  * request cannot use, so the leak surfaces as a half-written object rather
323
268
  * than as anything about sessions.
324
269
  *
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.
270
+ * **Nothing is waited for.** The logoff is dispatched, not awaited: this
271
+ * method notifies, and whether the server has acted on the notice is not
272
+ * something it reports. Finishing chains and releasing locks stay the
273
+ * caller's to do BEFORE calling — waiting here on a request that carries no
274
+ * timeout by design is what made a teardown unbounded, which blocks every
275
+ * later transition on the serialized tail.
331
276
  *
332
277
  * **Requests already in flight are not waited for, and the logoff ends the
333
278
  * session they are running on** — so they will start failing against a
@@ -336,38 +281,16 @@ class AbstractAbapConnection {
336
281
  * dropped the cookie. Generation fencing (see `SessionLifecycle.isCurrent`)
337
282
  * keeps their results from reaching this connection either way.
338
283
  *
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.
284
+ * **Nothing to wait for.** This TELLS the server the session is finished and
285
+ * does not act on the answer — whether and when the session is freed is the
286
+ * server's affair. Waiting for a reply nobody reads was also the one thing
287
+ * that could make a teardown unbounded, since the goodbye carries no request
288
+ * timeout by design.
352
289
  */
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();
290
+ async disconnect() {
291
+ // Nothing here throws. This method's place is a `finally` — a connection
292
+ // that was connected must be disconnected — and an exception raised there
293
+ // replaces the error that sent the caller into it.
371
294
  // Synchronous, at the call: admission shuts and the generation moves before
372
295
  // anything is queued, so a caller who has asked to disconnect cannot have
373
296
  // requests still going through while this waits its turn.
@@ -378,126 +301,19 @@ class AbstractAbapConnection {
378
301
  // asked to release.
379
302
  const session = this.getSessionIdentity();
380
303
  await this.lifecycle.transition('disconnect', async () => {
381
- await this.releaseServerSession();
304
+ // DISPATCHED, not awaited. The goodbye carries no request timeout by
305
+ // design — a server that never answers must not hold a teardown open —
306
+ // and nothing bounds it here either, because there is nothing to bound:
307
+ // awaiting an unbounded request is precisely what would make every
308
+ // teardown unbounded.
309
+ //
310
+ // The context is taken now, while the session is still true: the clear
311
+ // below runs while the close is suspended on its first await.
312
+ const context = this.sessionContext();
313
+ const inFlight = Promise.resolve(this.transport.close(context)).then(() => undefined);
382
314
  this.clearSessionState();
383
315
  this.lifecycle.markDisconnected();
384
316
  });
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
- }
501
317
  }
502
318
  isConnected() {
503
319
  return this.lifecycle.connected;
@@ -551,67 +367,6 @@ class AbstractAbapConnection {
551
367
  * the session is ours, and nothing else. It cannot reach session state, so a
552
368
  * strategy can neither mark this connection connected nor tear it down.
553
369
  */
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
370
  /**
616
371
  * Ask the server to open a session before the establishing call needs one.
617
372
  *
@@ -623,27 +378,19 @@ class AbstractAbapConnection {
623
378
  * is what happens when nobody asks.
624
379
  */
625
380
  /**
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.
381
+ * What a wire needs from this connection to get a session, or give one back.
634
382
  *
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.
383
+ * A snapshot in the sense that matters: a close is dispatched without being
384
+ * awaited, so nothing here may read the connection again once the teardown
385
+ * has cleared it.
639
386
  */
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}`);
387
+ sessionContext() {
388
+ return {
389
+ baseUrl: this.baseUrl,
390
+ authHeaders: () => this.getAuthHeaders(),
391
+ extraHeaders: { 'sap-adt-connection-id': this.getSessionId() ?? '' },
392
+ observe: (headers) => this.observeResponse(headers),
393
+ };
647
394
  }
648
395
  async establishAndCommit(baselineEpoch) {
649
396
  if (this.lifecycle.teardownEpoch !== baselineEpoch) {
@@ -663,7 +410,6 @@ class AbstractAbapConnection {
663
410
  // Before the establishing call, because on a system that has one this is
664
411
  // what creates the session the rest of the connection runs in — and the
665
412
  // cookies it sets are the ones the establishing call must carry.
666
- await this.openServerSession();
667
413
  // Forgotten again, because the open was OURS. Establishment is now two
668
414
  // requests where it used to be one, and the identity policy answers "did
669
415
  // the server move us to a different session while we were working" — a
@@ -699,9 +445,11 @@ class AbstractAbapConnection {
699
445
  // finished with something we never had sends a request nobody asked for
700
446
  // — into the middle of an authentication exchange, in the case that found
701
447
  // this.
702
- const goodbye = this.preflightOpenedSession
703
- ? this.sessionStrategy.closeSession(this.sessionTransport())
704
- : Promise.resolve();
448
+ // Whether there is anything to say is the wire's to know: a cloud
449
+ // session has an address or it does not, and an on-prem one was
450
+ // established or the cookie is debris from the refusal. The connection
451
+ // asks, and each wire answers by doing nothing when it has nothing.
452
+ const goodbye = this.transport.close(this.sessionContext());
705
453
  this.invalidateSession();
706
454
  // And the identity with it. The rejecting response was still observed, so
707
455
  // its cookie was recorded as a session that had just been established —
@@ -758,29 +506,26 @@ class AbstractAbapConnection {
758
506
  // bearer obtainable here, so the question stayed open. If a cloud system
759
507
  // turns out to hold sessions without issuing this cookie, this is the rule
760
508
  // to revisit — and it will say so loudly rather than fail quietly.
761
- const fingerprint = this.sessionFingerprint();
762
- if (fingerprint.size === 0 && !this.skipSessionType) {
509
+ if (!this.transport.sessionEstablished()) {
763
510
  // Goodbye first, for the same reason the catch above does it: the
764
511
  // preflight may have opened a session — on cloud it does — and this path
765
512
  // is about to drop the cookies that are the only permission to close it.
766
513
  // 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
- }
514
+ try {
515
+ void this.transport.close(this.sessionContext());
516
+ }
517
+ catch (error) {
518
+ this.logger?.debug(`Could not tell the server the session is finished: ${error instanceof Error ? error.message : String(error)}`);
774
519
  }
775
520
  this.invalidateSession();
776
521
  this.lifecycle.forgetIdentity();
777
522
  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. ' +
523
+ throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, `The server authenticated the request but opened no ABAP session: the ${this.transport.kind} wire reports it is on none, so there is nothing for a lock to be bound to. The wire itself is fine — the request was carried and answered — which is why this is not a transport failure and does not look like one. ` +
779
524
  'Stateless reads would still work over it, but a lock, and any write under that lock, is dead the moment it is issued. ' +
780
525
  '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
526
  'Whether to wait, retry, or release sessions this user still holds is yours to decide — this library does not retry on your behalf.');
782
527
  }
783
- this.lifecycle.markConnected(fingerprint);
528
+ this.lifecycle.markConnected(this.sessionFingerprint());
784
529
  }
785
530
  /** The teardown epoch, for a recovery to capture before it starts. */
786
531
  get teardownEpoch() {
@@ -866,40 +611,10 @@ class AbstractAbapConnection {
866
611
  this.logger?.debug('Ignoring a response from a previous session: its effects are fenced');
867
612
  return;
868
613
  }
869
- this.rememberAppServer(headers);
870
- this.applyIdentityPolicy(this.updateCookiesFromResponse(headers));
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
- };
614
+ // The wire folds the response into its own state; what the change MEANS
615
+ // is decided here, because it is a question about the session's lifetime.
616
+ this.transport.ingest(headers);
617
+ this.applyIdentityPolicy(this.lifecycle.observe(this.transport.sessionFingerprint()));
903
618
  }
904
619
  /**
905
620
  * Acts on what a response said about the session identity.
@@ -935,13 +650,14 @@ class AbstractAbapConnection {
935
650
  * still owes.
936
651
  */
937
652
  isDeadSessionResponse(error) {
938
- if (!(error instanceof axios_1.AxiosError) || !error.response)
653
+ const refusal = (0, IAdtTransport_js_1.refusalOf)(error);
654
+ if (!refusal)
939
655
  return false;
940
- if (error.response.status !== 400)
656
+ if (refusal.status !== 400)
941
657
  return false;
942
658
  const text = [
943
- error.response.statusText,
944
- typeof error.response.data === 'string' ? error.response.data : '',
659
+ refusal.statusText,
660
+ typeof refusal.data === 'string' ? refusal.data : '',
945
661
  ]
946
662
  .join(' ')
947
663
  .toLowerCase();
@@ -949,17 +665,11 @@ class AbstractAbapConnection {
949
665
  }
950
666
  /** Drops everything that described the session. Not a lifecycle transition. */
951
667
  clearSessionState() {
952
- if (this.axiosInstance) {
953
- this.axiosInstance.interceptors.request.clear();
954
- this.axiosInstance.interceptors.response.clear();
955
- this.axiosInstance = null;
956
- }
957
- this.csrfToken = null;
958
- this.cookies = null;
959
- // Names a server for a session that no longer exists.
960
- this.appServer = null;
961
- this.preflightOpenedSession = false;
962
- this.cookieStore.clear();
668
+ this.setCsrfToken(null);
669
+ // Cookies, the fingerprint and the application server are the wire's, and
670
+ // it gives them back together — a server named for a session that no
671
+ // longer exists is as stale as the cookie that addressed it.
672
+ this.transport.forgetSession();
963
673
  // Note: baseUrl is not reset as it's derived from immutable config
964
674
  }
965
675
  /**
@@ -971,13 +681,7 @@ class AbstractAbapConnection {
971
681
  * overwritten on every response.
972
682
  */
973
683
  sessionFingerprint() {
974
- const fingerprint = new Map();
975
- for (const [name, value] of this.cookieStore) {
976
- if (name.startsWith('SAP_SESSIONID')) {
977
- fingerprint.set(name, value);
978
- }
979
- }
980
- return fingerprint;
684
+ return this.transport.sessionFingerprint();
981
685
  }
982
686
  async getBaseUrl() {
983
687
  return this.baseUrl;
@@ -1010,21 +714,22 @@ class AbstractAbapConnection {
1010
714
  async performRequest(options, lease) {
1011
715
  const { url: endpoint, method, timeout, data, params, headers: customHeaders, } = options;
1012
716
  const normalizedMethod = method.toUpperCase();
1013
- // Build full URL: baseUrl + endpoint
1014
- const requestUrl = `${this.baseUrl}${endpoint}`;
717
+ // The PATH, not an address. Which server it belongs in front of — or
718
+ // whether it belongs in front of one at all — is the wire's to say.
719
+ const requestUrl = endpoint;
1015
720
  // Try to ensure CSRF token is available for POST/PUT/DELETE, but don't fail if it can't be fetched
1016
721
  // The retry logic will handle CSRF token errors automatically
1017
722
  if (normalizedMethod === 'POST' ||
1018
723
  normalizedMethod === 'PUT' ||
1019
724
  normalizedMethod === 'DELETE') {
1020
- if (!this.csrfToken) {
725
+ if (!this.transport.csrfToken()) {
1021
726
  try {
1022
- await this.ensureFreshCsrfToken(requestUrl);
727
+ await this.ensureWireReady();
1023
728
  }
1024
729
  catch (error) {
1025
730
  // If CSRF token can't be fetched upfront, continue anyway
1026
731
  // The retry logic will handle CSRF token errors automatically
1027
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Could not fetch CSRF token upfront, will retry on error: ${error instanceof Error ? error.message : String(error)}`);
732
+ this.logger?.debug(`Could not fetch CSRF token upfront, will retry on error: ${error instanceof Error ? error.message : String(error)}`);
1028
733
  }
1029
734
  }
1030
735
  }
@@ -1042,11 +747,6 @@ class AbstractAbapConnection {
1042
747
  if (this.sessionId) {
1043
748
  requestHeaders['sap-adt-connection-id'] = this.sessionId;
1044
749
  }
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());
1050
750
  // Add stateful session headers if stateful mode is enabled
1051
751
  if (this.sessionMode === 'stateful') {
1052
752
  requestHeaders['x-sap-adt-sessiontype'] = 'stateful';
@@ -1055,21 +755,19 @@ class AbstractAbapConnection {
1055
755
  }
1056
756
  // Add auth headers (these MUST NOT be overridden)
1057
757
  Object.assign(requestHeaders, await this.getAuthHeaders());
758
+ // Read once: the wire is asked what it holds, and the same value is what
759
+ // goes on the header.
760
+ const presented = this.transport.csrfToken();
1058
761
  if ((normalizedMethod === 'POST' ||
1059
762
  normalizedMethod === 'PUT' ||
1060
763
  normalizedMethod === 'DELETE') &&
1061
- this.csrfToken) {
1062
- requestHeaders['x-csrf-token'] = this.csrfToken;
1063
- }
1064
- // Add cookies LAST (MUST NOT be overridden by custom headers), MERGED with
1065
- // whatever the auth headers already put there — see mergeCookieHeaders.
1066
- if (this.cookies) {
1067
- requestHeaders.Cookie = (0, cookies_js_1.mergeCookieHeaders)(requestHeaders.Cookie, this.cookies);
1068
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Adding cookies to request (first 100 chars): ${this.cookies.substring(0, 100)}...`);
1069
- }
1070
- else {
1071
- this.logger?.debug(`[DEBUG] BaseAbapConnection - NO COOKIES available for this request to ${requestUrl}`);
764
+ presented) {
765
+ requestHeaders['x-csrf-token'] = presented;
1072
766
  }
767
+ // No cookies and no affinity headers here. Both are the wire's own state,
768
+ // and the wire puts them on the requests it sends — a connection that
769
+ // threaded them would be threading them for every transport, including one
770
+ // that has neither.
1073
771
  if ((normalizedMethod === 'POST' || normalizedMethod === 'PUT') && data) {
1074
772
  if (typeof data === 'string' && !requestHeaders['Content-Type']) {
1075
773
  if (requestUrl.includes('/usageReferences') &&
@@ -1085,8 +783,10 @@ class AbstractAbapConnection {
1085
783
  }
1086
784
  }
1087
785
  // Inside an uninterruptible critical section (lock → modify → unlock), a
1088
- // short per-request timeout must not abort the request mid-flight — that
1089
- // would drop the stateful session and orphan the lock. Raise the effective
786
+ // short per-request timeout must not abort the request mid-flight — not
787
+ // because the abort ends the ABAP session (only the server does that), but
788
+ // because it leaves the outcome unknown and the unlock handle lost while
789
+ // the lock stays held in that session. Raise the effective
1090
790
  // timeout to the large critical-section ceiling for the whole request
1091
791
  // (also honoured on the retry paths below, which reuse requestConfig).
1092
792
  const effectiveTimeout = this.inCriticalSection
@@ -1097,7 +797,9 @@ class AbstractAbapConnection {
1097
797
  url: requestUrl,
1098
798
  headers: requestHeaders,
1099
799
  timeout: effectiveTimeout,
1100
- params,
800
+ // `unknown` on the caller's options, a record on the seam: the two
801
+ // transports serialise a query differently and both need the pairs.
802
+ params: params,
1101
803
  };
1102
804
  if (data !== undefined) {
1103
805
  requestConfig.data = data;
@@ -1108,7 +810,7 @@ class AbstractAbapConnection {
1108
810
  method: normalizedMethod,
1109
811
  });
1110
812
  try {
1111
- const response = await this.getAxiosInstance()(requestConfig);
813
+ const response = await this.transport.send(requestConfig);
1112
814
  this.observeResponse(response.headers, lease.generation);
1113
815
  this.logger?.debug(`Request succeeded with status ${response.status}`, {
1114
816
  type: 'REQUEST_SUCCESS',
@@ -1138,15 +840,18 @@ class AbstractAbapConnection {
1138
840
  message: error instanceof Error ? error.message : String(error),
1139
841
  url: requestUrl,
1140
842
  method: normalizedMethod,
1141
- status: error instanceof axios_1.AxiosError ? error.response?.status : undefined,
843
+ status: (0, IAdtTransport_js_1.refusalOf)(error)?.status,
1142
844
  data: undefined,
1143
845
  };
1144
- if (error instanceof axios_1.AxiosError && error.response) {
846
+ const refusal = (0, IAdtTransport_js_1.refusalOf)(error);
847
+ if (refusal) {
1145
848
  errorDetails.data =
1146
- typeof error.response.data === 'string'
1147
- ? error.response.data.slice(0, 200)
1148
- : JSON.stringify(error.response.data).slice(0, 200);
1149
- this.observeResponse(error.response.headers, lease.generation);
849
+ typeof refusal.data === 'string'
850
+ ? refusal.data.slice(0, 200)
851
+ : JSON.stringify(refusal.data).slice(0, 200);
852
+ // Every wire's refusal, not only axios's. A response the connection
853
+ // never observed is a session replacement it never noticed.
854
+ this.observeResponse(refusal.headers, lease.generation);
1150
855
  }
1151
856
  // The server telling us the session is gone is invisible to the identity
1152
857
  // comparison: the cookie, and therefore the fingerprint, is unchanged.
@@ -1172,20 +877,24 @@ class AbstractAbapConnection {
1172
877
  else {
1173
878
  this.logger?.error(errorDetails.message, errorDetails);
1174
879
  }
1175
- // Detect the "login-form 401" pattern: SAP returned 401 for a mutation while
1176
- // we have a cached CSRF token. The token and its bound SAP session must be
1177
- // discarded before the retry. Basic auth only — JWT/SAML lifecycles are
1178
- // managed elsewhere.
1179
- const isCachedTokenStale = error instanceof axios_1.AxiosError &&
1180
- this.config.authType === 'basic' &&
1181
- (normalizedMethod === 'POST' ||
1182
- normalizedMethod === 'PUT' ||
1183
- normalizedMethod === 'DELETE') &&
1184
- error.response?.status === 401 &&
880
+ // The "login-form 401": SAP refused a mutation while we hold a cached CSRF
881
+ // token, so the token and the session it is bound to are dead and must be
882
+ // discarded before the retry.
883
+ //
884
+ // Not keyed on the credential any more. It used to be "basic auth only —
885
+ // JWT/SAML lifecycles are managed elsewhere", and elsewhere was
886
+ // JwtAbapConnection, which is gone. Credential renewal now lives a layer
887
+ // ABOVE this, in CredentialAbapConnection, which wraps the whole request:
888
+ // these retries happen first and it only sees a 401 that survived them.
889
+ // Nothing collides, so nothing needs to be excluded.
890
+ const isCachedTokenStale = (normalizedMethod === 'POST' ||
891
+ normalizedMethod === 'PUT' ||
892
+ normalizedMethod === 'DELETE') &&
893
+ (0, IAdtTransport_js_1.refusalOf)(error)?.status === 401 &&
1185
894
  this.getCsrfToken() !== null;
1186
895
  // Retry logic for CSRF token errors (403 with CSRF message) and the
1187
896
  // login-form 401 pattern.
1188
- if (this.shouldRetryCsrf(error) || isCachedTokenStale) {
897
+ if (this.shouldRetryCsrf(error, normalizedMethod) || isCachedTokenStale) {
1189
898
  this.logger?.debug(isCachedTokenStale
1190
899
  ? 'Stale CSRF token / SAP session — invalidating and retrying'
1191
900
  : 'CSRF token validation failed, fetching new token and retrying request', {
@@ -1207,7 +916,7 @@ class AbstractAbapConnection {
1207
916
  if (refreshedCookies) {
1208
917
  requestHeaders.Cookie = refreshedCookies;
1209
918
  }
1210
- const retryResponse = await this.getAxiosInstance()(requestConfig);
919
+ const retryResponse = await this.transport.send(requestConfig);
1211
920
  this.observeResponse(retryResponse.headers, lease.generation);
1212
921
  return retryResponse;
1213
922
  }
@@ -1224,30 +933,30 @@ class AbstractAbapConnection {
1224
933
  throw error;
1225
934
  }
1226
935
  }
1227
- // Retry logic for 401 errors on GET requests (authentication issue - need cookies)
1228
- // Only for basic auth - JWT auth will be handled by refresh logic below
1229
- if (error instanceof axios_1.AxiosError &&
1230
- error.response?.status === 401 &&
1231
- normalizedMethod === 'GET' &&
1232
- this.config.authType === 'basic' // Only for basic auth
1233
- ) {
936
+ // A 401 on a GET where cookies have since arrived: the first request had
937
+ // none, and the session they name is what the retry needs. Guarded below
938
+ // on actually holding some, so a wire that issues no cookies — RFC —
939
+ // never takes it.
940
+ if ((0, IAdtTransport_js_1.refusalOf)(error)?.status === 401 && normalizedMethod === 'GET') {
1234
941
  // If we already have cookies from error response, retry immediately
1235
- if (this.cookies) {
1236
- this.logger?.debug(`[DEBUG] BaseAbapConnection - 401 on GET request, retrying with cookies from error response`);
1237
- requestHeaders.Cookie = this.cookies;
1238
- const retryResponse = await this.getAxiosInstance()(requestConfig);
942
+ const afterError = this.transport.cookies();
943
+ if (afterError) {
944
+ this.logger?.debug(`401 on GET request, retrying with cookies from error response`);
945
+ requestHeaders.Cookie = afterError;
946
+ const retryResponse = await this.transport.send(requestConfig);
1239
947
  this.observeResponse(retryResponse.headers, lease.generation);
1240
948
  return retryResponse;
1241
949
  }
1242
950
  // If no cookies, try to get them via CSRF token fetch
1243
- this.logger?.debug(`[DEBUG] BaseAbapConnection - 401 on GET request, attempting to get cookies via CSRF token fetch`);
951
+ this.logger?.debug(`401 on GET request, attempting to get cookies via CSRF token fetch`);
1244
952
  try {
1245
953
  // Try to get CSRF token (this will also get cookies)
1246
- this.csrfToken = await this.fetchCsrfToken(requestUrl, 3, 1000, lease.generation);
1247
- if (this.cookies) {
1248
- requestHeaders.Cookie = this.cookies;
1249
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Retrying GET request with cookies from CSRF fetch`);
1250
- const retryResponse = await this.getAxiosInstance()(requestConfig);
954
+ this.setCsrfToken(await this.fetchCsrfToken(requestUrl, 3, 1000, lease.generation));
955
+ const afterCsrf = this.transport.cookies();
956
+ if (afterCsrf) {
957
+ requestHeaders.Cookie = afterCsrf;
958
+ this.logger?.debug(`Retrying GET request with cookies from CSRF fetch`);
959
+ const retryResponse = await this.transport.send(requestConfig);
1251
960
  this.observeResponse(retryResponse.headers, lease.generation);
1252
961
  return retryResponse;
1253
962
  }
@@ -1256,7 +965,7 @@ class AbstractAbapConnection {
1256
965
  if (this.isSessionVerdict(csrfError)) {
1257
966
  throw csrfError;
1258
967
  }
1259
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Failed to get CSRF token for 401 retry: ${csrfError instanceof Error ? csrfError.message : String(csrfError)}`);
968
+ this.logger?.debug(`Failed to get CSRF token for 401 retry: ${csrfError instanceof Error ? csrfError.message : String(csrfError)}`);
1260
969
  // Fall through to throw original error
1261
970
  }
1262
971
  }
@@ -1264,317 +973,90 @@ class AbstractAbapConnection {
1264
973
  }
1265
974
  }
1266
975
  /**
1267
- * Fetch CSRF token from SAP system
1268
- * Protected method for use by concrete implementations in their connect() method
976
+ * Ask the wire to establish itself, and hand back what it earned.
977
+ *
978
+ * The exchange itself is the transport's — it is the HTTP wire that has a
979
+ * token to earn and an endpoint to earn it from, and the RFC wire that has
980
+ * neither. What stays here is the part that is about the SESSION rather than
981
+ * the wire: fencing the answer by generation, and letting the identity policy
982
+ * read what it means.
1269
983
  */
1270
- async fetchCsrfToken(url, retryCount = csrfConfig_js_1.CSRF_CONFIG.RETRY_COUNT, retryDelay = csrfConfig_js_1.CSRF_CONFIG.RETRY_DELAY,
984
+ async fetchCsrfToken(_url, retryCount = csrfConfig_js_1.CSRF_CONFIG.RETRY_COUNT, retryDelay = csrfConfig_js_1.CSRF_CONFIG.RETRY_DELAY,
1271
985
  /** Fences the response effects; omitted during connect(), which has no lease. */
1272
986
  generation) {
1273
- // Try primary endpoint first, then fallback for older systems
1274
- const baseUrl = url.includes('/sap/bc/adt/')
1275
- ? url.split('/sap/bc/adt')[0]
1276
- : url.endsWith('/')
1277
- ? url.slice(0, -1)
1278
- : url;
1279
- let endpoints;
1280
- // If the URL already contains a specific endpoint, use only that
1281
- if (url.includes(csrfConfig_js_1.CSRF_CONFIG.ENDPOINT)) {
1282
- endpoints = [url];
1283
- }
1284
- else if (url.includes(csrfConfig_js_1.CSRF_CONFIG.FALLBACK_ENDPOINT)) {
1285
- endpoints = [url];
1286
- }
1287
- else {
1288
- endpoints = [
1289
- `${baseUrl}${csrfConfig_js_1.CSRF_CONFIG.ENDPOINT}`,
1290
- `${baseUrl}${csrfConfig_js_1.CSRF_CONFIG.FALLBACK_ENDPOINT}`,
1291
- ];
1292
- }
1293
- let lastError;
1294
- for (const csrfUrl of endpoints) {
1295
- try {
1296
- return await this.fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay, generation);
1297
- }
1298
- catch (error) {
1299
- // Third layer with a catch on this path, and the last one that could
1300
- // bury a verdict: falling through to the fallback endpoint would open
1301
- // ANOTHER session, and by then the teardown has cleared the fingerprint
1302
- // so the new one reads as `established` and the loss disappears.
1303
- if (this.isSessionVerdict(error)) {
1304
- throw error;
1305
- }
1306
- lastError = error instanceof Error ? error : new Error(String(error));
1307
- this.logger?.debug(`CSRF token not available from ${csrfUrl}, trying next endpoint...`);
1308
- }
1309
- }
1310
- // All endpoints exhausted
1311
- throw lastError ?? new Error('CSRF token fetch failed unexpectedly');
1312
- }
1313
- /**
1314
- * Fetch CSRF token from a specific endpoint with retries
1315
- */
1316
- async fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay, generation) {
1317
- this.logger?.debug(`Fetching CSRF token from: ${csrfUrl}`);
1318
- for (let attempt = 0; attempt <= retryCount; attempt++) {
1319
- try {
1320
- if (attempt > 0) {
1321
- this.logger?.debug(`Retry attempt ${attempt}/${retryCount} for CSRF token`);
1322
- }
1323
- const authHeaders = await this.getAuthHeaders();
1324
- const headers = {
1325
- ...authHeaders,
1326
- ...csrfConfig_js_1.CSRF_CONFIG.REQUIRED_HEADERS,
1327
- };
1328
- // The token fetch belongs to the same ADT conversation as every other
1329
- // request this connection makes. Without the connection id the server
1330
- // sees a caller that merely happens to present our cookies, so a fetch
1331
- // issued while a lock is held reads as a stranger reaching into the
1332
- // session. makeAdtRequest sends this header for all session types; this
1333
- // path must not be the exception.
1334
- if (this.sessionId) {
1335
- headers['sap-adt-connection-id'] = this.sessionId;
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());
1340
- // Always add cookies if available - they are needed for session continuity
1341
- // Even on first attempt, if we have cookies from previous session or error response, use them
1342
- if (this.cookies) {
1343
- headers.Cookie = (0, cookies_js_1.mergeCookieHeaders)(headers.Cookie, this.cookies);
1344
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Adding cookies to CSRF token request (attempt ${attempt + 1}, first 100 chars): ${this.cookies.substring(0, 100)}...`);
1345
- }
1346
- else {
1347
- this.logger?.debug(`[DEBUG] BaseAbapConnection - No cookies available for CSRF token request (will get fresh cookies from response)`);
1348
- }
1349
- // Log request details for debugging (only if debug logging is enabled)
1350
- this.logger?.debug(`[DEBUG] CSRF Token Request: url=${csrfUrl}, method=GET, hasAuth=${!!authHeaders.Authorization}, hasClient=${!!authHeaders['X-SAP-Client']}, hasCookies=${!!headers.Cookie}, attempt=${attempt + 1}`);
1351
- const response = await this.getAxiosInstance()({
1352
- method: 'GET',
1353
- url: csrfUrl,
1354
- headers,
1355
- timeout: (0, timeouts_js_1.getTimeout)('csrf'),
1356
- });
1357
- this.observeResponse(response.headers, generation);
1358
- const token = response.headers['x-csrf-token'];
1359
- if (!token) {
1360
- this.logger?.error('No CSRF token in response headers', {
1361
- headers: response.headers,
1362
- status: response.status,
1363
- });
1364
- if (attempt < retryCount) {
1365
- await new Promise((resolve) => setTimeout(resolve, retryDelay));
1366
- continue;
1367
- }
1368
- throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
1369
- }
1370
- if (response.headers['set-cookie']) {
1371
- this.observeResponse(response.headers, generation);
1372
- if (this.cookies) {
1373
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Cookies received from CSRF response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
1374
- this.logger?.debug('Cookies extracted from response', {
1375
- cookieLength: this.cookies.length,
1376
- });
1377
- }
1378
- }
1379
- this.logger?.debug('CSRF token successfully obtained');
1380
- return token;
1381
- }
1382
- catch (error) {
1383
- // A session verdict is not a failed token fetch and must not be
1384
- // retried into silence: the retry would observe the SAME new session,
1385
- // read it as `unchanged`, and the replacement this raised would be gone
1386
- // for good. It leaves immediately, past the loop and past the caller's
1387
- // recovery.
1388
- if (this.isSessionVerdict(error)) {
1389
- throw error;
1390
- }
1391
- if (error instanceof axios_1.AxiosError) {
1392
- // Always try to extract cookies from error response, even on 401
1393
- // This ensures cookies are available for subsequent requests
1394
- if (error.response?.headers) {
1395
- this.observeResponse(error.response.headers, generation);
1396
- if (this.cookies) {
1397
- this.logger?.debug('Cookies extracted from error response', {
1398
- status: error.response.status,
1399
- cookieLength: this.cookies.length,
1400
- });
1401
- }
1402
- }
1403
- this.logger?.error(`CSRF token error: ${error.message}`, {
1404
- url: csrfUrl,
1405
- status: error.response?.status,
1406
- attempt: attempt + 1,
1407
- maxAttempts: retryCount + 1,
1408
- });
1409
- if (error.response?.status === 405 &&
1410
- error.response?.headers['x-csrf-token']) {
1411
- this.logger?.debug('CSRF: SAP returned 405 (Method Not Allowed) — not critical, token found in header');
1412
- const token = error.response.headers['x-csrf-token'];
1413
- if (token) {
1414
- this.observeResponse(error.response.headers, generation);
1415
- return token;
1416
- }
1417
- }
1418
- if (error.response?.headers['x-csrf-token']) {
1419
- this.logger?.debug(`Got CSRF token despite error (status: ${error.response?.status})`);
1420
- const token = error.response.headers['x-csrf-token'];
1421
- this.observeResponse(error.response.headers, generation);
1422
- return token;
1423
- }
1424
- if (error.response) {
1425
- this.logger?.error('CSRF error details', {
1426
- status: error.response.status,
1427
- statusText: error.response.statusText,
1428
- headers: Object.keys(error.response.headers),
1429
- data: typeof error.response.data === 'string'
1430
- ? error.response.data.slice(0, 200)
1431
- : JSON.stringify(error.response.data).slice(0, 200),
1432
- });
1433
- }
1434
- else if (error.request) {
1435
- this.logger?.error('CSRF request error - no response received', {
1436
- request: error.request.path,
1437
- });
1438
- }
1439
- }
1440
- else {
1441
- this.logger?.error('CSRF non-axios error', {
1442
- error: error instanceof Error ? error.message : String(error),
1443
- });
1444
- }
1445
- if (attempt < retryCount) {
1446
- await new Promise((resolve) => setTimeout(resolve, retryDelay));
1447
- continue;
1448
- }
1449
- // Preserve original error information, especially AxiosError with response
1450
- if (error instanceof axios_1.AxiosError && error.response) {
1451
- // Re-throw the original AxiosError to preserve response information
1452
- throw error;
1453
- }
1454
- throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.FETCH_FAILED(retryCount + 1, error instanceof Error ? error.message : String(error)));
1455
- }
1456
- }
1457
- throw new Error('CSRF token fetch failed unexpectedly');
987
+ // Dropped first, because this is only ever reached to REPLACE one: the
988
+ // establishment is idempotent and would hand back the very token the
989
+ // caller has just been told is stale.
990
+ this.transport.adoptCsrfToken(null);
991
+ await this.transport.establish({
992
+ baseUrl: this.baseUrl,
993
+ authHeaders: () => this.getAuthHeaders(),
994
+ extraHeaders: { 'sap-adt-connection-id': this.sessionId ?? '' },
995
+ observe: (headers) => this.observeResponse(headers, generation),
996
+ retries: retryCount,
997
+ retryDelayMs: retryDelay,
998
+ timeoutMs: (0, timeouts_js_1.getTimeout)('csrf'),
999
+ isFatal: (error) => this.isSessionVerdict(error),
1000
+ });
1001
+ const token = this.transport.csrfToken();
1002
+ if (!token)
1003
+ throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
1004
+ return token;
1458
1005
  }
1459
- /**
1460
- * Get CSRF token (protected for use by subclasses)
1461
- */
1462
1006
  getCsrfToken() {
1463
- return this.csrfToken;
1007
+ return this.transport.csrfToken();
1464
1008
  }
1465
1009
  /**
1466
1010
  * Set CSRF token (protected for use by subclasses)
1467
1011
  */
1468
1012
  setCsrfToken(token) {
1469
- this.csrfToken = token;
1013
+ // A credential that did the exchange itself hands the token to the wire
1014
+ // that will present it.
1015
+ this.transport.adoptCsrfToken(token);
1470
1016
  }
1471
1017
  /**
1472
1018
  * Get cookies (protected for use by subclasses)
1473
1019
  */
1474
1020
  getCookies() {
1475
- return this.cookies;
1476
- }
1477
- setInitialCookies(cookies) {
1478
- this.cookies = cookies;
1021
+ return this.transport.cookies();
1479
1022
  }
1480
1023
  /**
1481
- * Folds a response's cookies into the jar and classifies what that means for
1482
- * the session identity. Returns the classification rather than acting on it:
1483
- * cookie parsing stays free of policy, and no exception fires in the middle
1484
- * of a state update. The caller decides.
1024
+ * Seed the wire with cookies the caller already holds — a SAML session, which
1025
+ * IS the credential rather than something a logon call earns.
1026
+ *
1027
+ * Handed over as a response would deliver them, because the wire owns the jar
1028
+ * and how it stores them is its business, not this class's.
1485
1029
  */
1486
- updateCookiesFromResponse(headers) {
1487
- if (!headers) {
1488
- return 'unchanged';
1489
- }
1490
- const setCookie = headers['set-cookie'];
1491
- if (!setCookie) {
1492
- return 'unchanged';
1493
- }
1494
- const cookiesArray = Array.isArray(setCookie) ? setCookie : [setCookie];
1495
- for (const entry of cookiesArray) {
1496
- if (typeof entry !== 'string') {
1497
- continue;
1498
- }
1499
- const [nameValue] = entry.split(';');
1500
- if (!nameValue) {
1501
- continue;
1502
- }
1503
- const [name, ...rest] = nameValue.split('=');
1504
- if (!name) {
1505
- continue;
1506
- }
1507
- const trimmedName = name.trim();
1508
- const trimmedValue = rest.join('=').trim();
1509
- if (!trimmedName) {
1510
- continue;
1511
- }
1512
- this.cookieStore.set(trimmedName, trimmedValue);
1513
- }
1514
- // Enforce configured SAP client in sap-usercontext cookie.
1515
- // SAP may return sap-usercontext=sap-client=<default_client> based on system
1516
- // default rather than the X-SAP-Client header value, causing requests to be
1517
- // routed to the wrong client (e.g. a read-only client → 403 on write operations).
1518
- if (this.config.client) {
1519
- this.cookieStore.set('sap-usercontext', `sap-client=${this.config.client}`);
1520
- }
1521
- if (this.cookieStore.size === 0) {
1522
- return 'unchanged';
1523
- }
1524
- const combined = Array.from(this.cookieStore.entries())
1525
- .map(([name, value]) => (value ? `${name}=${value}` : name))
1526
- .join('; ');
1527
- if (!combined) {
1528
- return 'unchanged';
1529
- }
1530
- this.cookies = combined;
1531
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Updated cookies from response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
1532
- return this.lifecycle.observe(this.sessionFingerprint());
1030
+ setInitialCookies(cookies) {
1031
+ this.transport.ingest({
1032
+ 'set-cookie': cookies.split(';').map((entry) => entry.trim()),
1033
+ });
1533
1034
  }
1534
1035
  /**
1535
- * Subclasses override to inject extra https.Agent options (e.g. mTLS cert/key/pfx).
1536
- * The returned options are merged with the base options (rejectUnauthorized).
1036
+ * Subclasses override to inject extra https.Agent options (e.g. mTLS
1037
+ * cert/key/pfx). The returned options are merged with the base options
1038
+ * (rejectUnauthorized).
1537
1039
  */
1538
1040
  getHttpsAgentOptions() {
1539
1041
  return {};
1540
1042
  }
1541
- getAxiosInstance() {
1542
- if (!this.axiosInstance) {
1543
- const rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED === '1' ||
1544
- (process.env.TLS_REJECT_UNAUTHORIZED === '1' &&
1545
- process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0');
1546
- this.logger?.debug(`TLS configuration: rejectUnauthorized=${rejectUnauthorized}`);
1547
- this.axiosInstance = axios_1.default.create({
1548
- httpsAgent: new node_https_1.Agent({
1549
- rejectUnauthorized,
1550
- ...this.getHttpsAgentOptions(),
1551
- }),
1552
- });
1553
- }
1554
- return this.axiosInstance;
1555
- }
1556
- async ensureFreshCsrfToken(requestUrl) {
1557
- // If we already have a CSRF token, reuse it to keep the same SAP session
1558
- // SAP ties the lock handle to the HTTP session (SAP_SESSIONID cookie)
1559
- if (this.csrfToken) {
1560
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Reusing existing CSRF token to maintain session`);
1561
- return;
1562
- }
1563
- try {
1564
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Fetching NEW CSRF token (will create new SAP session)`);
1565
- this.csrfToken = await this.fetchCsrfToken(requestUrl);
1566
- }
1567
- catch (error) {
1568
- // fetchCsrfToken handles auth errors
1569
- // Just re-throw the error with minimal logging to avoid duplicate error messages
1570
- const errorMsg = error instanceof Error
1571
- ? error.message
1572
- : csrfConfig_js_1.CSRF_ERROR_MESSAGES.REQUIRED_FOR_MUTATION;
1573
- // Only log at DEBUG level to avoid duplicate error messages
1574
- // (fetchCsrfToken already logged the error at ERROR level if auth failed)
1575
- this.logger?.debug(`[DEBUG] BaseAbapConnection - ensureFreshCsrfToken failed: ${errorMsg}`);
1576
- throw error;
1577
- }
1043
+ /**
1044
+ * Make sure the wire is ready to carry a mutation.
1045
+ *
1046
+ * What ready MEANS is the wire's: HTTP holds a CSRF token and returns at once
1047
+ * when it already has one; an RFC conversation has nothing to earn and does
1048
+ * nothing. Demanding a token back was an HTTP assumption, and over RFC it
1049
+ * raised `No CSRF token in response headers` before every write — swallowed
1050
+ * by the caller, but logged as an error and repeated on the next one.
1051
+ */
1052
+ async ensureWireReady() {
1053
+ await this.transport.establish({
1054
+ baseUrl: this.baseUrl,
1055
+ authHeaders: () => this.getAuthHeaders(),
1056
+ extraHeaders: { 'sap-adt-connection-id': this.sessionId ?? '' },
1057
+ observe: (headers) => this.observeResponse(headers),
1058
+ isFatal: (error) => this.isSessionVerdict(error),
1059
+ });
1578
1060
  }
1579
1061
  /**
1580
1062
  * Clear SAP-side session state when SAP rejects the cached CSRF token + session
@@ -1587,15 +1069,12 @@ class AbstractAbapConnection {
1587
1069
  */
1588
1070
  invalidateSession() {
1589
1071
  this.setCsrfToken(null);
1590
- this.cookies = null;
1591
- this.cookieStore.clear();
1072
+ // The wire's state described THAT session: the cookies, the fingerprint
1073
+ // taken from them, and the application server it lived on. Sending the
1074
+ // server name again would pin the next connect — preflight included — to a
1075
+ // server whose session is gone.
1076
+ this.transport.forgetSession();
1592
1077
  // 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;
1599
1078
  // And the tracked identity, because WE discarded the session. Without this
1600
1079
  // the cookie that arrives next reads as a foreign replacement — and since a
1601
1080
  // replacement is now always fatal, our own deliberate re-authentication
@@ -1604,28 +1083,27 @@ class AbstractAbapConnection {
1604
1083
  // is not a session taken from under us.
1605
1084
  this.lifecycle.forgetIdentity();
1606
1085
  }
1607
- shouldRetryCsrf(error) {
1608
- if (!(error instanceof axios_1.AxiosError)) {
1086
+ shouldRetryCsrf(error, method) {
1087
+ const refusal = (0, IAdtTransport_js_1.refusalOf)(error);
1088
+ if (!refusal) {
1609
1089
  return false;
1610
1090
  }
1611
- const responseData = error.response?.data;
1091
+ const responseData = refusal.data;
1612
1092
  const responseText = typeof responseData === 'string'
1613
1093
  ? responseData
1614
1094
  : JSON.stringify(responseData || '');
1615
- // Don't retry for JWT auth - refresh logic will handle it
1616
- if (this.config.authType === 'jwt') {
1617
- return false;
1618
- }
1619
1095
  // Retry on 403 with CSRF message, or if response mentions CSRF token
1620
1096
  // Also retry on 401 for POST/PUT/DELETE if we don't have CSRF token yet (might need to get cookies first)
1621
- const method = error.config?.method?.toUpperCase();
1622
- const isPostPutDelete = method && ['POST', 'PUT', 'DELETE'].includes(method);
1623
- const needsCsrfToken = !!isPostPutDelete && !this.csrfToken;
1624
- return ((!!error.response &&
1625
- error.response.status === 403 &&
1626
- responseText.includes('CSRF')) ||
1097
+ // Handed in rather than read off `error.config`, which is axios's own
1098
+ // record of the request and does not exist on another wire's refusal. The
1099
+ // caller already normalised the method; asking the error for it was asking
1100
+ // the HTTP client.
1101
+ const normalized = method?.toUpperCase();
1102
+ const isPostPutDelete = normalized && ['POST', 'PUT', 'DELETE'].includes(normalized);
1103
+ const needsCsrfToken = !!isPostPutDelete && !this.transport.csrfToken();
1104
+ return ((refusal.status === 403 && responseText.includes('CSRF')) ||
1627
1105
  responseText.includes('CSRF token') ||
1628
- (needsCsrfToken && error.response?.status === 401));
1106
+ (needsCsrfToken && refusal.status === 401));
1629
1107
  }
1630
1108
  }
1631
1109
  exports.AbstractAbapConnection = AbstractAbapConnection;