@mcp-abap-adt/connection 4.0.0 → 6.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 (87) hide show
  1. package/CHANGELOG.md +407 -1
  2. package/README.md +239 -53
  3. package/dist/auth/providers.d.ts +124 -0
  4. package/dist/auth/providers.d.ts.map +1 -0
  5. package/dist/auth/providers.js +183 -0
  6. package/dist/connection/AbstractAbapConnection.d.ts +209 -57
  7. package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
  8. package/dist/connection/AbstractAbapConnection.js +459 -457
  9. package/dist/connection/AdtCloudConnector.d.ts +34 -0
  10. package/dist/connection/AdtCloudConnector.d.ts.map +1 -0
  11. package/dist/connection/AdtCloudConnector.js +27 -0
  12. package/dist/connection/AdtOnPremConnector.d.ts +43 -0
  13. package/dist/connection/AdtOnPremConnector.d.ts.map +1 -0
  14. package/dist/connection/AdtOnPremConnector.js +28 -0
  15. package/dist/connection/CloudHttpTransport.d.ts +37 -0
  16. package/dist/connection/CloudHttpTransport.d.ts.map +1 -0
  17. package/dist/connection/CloudHttpTransport.js +145 -0
  18. package/dist/connection/CredentialAbapConnection.d.ts +55 -0
  19. package/dist/connection/CredentialAbapConnection.d.ts.map +1 -0
  20. package/dist/connection/CredentialAbapConnection.js +128 -0
  21. package/dist/connection/HttpTransport.d.ts +178 -0
  22. package/dist/connection/HttpTransport.d.ts.map +1 -0
  23. package/dist/connection/HttpTransport.js +402 -0
  24. package/dist/connection/IAdtTransport.d.ts +232 -0
  25. package/dist/connection/IAdtTransport.d.ts.map +1 -0
  26. package/dist/connection/IAdtTransport.js +28 -0
  27. package/dist/connection/LegacyOnPremHttpTransport.d.ts +40 -0
  28. package/dist/connection/LegacyOnPremHttpTransport.d.ts.map +1 -0
  29. package/dist/connection/LegacyOnPremHttpTransport.js +57 -0
  30. package/dist/connection/OnPremHttpTransport.d.ts +45 -0
  31. package/dist/connection/OnPremHttpTransport.d.ts.map +1 -0
  32. package/dist/connection/OnPremHttpTransport.js +91 -0
  33. package/dist/connection/RfcTransport.d.ts +89 -0
  34. package/dist/connection/RfcTransport.d.ts.map +1 -0
  35. package/dist/connection/RfcTransport.js +256 -0
  36. package/dist/connection/rfcConversation.d.ts +44 -0
  37. package/dist/connection/rfcConversation.d.ts.map +1 -0
  38. package/dist/connection/rfcConversation.js +71 -0
  39. package/dist/index.d.ts +10 -7
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +30 -18
  42. package/dist/session/SessionLifecycle.d.ts +17 -2
  43. package/dist/session/SessionLifecycle.d.ts.map +1 -1
  44. package/dist/session/SessionLifecycle.js +17 -2
  45. package/dist/utils/cookies.d.ts +12 -0
  46. package/dist/utils/cookies.d.ts.map +1 -0
  47. package/dist/utils/cookies.js +24 -0
  48. package/dist/utils/timeouts.d.ts +6 -3
  49. package/dist/utils/timeouts.d.ts.map +1 -1
  50. package/dist/utils/timeouts.js +6 -3
  51. package/docs/INDEX.md +5 -2
  52. package/docs/INSTALLATION.md +28 -6
  53. package/docs/JWT_AUTH_TOOLS.md +20 -4
  54. package/docs/MIGRATION-2.0.md +1 -1
  55. package/docs/MIGRATION-5.0.md +116 -0
  56. package/docs/MIGRATION-6.0.md +359 -0
  57. package/docs/SCOPE.md +1 -1
  58. package/docs/STATEFUL_SESSION_GUIDE.md +155 -20
  59. package/docs/USAGE.md +322 -111
  60. package/examples/basic-connection.js +15 -3
  61. package/examples/jwt-with-token-refresh.js +15 -7
  62. package/examples/saml-connection.js +15 -2
  63. package/package.json +12 -10
  64. package/dist/__tests__/helpers/session.d.ts +0 -15
  65. package/dist/__tests__/helpers/session.d.ts.map +0 -1
  66. package/dist/__tests__/helpers/session.js +0 -19
  67. package/dist/connection/BaseAbapConnection.d.ts +0 -23
  68. package/dist/connection/BaseAbapConnection.d.ts.map +0 -1
  69. package/dist/connection/BaseAbapConnection.js +0 -75
  70. package/dist/connection/CertificateAbapConnection.d.ts +0 -25
  71. package/dist/connection/CertificateAbapConnection.d.ts.map +0 -1
  72. package/dist/connection/CertificateAbapConnection.js +0 -79
  73. package/dist/connection/JwtAbapConnection.d.ts +0 -115
  74. package/dist/connection/JwtAbapConnection.d.ts.map +0 -1
  75. package/dist/connection/JwtAbapConnection.js +0 -358
  76. package/dist/connection/KerberosAbapConnection.d.ts +0 -24
  77. package/dist/connection/KerberosAbapConnection.d.ts.map +0 -1
  78. package/dist/connection/KerberosAbapConnection.js +0 -120
  79. package/dist/connection/RfcAbapConnection.d.ts +0 -49
  80. package/dist/connection/RfcAbapConnection.d.ts.map +0 -1
  81. package/dist/connection/RfcAbapConnection.js +0 -331
  82. package/dist/connection/SamlAbapConnection.d.ts +0 -25
  83. package/dist/connection/SamlAbapConnection.d.ts.map +0 -1
  84. package/dist/connection/SamlAbapConnection.js +0 -75
  85. package/dist/connection/connectionFactory.d.ts +0 -9
  86. package/dist/connection/connectionFactory.d.ts.map +0 -1
  87. package/dist/connection/connectionFactory.js +0 -32
@@ -1,54 +1,37 @@
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
6
  const SessionLifecycle_js_1 = require("../session/SessionLifecycle.js");
42
7
  const timeouts_js_1 = require("../utils/timeouts.js");
43
8
  const csrfConfig_js_1 = require("./csrfConfig.js");
9
+ const IAdtTransport_js_1 = require("./IAdtTransport.js");
44
10
  /**
45
11
  * Declares the capabilities explicitly rather than satisfying them by accident.
46
12
  * `AbapConnection` is the base contract every transport honours; these two are
47
13
  * the HTTP session's own, and naming them means a signature that drifts from
48
14
  * the published contract fails to compile here instead of at the consumer.
15
+ *
16
+ * **This gives the consumer instruments; it does not decide for it.** `connect()`
17
+ * opens one session and `disconnect()` closes it. How many connections to hold,
18
+ * how long to hold them and when to let go stays with the caller — there are no
19
+ * thresholds here, no pooling and no eviction, because none of that is knowable
20
+ * from inside a single connection. A session this one did not open is not its
21
+ * business: the session limit is per user and the pool is shared, so a SAP GUI
22
+ * logon of the same user sits in the same list.
23
+ *
24
+ * **Nothing the server decides is treated as something to count on.** Whether it
25
+ * issues a session cookie, whether it still holds a session it issued, how many
26
+ * it will tolerate, how fast it answers a logoff — all of that is its own and
27
+ * may differ by system and release. So each is observed and reported, never
28
+ * relied upon: the logoff is best effort under a bound the caller sets, a
29
+ * missing session cookie is a warning rather than a rule, and no code here
30
+ * counts sessions or predicts the next answer from the last one.
49
31
  */
50
32
  class AbstractAbapConnection {
51
33
  config;
34
+ transport;
52
35
  logger;
53
36
  /**
54
37
  * Owns session state, admission and teardown ordering. Composed rather than
@@ -56,30 +39,79 @@ class AbstractAbapConnection {
56
39
  * transports share this unit instead of a base class.
57
40
  */
58
41
  lifecycle = new SessionLifecycle_js_1.SessionLifecycle();
59
- axiosInstance = null;
60
- csrfToken = null;
61
- cookies = null;
62
- cookieStore = new Map();
63
42
  baseUrl;
64
43
  sessionId = null;
65
44
  sessionMode = 'stateless';
66
- skipSessionType;
67
45
  /**
68
46
  * When true, requests are treated as part of an uninterruptible critical
69
47
  * section (e.g. a lock → modify → unlock chain). In this state a short
70
- * per-request timeout must NOT abort the request mid-flight, because
71
- * aborting the socket drops the stateful ADT session and orphans the lock
72
- * 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
73
53
  * raises the effective timeout to CRITICAL_SECTION_TIMEOUT (a large ceiling)
74
54
  * so the request runs to completion instead of being interrupted.
75
55
  */
76
56
  inCriticalSection = false;
77
57
  /** Reference count for nested beginCriticalSection()/endCriticalSection() pairs. */
78
58
  criticalSectionDepth = 0;
79
- constructor(config, logger, sessionId, options) {
59
+ /** The default release deadline, validated once at construction. */
60
+ /**
61
+ * The logoff for the session this connection last held, while it is on its
62
+ * way. One, because a connection holds one session: `connect()` opens it and
63
+ * `disconnect()` closes it, and how many connections to run is the caller's
64
+ * business, not something to be tracked here.
65
+ *
66
+ * Carries the session id, not just the promise, so a release still in flight
67
+ * for an EARLIER session is recognised as not being this one's — reusing it
68
+ * was what left the second session of a reconnect never released at all.
69
+ *
70
+ * The id is the `SAP_SESSIONID` value — the ABAP session, the one locks are
71
+ * bound to — never the cookie header: that header also carries `sap-XSRF_*`,
72
+ * which rotates within one and the same session, so comparing headers made a
73
+ * session stop recognising itself after a token refresh.
74
+ */
75
+ /**
76
+ * How this server opens and gives back a session, decided by asking it rather
77
+ * than by guessing which system it is. Set at establishment; until then the
78
+ * on-prem mechanism, which is the one that needs no resource.
79
+ */
80
+ /**
81
+ * The application server this session lives on, as the server named it.
82
+ *
83
+ * A session belongs to ONE application server. On a multi-node system a
84
+ * request that lands on another gets another session — and a lock held on the
85
+ * first is then dead through nobody's fault and no inactivity. Eclipse pins
86
+ * itself with these headers; without them every request is a fresh throw of
87
+ * the dice.
88
+ *
89
+ * Learned from `sap-adt-saplb` on a response, sent back as `saplb`. Cleared
90
+ * with the rest of the session state: it names a server for a session that no
91
+ * longer exists.
92
+ */
93
+ /**
94
+ * Whether the preflight opened a session of its own.
95
+ *
96
+ * Distinct from "there are cookies": a failed establishment often leaves a
97
+ * cookie from the 401 that rejected it, and that is debris, not a session.
98
+ * Only a preflight answered with a session address opened one, and only that
99
+ * is worth saying goodbye to when establishment then fails.
100
+ */
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) {
80
112
  this.config = config;
113
+ this.transport = transport;
81
114
  this.logger = logger;
82
- this.skipSessionType = options?.skipSessionType ?? false;
83
115
  // Generate sessionId (used for sap-adt-connection-id header)
84
116
  this.sessionId = sessionId || (0, node_crypto_1.randomUUID)();
85
117
  // Initialize baseUrl from config (required, will throw if invalid)
@@ -98,16 +130,12 @@ class AbstractAbapConnection {
98
130
  * - stateful: SAP maintains session state between requests (locks, transactions)
99
131
  * - stateless: Each request is independent
100
132
  *
101
- * When skipSessionType is enabled (via constructor options), this is a no-op:
102
- * the x-sap-adt-sessiontype header will never be sent. This is needed for
103
- * older BASIS versions (e.g. 7.40) where the stateful header causes locks
104
- * to be stored in ABAP session memory instead of the global enqueue table,
105
- * 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.
106
137
  */
107
138
  setSessionType(type) {
108
- if (this.skipSessionType) {
109
- return;
110
- }
111
139
  this.sessionMode = type;
112
140
  this.logger?.debug(`Session type set to: ${type}`, {
113
141
  sessionId: this.sessionId?.substring(0, 8),
@@ -126,9 +154,11 @@ class AbstractAbapConnection {
126
154
  * in a finally, AFTER unlocking). While in a critical section, a short
127
155
  * per-request timeout is not applied — makeAdtRequest uses a large ceiling
128
156
  * (CRITICAL_SECTION_TIMEOUT, env SAP_TIMEOUT_CRITICAL) instead — so a slow
129
- * PUT/activate/unlock is not aborted mid-flight. Aborting mid-flight tears
130
- * down the socket, which drops the stateful ADT session and orphans the
131
- * 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.
132
162
  *
133
163
  * Nesting is reference-counted so nested begin/end pairs are safe.
134
164
  */
@@ -174,6 +204,27 @@ class AbstractAbapConnection {
174
204
  getConfig() {
175
205
  return this.config;
176
206
  }
207
+ /**
208
+ * Gets the credential ready before anything is sent.
209
+ *
210
+ * A no-op for the auth types whose credential is already in hand — basic
211
+ * builds a header from the configuration, JWT carries a token it was given.
212
+ * It exists for the ones that have to fetch or load theirs, because the
213
+ * preflight now runs BEFORE `establishSession()` and needs a credential to
214
+ * go out with: a certificate connection reads its material there, and
215
+ * without this the preflight throws `certificate material not loaded` while
216
+ * assembling the transport — before a single request is made, on every
217
+ * system, cloud or on-prem.
218
+ *
219
+ * Must be idempotent: `establishSession()` may prepare the same credential
220
+ * again, and does.
221
+ *
222
+ * Kerberos deliberately does NOT implement it. Minting the SPNEGO token this
223
+ * early changes when the exchange happens, and that connection is not
224
+ * production-tested — its preflight fails the way it already did, is caught
225
+ * inside the strategy, and the connection falls back to ICF as before.
226
+ */
227
+ async prepareCredential() { }
177
228
  /**
178
229
  * Establishes the session, once, under the lifecycle.
179
230
  *
@@ -193,29 +244,73 @@ class AbstractAbapConnection {
193
244
  await this.lifecycle.transition('connect', async () => {
194
245
  if (this.lifecycle.connected)
195
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());
196
252
  await this.establishAndCommit(baselineEpoch);
197
253
  });
198
254
  }
199
255
  /**
200
256
  * Tears the session down. Never throws, and always settles.
201
257
  *
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.
258
+ * Tells the server the session is no longer needed, then clears the local
259
+ * state. **When the server actually reclaims it is the server's business** —
260
+ * possibly not until the next connect asks it for one — and nothing here
261
+ * waits for that or depends on it. What matters is that the session stops
262
+ * being counted against the user, which dropping the cookie alone does not
263
+ * achieve: the server keeps it until its own timeout, so a process that
264
+ * connects repeatedly leaves one behind every time. Measured on S/4HANA on-prem, 25 connects in a row: with the logoff,
265
+ * 24-25 of them were given a session; without it, 2. A connection that gets
266
+ * no session still answers `200` to a LOCK and hands back a handle the next
267
+ * request cannot use, so the leak surfaces as a half-written object rather
268
+ * than as anything about sessions.
269
+ *
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.
206
276
  *
207
- * Requests already in flight run to completion untouched. Generation fencing
208
- * (see `SessionLifecycle.isCurrent`) keeps their results from reaching this
209
- * connection afterwards.
277
+ * **Requests already in flight are not waited for, and the logoff ends the
278
+ * session they are running on** — so they will start failing against a
279
+ * session that no longer exists. That is the caller having asked to
280
+ * disconnect, not a race, and it is a change from the version that only
281
+ * dropped the cookie. Generation fencing (see `SessionLifecycle.isCurrent`)
282
+ * keeps their results from reaching this connection either way.
210
283
  *
211
- * Sends no ADT session-close — see the design's D2.
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.
212
289
  */
213
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.
214
294
  // Synchronous, at the call: admission shuts and the generation moves before
215
295
  // anything is queued, so a caller who has asked to disconnect cannot have
216
296
  // requests still going through while this waits its turn.
217
297
  this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: false });
298
+ // Captured before the transition and before anything is cleared: a
299
+ // concurrent disconnect JOINS the transition and its callback is never run
300
+ // for the joiner, so a joiner would otherwise learn nothing about what it
301
+ // asked to release.
302
+ const session = this.getSessionIdentity();
218
303
  await this.lifecycle.transition('disconnect', async () => {
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);
219
314
  this.clearSessionState();
220
315
  this.lifecycle.markDisconnected();
221
316
  });
@@ -231,22 +326,11 @@ class AbstractAbapConnection {
231
326
  * session cookie. Use {@link isConnected} for connection state.
232
327
  *
233
328
  * 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.
329
+ * learned; only a CHANGED value means the session we had is gone.
235
330
  */
236
331
  getSessionIdentity() {
237
332
  return this.lifecycle.identity;
238
333
  }
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
334
  /**
251
335
  * Re-establishes the session for a request that is recovering from a
252
336
  * credential renewal, then lets that request retry.
@@ -278,6 +362,36 @@ class AbstractAbapConnection {
278
362
  * Shared by connect() and recoverSession() rather than written twice —
279
363
  * the two drifted apart once already, and a third caller would drift again.
280
364
  */
365
+ /**
366
+ * What a session strategy is given: enough to make one request and to prove
367
+ * the session is ours, and nothing else. It cannot reach session state, so a
368
+ * strategy can neither mark this connection connected nor tear it down.
369
+ */
370
+ /**
371
+ * Ask the server to open a session before the establishing call needs one.
372
+ *
373
+ * The strategy is chosen by what the server publishes, not by which system we
374
+ * believe it to be: ABAP Cloud offers a session resource and issues
375
+ * `SAP_SESSIONID` to whoever asks for one — with `x-sap-security-session:
376
+ * create` — while on-prem has no such resource and its session arrives with
377
+ * the establishing request. Believing cloud simply "issues no SAP_SESSIONID"
378
+ * is what happens when nobody asks.
379
+ */
380
+ /**
381
+ * What a wire needs from this connection to get a session, or give one back.
382
+ *
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.
386
+ */
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
+ };
394
+ }
281
395
  async establishAndCommit(baselineEpoch) {
282
396
  if (this.lifecycle.teardownEpoch !== baselineEpoch) {
283
397
  throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested for this connection');
@@ -289,6 +403,20 @@ class AbstractAbapConnection {
289
403
  // makes the new fingerprint `established`, which is what it is.
290
404
  this.lifecycle.forgetIdentity();
291
405
  try {
406
+ // First, because the preflight below has to be able to authenticate: the
407
+ // credential of a certificate or Kerberos connection is not in hand until
408
+ // it is loaded or minted, and assembling a request without it throws.
409
+ await this.prepareCredential();
410
+ // Before the establishing call, because on a system that has one this is
411
+ // what creates the session the rest of the connection runs in — and the
412
+ // cookies it sets are the ones the establishing call must carry.
413
+ // Forgotten again, because the open was OURS. Establishment is now two
414
+ // requests where it used to be one, and the identity policy answers "did
415
+ // the server move us to a different session while we were working" — a
416
+ // question that has no meaning between two calls we make ourselves to set
417
+ // this session up. The identity that counts is taken at the end, from the
418
+ // cookies we finish with.
419
+ this.lifecycle.forgetIdentity();
292
420
  await this.establishSession();
293
421
  }
294
422
  catch (error) {
@@ -302,6 +430,26 @@ class AbstractAbapConnection {
302
430
  // Safe to clear here, unlike the abandonment path below: establishSession()
303
431
  // threw, so no session was published, and admission requires a connected
304
432
  // lifecycle — nothing can be in flight over what this drops.
433
+ //
434
+ // But say goodbye FIRST. The preflight may already have opened a session
435
+ // — on cloud it does, and the SAP_SESSIONID is in the cookies about to be
436
+ // dropped — and establishing can still fail after it, on a credential the
437
+ // preflight never used. Clearing without telling the server would leak
438
+ // exactly the session this release exists to stop leaking, and leave it
439
+ // unreachable: the connection is not connected, so disconnect() sends
440
+ // nothing, and the cookie that was the only permission to close it is
441
+ // gone. The transport is a snapshot, so this survives the clearing that
442
+ // follows it.
443
+ // Only when the preflight opened one. A cookie left by the 401 that
444
+ // rejected us is debris, not a session, and telling the server we are
445
+ // finished with something we never had sends a request nobody asked for
446
+ // — into the middle of an authentication exchange, in the case that found
447
+ // this.
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());
305
453
  this.invalidateSession();
306
454
  // And the identity with it. The rejecting response was still observed, so
307
455
  // its cookie was recorded as a session that had just been established —
@@ -309,6 +457,10 @@ class AbstractAbapConnection {
309
457
  // isConnected() says false. Two answers to one question is worse than
310
458
  // either.
311
459
  this.lifecycle.markDisconnected();
460
+ // Not awaited, for the same reason a teardown does not wait: the caller
461
+ // is owed the establishment error now, not after a round trip nobody is
462
+ // waiting on. closeSession never throws, so nothing here can go unhandled.
463
+ void goodbye;
312
464
  throw error;
313
465
  }
314
466
  if (this.lifecycle.teardownEpoch !== baselineEpoch) {
@@ -319,15 +471,76 @@ class AbstractAbapConnection {
319
471
  // from inside the guard meant to protect it.
320
472
  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
473
  }
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.
474
+ // The session IS the SAP_SESSIONID the server issued; our own session id is
475
+ // a conversation label we generate and says nothing about what exists on
476
+ // the other side. An empty fingerprint therefore means the server opened no
477
+ // session — on-prem it answers with `sap-XSRF_*` instead once enough
478
+ // sessions are already open for the user — and such a connection still gets
479
+ // `200` for a LOCK and hands back a handle the next request cannot use.
480
+ //
481
+ // SAP_SESSIONID names the ABAP session — the one locks are bound to. Its
482
+ // absence is therefore not a transport problem: the HTTP side is fine, the
483
+ // cookies are here, and stateless requests will work. What is missing is any
484
+ // ABAP session known to this connection, so there is nothing a lock could be
485
+ // bound to and every lock taken over it is dead the moment it is issued.
486
+ //
487
+ // Checked rather than assumed: a connection that got no cookie was held open
488
+ // against an on-prem system and the session list showed nothing for it,
489
+ // while one that got a cookie appeared there.
490
+ //
491
+ // Which is why this refuses to connect rather than warning. There is no
492
+ // count to plan around — the same system allowed 21 sessions one day and
493
+ // refused an eleventh the next — so a caller cannot avoid the condition by
494
+ // being frugal, and the only reliable signal is whether THIS connect got a
495
+ // session. Reported with its cause; recovering is the caller's call, and
496
+ // nothing here retries on anyone's behalf.
497
+ //
498
+ // Reported, not decided on. A session that was not opened is a condition on
499
+ // the server, and what to do about it — wait, retry, release sessions this
500
+ // user still holds, carry on read-only over a fresh connection — depends on
501
+ // things only the caller knows. So it is raised where the caller can catch
502
+ // it, with enough in the message to act on, and nothing is retried here.
503
+ //
504
+ // Every transport, not only basic: splitting by authentication type would
505
+ // encode a guess about cloud ABAP, whose ADT endpoint would not answer the
506
+ // bearer obtainable here, so the question stayed open. If a cloud system
507
+ // turns out to hold sessions without issuing this cookie, this is the rule
508
+ // to revisit — and it will say so loudly rather than fail quietly.
509
+ if (!this.transport.sessionEstablished()) {
510
+ // Goodbye first, for the same reason the catch above does it: the
511
+ // preflight may have opened a session — on cloud it does — and this path
512
+ // is about to drop the cookies that are the only permission to close it.
513
+ // Refusing to connect must not leak the session the refusal is about.
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)}`);
519
+ }
520
+ this.invalidateSession();
521
+ this.lifecycle.forgetIdentity();
522
+ this.lifecycle.markDisconnected();
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. ` +
524
+ 'Stateless reads would still work over it, but a lock, and any write under that lock, is dead the moment it is issued. ' +
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. ' +
526
+ 'Whether to wait, retry, or release sessions this user still holds is yours to decide — this library does not retry on your behalf.');
527
+ }
325
528
  this.lifecycle.markConnected(this.sessionFingerprint());
326
529
  }
327
530
  /** The teardown epoch, for a recovery to capture before it starts. */
328
531
  get teardownEpoch() {
329
532
  return this.lifecycle.teardownEpoch;
330
533
  }
534
+ /**
535
+ * Which session the connection is on now.
536
+ *
537
+ * Moves whenever the session does. A response that comes back carrying an
538
+ * older one belongs to a session that has already been replaced, and must not
539
+ * be acted on as if it said something about the current one.
540
+ */
541
+ get sessionGeneration() {
542
+ return this.lifecycle.sessionGeneration;
543
+ }
331
544
  /**
332
545
  * Raises a session-lost teardown from inside request handling.
333
546
  *
@@ -398,7 +611,10 @@ class AbstractAbapConnection {
398
611
  this.logger?.debug('Ignoring a response from a previous session: its effects are fenced');
399
612
  return;
400
613
  }
401
- this.applyIdentityPolicy(this.updateCookiesFromResponse(headers));
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()));
402
618
  }
403
619
  /**
404
620
  * Acts on what a response said about the session identity.
@@ -420,25 +636,28 @@ class AbstractAbapConnection {
420
636
  if (classification !== 'replaced')
421
637
  return;
422
638
  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');
639
+ 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. ' +
640
+ '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. ' +
641
+ 'What to do about it is yours: re-establish and redo the work, or fail the operation. Nothing is retried here.');
424
642
  }
425
643
  /**
426
644
  * Whether the server is telling us the session it was given no longer exists.
427
645
  *
428
- * The E19 shape was HTTP 400 with "Session not found", answered in ~60 ms
646
+ * One on-prem system answered HTTP 400 with "Session not found", answered in ~60 ms
429
647
  * with the cookie present — which is why identity comparison cannot see this:
430
648
  * the cookie, and therefore the fingerprint, is completely unchanged. The
431
649
  * exact match is landscape-specific and is one of the live probes this design
432
650
  * still owes.
433
651
  */
434
652
  isDeadSessionResponse(error) {
435
- if (!(error instanceof axios_1.AxiosError) || !error.response)
653
+ const refusal = (0, IAdtTransport_js_1.refusalOf)(error);
654
+ if (!refusal)
436
655
  return false;
437
- if (error.response.status !== 400)
656
+ if (refusal.status !== 400)
438
657
  return false;
439
658
  const text = [
440
- error.response.statusText,
441
- typeof error.response.data === 'string' ? error.response.data : '',
659
+ refusal.statusText,
660
+ typeof refusal.data === 'string' ? refusal.data : '',
442
661
  ]
443
662
  .join(' ')
444
663
  .toLowerCase();
@@ -446,14 +665,11 @@ class AbstractAbapConnection {
446
665
  }
447
666
  /** Drops everything that described the session. Not a lifecycle transition. */
448
667
  clearSessionState() {
449
- if (this.axiosInstance) {
450
- this.axiosInstance.interceptors.request.clear();
451
- this.axiosInstance.interceptors.response.clear();
452
- this.axiosInstance = null;
453
- }
454
- this.csrfToken = null;
455
- this.cookies = null;
456
- 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();
457
673
  // Note: baseUrl is not reset as it's derived from immutable config
458
674
  }
459
675
  /**
@@ -465,13 +681,7 @@ class AbstractAbapConnection {
465
681
  * overwritten on every response.
466
682
  */
467
683
  sessionFingerprint() {
468
- const fingerprint = new Map();
469
- for (const [name, value] of this.cookieStore) {
470
- if (name.startsWith('SAP_SESSIONID')) {
471
- fingerprint.set(name, value);
472
- }
473
- }
474
- return fingerprint;
684
+ return this.transport.sessionFingerprint();
475
685
  }
476
686
  async getBaseUrl() {
477
687
  return this.baseUrl;
@@ -504,21 +714,22 @@ class AbstractAbapConnection {
504
714
  async performRequest(options, lease) {
505
715
  const { url: endpoint, method, timeout, data, params, headers: customHeaders, } = options;
506
716
  const normalizedMethod = method.toUpperCase();
507
- // Build full URL: baseUrl + endpoint
508
- 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;
509
720
  // Try to ensure CSRF token is available for POST/PUT/DELETE, but don't fail if it can't be fetched
510
721
  // The retry logic will handle CSRF token errors automatically
511
722
  if (normalizedMethod === 'POST' ||
512
723
  normalizedMethod === 'PUT' ||
513
724
  normalizedMethod === 'DELETE') {
514
- if (!this.csrfToken) {
725
+ if (!this.transport.csrfToken()) {
515
726
  try {
516
- await this.ensureFreshCsrfToken(requestUrl);
727
+ await this.ensureWireReady();
517
728
  }
518
729
  catch (error) {
519
730
  // If CSRF token can't be fetched upfront, continue anyway
520
731
  // The retry logic will handle CSRF token errors automatically
521
- 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)}`);
522
733
  }
523
734
  }
524
735
  }
@@ -544,20 +755,19 @@ class AbstractAbapConnection {
544
755
  }
545
756
  // Add auth headers (these MUST NOT be overridden)
546
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();
547
761
  if ((normalizedMethod === 'POST' ||
548
762
  normalizedMethod === 'PUT' ||
549
763
  normalizedMethod === 'DELETE') &&
550
- this.csrfToken) {
551
- requestHeaders['x-csrf-token'] = this.csrfToken;
552
- }
553
- // Add cookies LAST (MUST NOT be overridden by custom headers)
554
- if (this.cookies) {
555
- requestHeaders.Cookie = this.cookies;
556
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Adding cookies to request (first 100 chars): ${this.cookies.substring(0, 100)}...`);
557
- }
558
- else {
559
- this.logger?.debug(`[DEBUG] BaseAbapConnection - NO COOKIES available for this request to ${requestUrl}`);
764
+ presented) {
765
+ requestHeaders['x-csrf-token'] = presented;
560
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.
561
771
  if ((normalizedMethod === 'POST' || normalizedMethod === 'PUT') && data) {
562
772
  if (typeof data === 'string' && !requestHeaders['Content-Type']) {
563
773
  if (requestUrl.includes('/usageReferences') &&
@@ -573,8 +783,10 @@ class AbstractAbapConnection {
573
783
  }
574
784
  }
575
785
  // Inside an uninterruptible critical section (lock → modify → unlock), a
576
- // short per-request timeout must not abort the request mid-flight — that
577
- // 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
578
790
  // timeout to the large critical-section ceiling for the whole request
579
791
  // (also honoured on the retry paths below, which reuse requestConfig).
580
792
  const effectiveTimeout = this.inCriticalSection
@@ -585,7 +797,9 @@ class AbstractAbapConnection {
585
797
  url: requestUrl,
586
798
  headers: requestHeaders,
587
799
  timeout: effectiveTimeout,
588
- 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,
589
803
  };
590
804
  if (data !== undefined) {
591
805
  requestConfig.data = data;
@@ -596,7 +810,7 @@ class AbstractAbapConnection {
596
810
  method: normalizedMethod,
597
811
  });
598
812
  try {
599
- const response = await this.getAxiosInstance()(requestConfig);
813
+ const response = await this.transport.send(requestConfig);
600
814
  this.observeResponse(response.headers, lease.generation);
601
815
  this.logger?.debug(`Request succeeded with status ${response.status}`, {
602
816
  type: 'REQUEST_SUCCESS',
@@ -626,15 +840,18 @@ class AbstractAbapConnection {
626
840
  message: error instanceof Error ? error.message : String(error),
627
841
  url: requestUrl,
628
842
  method: normalizedMethod,
629
- status: error instanceof axios_1.AxiosError ? error.response?.status : undefined,
843
+ status: (0, IAdtTransport_js_1.refusalOf)(error)?.status,
630
844
  data: undefined,
631
845
  };
632
- if (error instanceof axios_1.AxiosError && error.response) {
846
+ const refusal = (0, IAdtTransport_js_1.refusalOf)(error);
847
+ if (refusal) {
633
848
  errorDetails.data =
634
- typeof error.response.data === 'string'
635
- ? error.response.data.slice(0, 200)
636
- : JSON.stringify(error.response.data).slice(0, 200);
637
- 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);
638
855
  }
639
856
  // The server telling us the session is gone is invisible to the identity
640
857
  // comparison: the cookie, and therefore the fingerprint, is unchanged.
@@ -660,20 +877,24 @@ class AbstractAbapConnection {
660
877
  else {
661
878
  this.logger?.error(errorDetails.message, errorDetails);
662
879
  }
663
- // Detect the "login-form 401" pattern: SAP returned 401 for a mutation while
664
- // we have a cached CSRF token. The token and its bound SAP session must be
665
- // discarded before the retry. Basic auth only — JWT/SAML lifecycles are
666
- // managed elsewhere.
667
- const isCachedTokenStale = error instanceof axios_1.AxiosError &&
668
- this.config.authType === 'basic' &&
669
- (normalizedMethod === 'POST' ||
670
- normalizedMethod === 'PUT' ||
671
- normalizedMethod === 'DELETE') &&
672
- 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 &&
673
894
  this.getCsrfToken() !== null;
674
895
  // Retry logic for CSRF token errors (403 with CSRF message) and the
675
896
  // login-form 401 pattern.
676
- if (this.shouldRetryCsrf(error) || isCachedTokenStale) {
897
+ if (this.shouldRetryCsrf(error, normalizedMethod) || isCachedTokenStale) {
677
898
  this.logger?.debug(isCachedTokenStale
678
899
  ? 'Stale CSRF token / SAP session — invalidating and retrying'
679
900
  : 'CSRF token validation failed, fetching new token and retrying request', {
@@ -695,7 +916,7 @@ class AbstractAbapConnection {
695
916
  if (refreshedCookies) {
696
917
  requestHeaders.Cookie = refreshedCookies;
697
918
  }
698
- const retryResponse = await this.getAxiosInstance()(requestConfig);
919
+ const retryResponse = await this.transport.send(requestConfig);
699
920
  this.observeResponse(retryResponse.headers, lease.generation);
700
921
  return retryResponse;
701
922
  }
@@ -712,30 +933,30 @@ class AbstractAbapConnection {
712
933
  throw error;
713
934
  }
714
935
  }
715
- // Retry logic for 401 errors on GET requests (authentication issue - need cookies)
716
- // Only for basic auth - JWT auth will be handled by refresh logic below
717
- if (error instanceof axios_1.AxiosError &&
718
- error.response?.status === 401 &&
719
- normalizedMethod === 'GET' &&
720
- this.config.authType === 'basic' // Only for basic auth
721
- ) {
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') {
722
941
  // If we already have cookies from error response, retry immediately
723
- if (this.cookies) {
724
- this.logger?.debug(`[DEBUG] BaseAbapConnection - 401 on GET request, retrying with cookies from error response`);
725
- requestHeaders.Cookie = this.cookies;
726
- 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);
727
947
  this.observeResponse(retryResponse.headers, lease.generation);
728
948
  return retryResponse;
729
949
  }
730
950
  // If no cookies, try to get them via CSRF token fetch
731
- 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`);
732
952
  try {
733
953
  // Try to get CSRF token (this will also get cookies)
734
- this.csrfToken = await this.fetchCsrfToken(requestUrl, 3, 1000, lease.generation);
735
- if (this.cookies) {
736
- requestHeaders.Cookie = this.cookies;
737
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Retrying GET request with cookies from CSRF fetch`);
738
- 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);
739
960
  this.observeResponse(retryResponse.headers, lease.generation);
740
961
  return retryResponse;
741
962
  }
@@ -744,7 +965,7 @@ class AbstractAbapConnection {
744
965
  if (this.isSessionVerdict(csrfError)) {
745
966
  throw csrfError;
746
967
  }
747
- 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)}`);
748
969
  // Fall through to throw original error
749
970
  }
750
971
  }
@@ -752,326 +973,108 @@ class AbstractAbapConnection {
752
973
  }
753
974
  }
754
975
  /**
755
- * Fetch CSRF token from SAP system
756
- * 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.
757
983
  */
758
- 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,
759
985
  /** Fences the response effects; omitted during connect(), which has no lease. */
760
986
  generation) {
761
- // Try primary endpoint first, then fallback for older systems
762
- const baseUrl = url.includes('/sap/bc/adt/')
763
- ? url.split('/sap/bc/adt')[0]
764
- : url.endsWith('/')
765
- ? url.slice(0, -1)
766
- : url;
767
- let endpoints;
768
- // If the URL already contains a specific endpoint, use only that
769
- if (url.includes(csrfConfig_js_1.CSRF_CONFIG.ENDPOINT)) {
770
- endpoints = [url];
771
- }
772
- else if (url.includes(csrfConfig_js_1.CSRF_CONFIG.FALLBACK_ENDPOINT)) {
773
- endpoints = [url];
774
- }
775
- else {
776
- endpoints = [
777
- `${baseUrl}${csrfConfig_js_1.CSRF_CONFIG.ENDPOINT}`,
778
- `${baseUrl}${csrfConfig_js_1.CSRF_CONFIG.FALLBACK_ENDPOINT}`,
779
- ];
780
- }
781
- let lastError;
782
- for (const csrfUrl of endpoints) {
783
- try {
784
- return await this.fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay, generation);
785
- }
786
- catch (error) {
787
- // Third layer with a catch on this path, and the last one that could
788
- // bury a verdict: falling through to the fallback endpoint would open
789
- // ANOTHER session, and by then the teardown has cleared the fingerprint
790
- // so the new one reads as `established` and the loss disappears.
791
- if (this.isSessionVerdict(error)) {
792
- throw error;
793
- }
794
- lastError = error instanceof Error ? error : new Error(String(error));
795
- this.logger?.debug(`CSRF token not available from ${csrfUrl}, trying next endpoint...`);
796
- }
797
- }
798
- // All endpoints exhausted
799
- throw lastError ?? new Error('CSRF token fetch failed unexpectedly');
800
- }
801
- /**
802
- * Fetch CSRF token from a specific endpoint with retries
803
- */
804
- async fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay, generation) {
805
- this.logger?.debug(`Fetching CSRF token from: ${csrfUrl}`);
806
- for (let attempt = 0; attempt <= retryCount; attempt++) {
807
- try {
808
- if (attempt > 0) {
809
- this.logger?.debug(`Retry attempt ${attempt}/${retryCount} for CSRF token`);
810
- }
811
- const authHeaders = await this.getAuthHeaders();
812
- const headers = {
813
- ...authHeaders,
814
- ...csrfConfig_js_1.CSRF_CONFIG.REQUIRED_HEADERS,
815
- };
816
- // The token fetch belongs to the same ADT conversation as every other
817
- // request this connection makes. Without the connection id the server
818
- // sees a caller that merely happens to present our cookies, so a fetch
819
- // issued while a lock is held reads as a stranger reaching into the
820
- // session. makeAdtRequest sends this header for all session types; this
821
- // path must not be the exception.
822
- if (this.sessionId) {
823
- headers['sap-adt-connection-id'] = this.sessionId;
824
- }
825
- // Always add cookies if available - they are needed for session continuity
826
- // Even on first attempt, if we have cookies from previous session or error response, use them
827
- if (this.cookies) {
828
- headers.Cookie = this.cookies;
829
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Adding cookies to CSRF token request (attempt ${attempt + 1}, first 100 chars): ${this.cookies.substring(0, 100)}...`);
830
- }
831
- else {
832
- this.logger?.debug(`[DEBUG] BaseAbapConnection - No cookies available for CSRF token request (will get fresh cookies from response)`);
833
- }
834
- // Log request details for debugging (only if debug logging is enabled)
835
- 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}`);
836
- const response = await this.getAxiosInstance()({
837
- method: 'GET',
838
- url: csrfUrl,
839
- headers,
840
- timeout: (0, timeouts_js_1.getTimeout)('csrf'),
841
- });
842
- this.observeResponse(response.headers, generation);
843
- const token = response.headers['x-csrf-token'];
844
- if (!token) {
845
- this.logger?.error('No CSRF token in response headers', {
846
- headers: response.headers,
847
- status: response.status,
848
- });
849
- if (attempt < retryCount) {
850
- await new Promise((resolve) => setTimeout(resolve, retryDelay));
851
- continue;
852
- }
853
- throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
854
- }
855
- if (response.headers['set-cookie']) {
856
- this.observeResponse(response.headers, generation);
857
- if (this.cookies) {
858
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Cookies received from CSRF response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
859
- this.logger?.debug('Cookies extracted from response', {
860
- cookieLength: this.cookies.length,
861
- });
862
- }
863
- }
864
- this.logger?.debug('CSRF token successfully obtained');
865
- return token;
866
- }
867
- catch (error) {
868
- // A session verdict is not a failed token fetch and must not be
869
- // retried into silence: the retry would observe the SAME new session,
870
- // read it as `unchanged`, and the replacement this raised would be gone
871
- // for good. It leaves immediately, past the loop and past the caller's
872
- // recovery.
873
- if (this.isSessionVerdict(error)) {
874
- throw error;
875
- }
876
- if (error instanceof axios_1.AxiosError) {
877
- // Always try to extract cookies from error response, even on 401
878
- // This ensures cookies are available for subsequent requests
879
- if (error.response?.headers) {
880
- this.observeResponse(error.response.headers, generation);
881
- if (this.cookies) {
882
- this.logger?.debug('Cookies extracted from error response', {
883
- status: error.response.status,
884
- cookieLength: this.cookies.length,
885
- });
886
- }
887
- }
888
- this.logger?.error(`CSRF token error: ${error.message}`, {
889
- url: csrfUrl,
890
- status: error.response?.status,
891
- attempt: attempt + 1,
892
- maxAttempts: retryCount + 1,
893
- });
894
- if (error.response?.status === 405 &&
895
- error.response?.headers['x-csrf-token']) {
896
- this.logger?.debug('CSRF: SAP returned 405 (Method Not Allowed) — not critical, token found in header');
897
- const token = error.response.headers['x-csrf-token'];
898
- if (token) {
899
- this.observeResponse(error.response.headers, generation);
900
- return token;
901
- }
902
- }
903
- if (error.response?.headers['x-csrf-token']) {
904
- this.logger?.debug(`Got CSRF token despite error (status: ${error.response?.status})`);
905
- const token = error.response.headers['x-csrf-token'];
906
- this.observeResponse(error.response.headers, generation);
907
- return token;
908
- }
909
- if (error.response) {
910
- this.logger?.error('CSRF error details', {
911
- status: error.response.status,
912
- statusText: error.response.statusText,
913
- headers: Object.keys(error.response.headers),
914
- data: typeof error.response.data === 'string'
915
- ? error.response.data.slice(0, 200)
916
- : JSON.stringify(error.response.data).slice(0, 200),
917
- });
918
- }
919
- else if (error.request) {
920
- this.logger?.error('CSRF request error - no response received', {
921
- request: error.request.path,
922
- });
923
- }
924
- }
925
- else {
926
- this.logger?.error('CSRF non-axios error', {
927
- error: error instanceof Error ? error.message : String(error),
928
- });
929
- }
930
- if (attempt < retryCount) {
931
- await new Promise((resolve) => setTimeout(resolve, retryDelay));
932
- continue;
933
- }
934
- // Preserve original error information, especially AxiosError with response
935
- if (error instanceof axios_1.AxiosError && error.response) {
936
- // Re-throw the original AxiosError to preserve response information
937
- throw error;
938
- }
939
- throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.FETCH_FAILED(retryCount + 1, error instanceof Error ? error.message : String(error)));
940
- }
941
- }
942
- 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;
943
1005
  }
944
- /**
945
- * Get CSRF token (protected for use by subclasses)
946
- */
947
1006
  getCsrfToken() {
948
- return this.csrfToken;
1007
+ return this.transport.csrfToken();
949
1008
  }
950
1009
  /**
951
1010
  * Set CSRF token (protected for use by subclasses)
952
1011
  */
953
1012
  setCsrfToken(token) {
954
- 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);
955
1016
  }
956
1017
  /**
957
1018
  * Get cookies (protected for use by subclasses)
958
1019
  */
959
1020
  getCookies() {
960
- return this.cookies;
961
- }
962
- setInitialCookies(cookies) {
963
- this.cookies = cookies;
1021
+ return this.transport.cookies();
964
1022
  }
965
1023
  /**
966
- * Folds a response's cookies into the jar and classifies what that means for
967
- * the session identity. Returns the classification rather than acting on it:
968
- * cookie parsing stays free of policy, and no exception fires in the middle
969
- * of a state update. The caller decides.
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.
970
1029
  */
971
- updateCookiesFromResponse(headers) {
972
- if (!headers) {
973
- return 'unchanged';
974
- }
975
- const setCookie = headers['set-cookie'];
976
- if (!setCookie) {
977
- return 'unchanged';
978
- }
979
- const cookiesArray = Array.isArray(setCookie) ? setCookie : [setCookie];
980
- for (const entry of cookiesArray) {
981
- if (typeof entry !== 'string') {
982
- continue;
983
- }
984
- const [nameValue] = entry.split(';');
985
- if (!nameValue) {
986
- continue;
987
- }
988
- const [name, ...rest] = nameValue.split('=');
989
- if (!name) {
990
- continue;
991
- }
992
- const trimmedName = name.trim();
993
- const trimmedValue = rest.join('=').trim();
994
- if (!trimmedName) {
995
- continue;
996
- }
997
- this.cookieStore.set(trimmedName, trimmedValue);
998
- }
999
- // Enforce configured SAP client in sap-usercontext cookie.
1000
- // SAP may return sap-usercontext=sap-client=<default_client> based on system
1001
- // default rather than the X-SAP-Client header value, causing requests to be
1002
- // routed to the wrong client (e.g. a read-only client → 403 on write operations).
1003
- if (this.config.client) {
1004
- this.cookieStore.set('sap-usercontext', `sap-client=${this.config.client}`);
1005
- }
1006
- if (this.cookieStore.size === 0) {
1007
- return 'unchanged';
1008
- }
1009
- const combined = Array.from(this.cookieStore.entries())
1010
- .map(([name, value]) => (value ? `${name}=${value}` : name))
1011
- .join('; ');
1012
- if (!combined) {
1013
- return 'unchanged';
1014
- }
1015
- this.cookies = combined;
1016
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Updated cookies from response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
1017
- return this.lifecycle.observe(this.sessionFingerprint());
1030
+ setInitialCookies(cookies) {
1031
+ this.transport.ingest({
1032
+ 'set-cookie': cookies.split(';').map((entry) => entry.trim()),
1033
+ });
1018
1034
  }
1019
1035
  /**
1020
- * Subclasses override to inject extra https.Agent options (e.g. mTLS cert/key/pfx).
1021
- * 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).
1022
1039
  */
1023
1040
  getHttpsAgentOptions() {
1024
1041
  return {};
1025
1042
  }
1026
- getAxiosInstance() {
1027
- if (!this.axiosInstance) {
1028
- const rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED === '1' ||
1029
- (process.env.TLS_REJECT_UNAUTHORIZED === '1' &&
1030
- process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0');
1031
- this.logger?.debug(`TLS configuration: rejectUnauthorized=${rejectUnauthorized}`);
1032
- this.axiosInstance = axios_1.default.create({
1033
- httpsAgent: new node_https_1.Agent({
1034
- rejectUnauthorized,
1035
- ...this.getHttpsAgentOptions(),
1036
- }),
1037
- });
1038
- }
1039
- return this.axiosInstance;
1040
- }
1041
- async ensureFreshCsrfToken(requestUrl) {
1042
- // If we already have a CSRF token, reuse it to keep the same SAP session
1043
- // SAP ties the lock handle to the HTTP session (SAP_SESSIONID cookie)
1044
- if (this.csrfToken) {
1045
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Reusing existing CSRF token to maintain session`);
1046
- return;
1047
- }
1048
- try {
1049
- this.logger?.debug(`[DEBUG] BaseAbapConnection - Fetching NEW CSRF token (will create new SAP session)`);
1050
- this.csrfToken = await this.fetchCsrfToken(requestUrl);
1051
- }
1052
- catch (error) {
1053
- // fetchCsrfToken handles auth errors
1054
- // Just re-throw the error with minimal logging to avoid duplicate error messages
1055
- const errorMsg = error instanceof Error
1056
- ? error.message
1057
- : csrfConfig_js_1.CSRF_ERROR_MESSAGES.REQUIRED_FOR_MUTATION;
1058
- // Only log at DEBUG level to avoid duplicate error messages
1059
- // (fetchCsrfToken already logged the error at ERROR level if auth failed)
1060
- this.logger?.debug(`[DEBUG] BaseAbapConnection - ensureFreshCsrfToken failed: ${errorMsg}`);
1061
- throw error;
1062
- }
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
+ });
1063
1060
  }
1064
1061
  /**
1065
1062
  * Clear SAP-side session state when SAP rejects the cached CSRF token + session
1066
1063
  * cookies (HTTP 401 on a mutation while a cached token exists). This forces the
1067
1064
  * next request path to fetch a fresh token and a fresh SAP_SESSIONID cookie.
1068
1065
  *
1069
- * Distinct from reset(): this leaves the axios instance and interceptors in place.
1066
+ * Distinct from disconnect(): this leaves the axios instance and interceptors
1067
+ * in place, and tells the server nothing — it is a request-level repair, not a
1068
+ * teardown.
1070
1069
  */
1071
1070
  invalidateSession() {
1072
1071
  this.setCsrfToken(null);
1073
- this.cookies = null;
1074
- 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();
1077
+ // Everything else that described THAT session. The application server named
1075
1078
  // And the tracked identity, because WE discarded the session. Without this
1076
1079
  // the cookie that arrives next reads as a foreign replacement — and since a
1077
1080
  // replacement is now always fatal, our own deliberate re-authentication
@@ -1080,28 +1083,27 @@ class AbstractAbapConnection {
1080
1083
  // is not a session taken from under us.
1081
1084
  this.lifecycle.forgetIdentity();
1082
1085
  }
1083
- shouldRetryCsrf(error) {
1084
- if (!(error instanceof axios_1.AxiosError)) {
1086
+ shouldRetryCsrf(error, method) {
1087
+ const refusal = (0, IAdtTransport_js_1.refusalOf)(error);
1088
+ if (!refusal) {
1085
1089
  return false;
1086
1090
  }
1087
- const responseData = error.response?.data;
1091
+ const responseData = refusal.data;
1088
1092
  const responseText = typeof responseData === 'string'
1089
1093
  ? responseData
1090
1094
  : JSON.stringify(responseData || '');
1091
- // Don't retry for JWT auth - refresh logic will handle it
1092
- if (this.config.authType === 'jwt') {
1093
- return false;
1094
- }
1095
1095
  // Retry on 403 with CSRF message, or if response mentions CSRF token
1096
1096
  // Also retry on 401 for POST/PUT/DELETE if we don't have CSRF token yet (might need to get cookies first)
1097
- const method = error.config?.method?.toUpperCase();
1098
- const isPostPutDelete = method && ['POST', 'PUT', 'DELETE'].includes(method);
1099
- const needsCsrfToken = !!isPostPutDelete && !this.csrfToken;
1100
- return ((!!error.response &&
1101
- error.response.status === 403 &&
1102
- 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')) ||
1103
1105
  responseText.includes('CSRF token') ||
1104
- (needsCsrfToken && error.response?.status === 401));
1106
+ (needsCsrfToken && refusal.status === 401));
1105
1107
  }
1106
1108
  }
1107
1109
  exports.AbstractAbapConnection = AbstractAbapConnection;