@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.
- package/CHANGELOG.md +243 -1
- package/README.md +204 -48
- package/bin/sap-abap-auth.js +101 -13
- package/dist/auth/providers.d.ts +38 -9
- package/dist/auth/providers.d.ts.map +1 -1
- package/dist/auth/providers.js +46 -3
- package/dist/connection/AbstractAbapConnection.d.ts +83 -136
- package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
- package/dist/connection/AbstractAbapConnection.js +241 -763
- package/dist/connection/AdtCloudConnector.d.ts +12 -7
- package/dist/connection/AdtCloudConnector.d.ts.map +1 -1
- package/dist/connection/AdtCloudConnector.js +2 -6
- package/dist/connection/AdtOnPremConnector.d.ts +20 -7
- package/dist/connection/AdtOnPremConnector.d.ts.map +1 -1
- package/dist/connection/AdtOnPremConnector.js +2 -6
- package/dist/connection/CloudHttpTransport.d.ts +37 -0
- package/dist/connection/CloudHttpTransport.d.ts.map +1 -0
- package/dist/{session/CloudSecuritySessionStrategy.js → connection/CloudHttpTransport.js} +59 -49
- package/dist/connection/CredentialAbapConnection.d.ts +8 -41
- package/dist/connection/CredentialAbapConnection.d.ts.map +1 -1
- package/dist/connection/CredentialAbapConnection.js +44 -111
- package/dist/connection/HttpTransport.d.ts +178 -0
- package/dist/connection/HttpTransport.d.ts.map +1 -0
- package/dist/connection/HttpTransport.js +402 -0
- package/dist/connection/IAdtTransport.d.ts +232 -0
- package/dist/connection/IAdtTransport.d.ts.map +1 -0
- package/dist/connection/IAdtTransport.js +28 -0
- package/dist/connection/LegacyOnPremHttpTransport.d.ts +40 -0
- package/dist/connection/LegacyOnPremHttpTransport.d.ts.map +1 -0
- package/dist/connection/LegacyOnPremHttpTransport.js +57 -0
- package/dist/connection/OnPremHttpTransport.d.ts +45 -0
- package/dist/connection/OnPremHttpTransport.d.ts.map +1 -0
- package/dist/connection/OnPremHttpTransport.js +91 -0
- package/dist/connection/RfcTransport.d.ts +89 -0
- package/dist/connection/RfcTransport.d.ts.map +1 -0
- package/dist/connection/RfcTransport.js +270 -0
- package/dist/connection/rfcConversation.d.ts +44 -0
- package/dist/connection/rfcConversation.d.ts.map +1 -0
- package/dist/connection/rfcConversation.js +71 -0
- package/dist/index.d.ts +7 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -18
- package/dist/utils/timeouts.d.ts +6 -19
- package/dist/utils/timeouts.d.ts.map +1 -1
- package/dist/utils/timeouts.js +6 -22
- package/docs/INDEX.md +5 -2
- package/docs/INSTALLATION.md +28 -10
- package/docs/JWT_AUTH_TOOLS.md +20 -4
- package/docs/MIGRATION-6.0.md +359 -0
- package/docs/SCOPE.md +1 -1
- package/docs/STATEFUL_SESSION_GUIDE.md +86 -17
- package/docs/USAGE.md +260 -119
- package/examples/basic-connection.js +15 -3
- package/examples/jwt-with-token-refresh.js +15 -7
- package/examples/saml-connection.js +15 -2
- package/package.json +12 -11
- package/dist/__tests__/helpers/session.d.ts +0 -15
- package/dist/__tests__/helpers/session.d.ts.map +0 -1
- package/dist/__tests__/helpers/session.js +0 -19
- package/dist/auth/IAuthProvider.d.ts +0 -84
- package/dist/auth/IAuthProvider.d.ts.map +0 -1
- package/dist/auth/IAuthProvider.js +0 -21
- package/dist/connection/BaseAbapConnection.d.ts +0 -29
- package/dist/connection/BaseAbapConnection.d.ts.map +0 -1
- package/dist/connection/BaseAbapConnection.js +0 -81
- package/dist/connection/CertificateAbapConnection.d.ts +0 -35
- package/dist/connection/CertificateAbapConnection.d.ts.map +0 -1
- package/dist/connection/CertificateAbapConnection.js +0 -91
- package/dist/connection/JwtAbapConnection.d.ts +0 -131
- package/dist/connection/JwtAbapConnection.d.ts.map +0 -1
- package/dist/connection/JwtAbapConnection.js +0 -376
- package/dist/connection/KerberosAbapConnection.d.ts +0 -32
- package/dist/connection/KerberosAbapConnection.d.ts.map +0 -1
- package/dist/connection/KerberosAbapConnection.js +0 -128
- package/dist/connection/RfcAbapConnection.d.ts +0 -44
- package/dist/connection/RfcAbapConnection.d.ts.map +0 -1
- package/dist/connection/RfcAbapConnection.js +0 -324
- package/dist/connection/SamlAbapConnection.d.ts +0 -31
- package/dist/connection/SamlAbapConnection.d.ts.map +0 -1
- package/dist/connection/SamlAbapConnection.js +0 -81
- package/dist/connection/connectionFactory.d.ts +0 -25
- package/dist/connection/connectionFactory.d.ts.map +0 -1
- package/dist/connection/connectionFactory.js +0 -84
- package/dist/session/CloudSecuritySessionStrategy.d.ts +0 -32
- package/dist/session/CloudSecuritySessionStrategy.d.ts.map +0 -1
- package/dist/session/IcfSessionStrategy.d.ts +0 -27
- package/dist/session/IcfSessionStrategy.d.ts.map +0 -1
- package/dist/session/IcfSessionStrategy.js +0 -62
- package/dist/session/SessionStrategy.d.ts +0 -86
- package/dist/session/SessionStrategy.d.ts.map +0 -1
- package/dist/session/SessionStrategy.js +0 -33
- 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
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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.
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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
|
-
* **
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* is what made a teardown unbounded, which blocks every
|
|
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
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
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(
|
|
354
|
-
// Nothing here throws
|
|
355
|
-
//
|
|
356
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
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
|
-
|
|
641
|
-
return
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
870
|
-
|
|
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
|
-
|
|
653
|
+
const refusal = (0, IAdtTransport_js_1.refusalOf)(error);
|
|
654
|
+
if (!refusal)
|
|
939
655
|
return false;
|
|
940
|
-
if (
|
|
656
|
+
if (refusal.status !== 400)
|
|
941
657
|
return false;
|
|
942
658
|
const text = [
|
|
943
|
-
|
|
944
|
-
typeof
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1014
|
-
|
|
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.
|
|
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(`
|
|
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
|
-
|
|
1062
|
-
requestHeaders['x-csrf-token'] =
|
|
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 —
|
|
1089
|
-
//
|
|
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
|
-
|
|
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.
|
|
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:
|
|
843
|
+
status: (0, IAdtTransport_js_1.refusalOf)(error)?.status,
|
|
1142
844
|
data: undefined,
|
|
1143
845
|
};
|
|
1144
|
-
|
|
846
|
+
const refusal = (0, IAdtTransport_js_1.refusalOf)(error);
|
|
847
|
+
if (refusal) {
|
|
1145
848
|
errorDetails.data =
|
|
1146
|
-
typeof
|
|
1147
|
-
?
|
|
1148
|
-
: JSON.stringify(
|
|
1149
|
-
|
|
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
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
// discarded before the retry.
|
|
1178
|
-
//
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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.
|
|
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
|
-
//
|
|
1228
|
-
//
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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(`
|
|
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.
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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(`
|
|
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
|
-
*
|
|
1268
|
-
*
|
|
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(
|
|
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
|
-
//
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1482
|
-
*
|
|
1483
|
-
*
|
|
1484
|
-
*
|
|
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
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
|
1536
|
-
* The returned options are merged with the base options
|
|
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
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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
|
-
|
|
1591
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
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 &&
|
|
1106
|
+
(needsCsrfToken && refusal.status === 401));
|
|
1629
1107
|
}
|
|
1630
1108
|
}
|
|
1631
1109
|
exports.AbstractAbapConnection = AbstractAbapConnection;
|