@mcp-abap-adt/connection 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -1
- package/README.md +11 -9
- package/dist/connection/AbstractAbapConnection.d.ts +32 -16
- package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
- package/dist/connection/AbstractAbapConnection.js +93 -51
- package/dist/index.js +4 -4
- package/dist/session/SessionLifecycle.d.ts +49 -62
- package/dist/session/SessionLifecycle.d.ts.map +1 -1
- package/dist/session/SessionLifecycle.js +56 -155
- package/docs/MIGRATION-2.0.md +22 -33
- package/docs/STATEFUL_SESSION_GUIDE.md +5 -4
- package/docs/USAGE.md +67 -71
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.0.0] - 2026-08-03
|
|
11
|
+
|
|
12
|
+
Undoes two mistakes from 2.0.0, four days old. Everything else that release
|
|
13
|
+
carried — mandatory `connect()`, replaced-session detection, session verdicts
|
|
14
|
+
surviving the retry layers, the Kerberos diagnosis — is unchanged.
|
|
15
|
+
|
|
16
|
+
### Changed — BREAKING
|
|
17
|
+
- **`disconnect()` returns `Promise<void>` and waits for nothing.** It used to
|
|
18
|
+
drain in-flight requests before clearing anything, and that drain had an
|
|
19
|
+
unbounded tail: a request whose caller chose no timeout — a legitimate choice
|
|
20
|
+
for a long poll — could hold a teardown open forever. Because lifecycle
|
|
21
|
+
transitions are serialized, every later `connect()` queued behind it. We
|
|
22
|
+
published a `disconnect()` that resolves; that path could not.
|
|
23
|
+
|
|
24
|
+
Deciding when to disconnect is the caller's, and so is preparing for it.
|
|
25
|
+
Requests in flight now run to completion untouched — nothing is aborted — and
|
|
26
|
+
the session state is cleared at once. Requires `@mcp-abap-adt/interfaces`
|
|
27
|
+
**^12.0.0**.
|
|
28
|
+
- **A replaced session is always fatal.** It used to be tolerated when no lock
|
|
29
|
+
window was open. Windows are gone (below), and this layer does not know that a
|
|
30
|
+
lock exists, what object it covers or what would release it — so the rule is
|
|
31
|
+
written from what it can know: the ABAP session we were speaking to is not the
|
|
32
|
+
one we are speaking to now, and anything held against the old one is dead.
|
|
33
|
+
Being wrong in this direction costs a reconnect; being wrong the other way
|
|
34
|
+
costs a lock nobody can find.
|
|
35
|
+
|
|
36
|
+
### Removed — BREAKING
|
|
37
|
+
- **`beginWindow()` / `endWindow()`**, and the window accounting in
|
|
38
|
+
`SessionLifecycle`. They were added in 2.0.0 and never worked: `beginWindow()`
|
|
39
|
+
put a label in a map and touched no timeout. The protection they were assumed
|
|
40
|
+
to provide — a span where a short per-request timeout must not abort a
|
|
41
|
+
request — is `beginCriticalSection()` / `endCriticalSection()`, which has done
|
|
42
|
+
it since 1.9.0, is reference-counted, and is **unchanged by this release**. Two
|
|
43
|
+
mechanisms for one idea, and the one promoted into the public API was the
|
|
44
|
+
no-op. Nothing called it: zero callers across every repository that depends on
|
|
45
|
+
this package.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
- **Session-generation fencing.** Removing the wait means a request can settle
|
|
49
|
+
after a later `connect()` established a new session, and the response path
|
|
50
|
+
mutates shared state — cookies, the identity policy, the CSRF cache. A stale
|
|
51
|
+
response would write over the new session and could be read as a replacement,
|
|
52
|
+
tearing down a session that is perfectly healthy. Every lease now carries the
|
|
53
|
+
generation it was admitted under, and a response whose generation is not
|
|
54
|
+
current has its effects skipped. It still resolves normally to its own caller:
|
|
55
|
+
fencing suppresses effects, not results.
|
|
56
|
+
|
|
57
|
+
Deliberately not the teardown epoch. Only a caller-initiated teardown moves the
|
|
58
|
+
epoch — a recovery must not cancel itself — so after a session loss and a
|
|
59
|
+
successful recovery, requests from the dead session carry the same epoch as the
|
|
60
|
+
new one and pass straight through a fence built on it.
|
|
61
|
+
|
|
62
|
+
The fence sits at the **top of the error path**, before anything reads or
|
|
63
|
+
writes shared state — not merely before the response headers are applied.
|
|
64
|
+
Everything below it acts on the connection rather than on the request: a late
|
|
65
|
+
400 "session not found" would tear down the healthy session established since,
|
|
66
|
+
and a late 401/403 would clear the live session's state, fetch a fresh CSRF
|
|
67
|
+
token and **retry** — replaying a mutation from a dead session inside the live
|
|
68
|
+
one. A stale request now gets its error back and nothing else happens.
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
- **Our own re-establishment no longer reads as someone else's replacement.**
|
|
72
|
+
With a replacement now always fatal, a deliberate re-authentication would have
|
|
73
|
+
torn the connection down: `invalidateSession()` dropped the cookies but kept
|
|
74
|
+
the tracked identity, so the next cookie looked foreign. Both it and the
|
|
75
|
+
establishment path now forget the identity first. The distinction that matters
|
|
76
|
+
is not "was a lock held" but "did we cause this".
|
|
77
|
+
|
|
10
78
|
## [2.0.0] - 2026-07-29
|
|
11
79
|
|
|
12
80
|
The connection owns its session now, and says so. Before this release a stateful
|
|
@@ -718,7 +786,8 @@ const connection = createAbapConnection(config, logger);
|
|
|
718
786
|
- JWT token refresh now properly handles connection errors (401/403 during initial connect)
|
|
719
787
|
- Permission errors (403 with "ExceptionResourceNoAccess") no longer trigger JWT refresh loops
|
|
720
788
|
- Proper separation: base class handles HTTP/session, concrete classes handle auth-specific errors
|
|
721
|
-
[Unreleased]: https://github.com/fr0ster/mcp-abap-connection/compare/
|
|
789
|
+
[Unreleased]: https://github.com/fr0ster/mcp-abap-connection/compare/v3.0.0...HEAD
|
|
790
|
+
[3.0.0]: https://github.com/fr0ster/mcp-abap-connection/compare/v2.0.0...v3.0.0
|
|
722
791
|
[2.0.0]: https://github.com/fr0ster/mcp-abap-connection/compare/v1.10.2...v2.0.0
|
|
723
792
|
[1.10.2]: https://github.com/fr0ster/mcp-abap-connection/compare/v1.10.1...v1.10.2
|
|
724
793
|
[1.10.1]: https://github.com/fr0ster/mcp-abap-connection/compare/v1.10.0...v1.10.1
|
package/README.md
CHANGED
|
@@ -409,24 +409,26 @@ interface AbapConnection {
|
|
|
409
409
|
```
|
|
410
410
|
|
|
411
411
|
The HTTP connection classes carry the rest of the session lifecycle. It is on the
|
|
412
|
-
shared contract as
|
|
413
|
-
|
|
414
|
-
|
|
412
|
+
shared contract as a **capability atom** in `@mcp-abap-adt/interfaces` rather than
|
|
413
|
+
as methods on `IAbapConnection`, which is why `RfcAbapConnection` — a transport
|
|
414
|
+
that owns no HTTP session — is unaffected by its existence:
|
|
415
415
|
|
|
416
416
|
```typescript
|
|
417
417
|
// ISessionLifecycleAware
|
|
418
|
-
disconnect(): Promise<
|
|
418
|
+
disconnect(): Promise<void>; // never throws, and waits for nothing
|
|
419
419
|
isConnected(): boolean;
|
|
420
|
-
getSessionIdentity(): string | null;
|
|
421
|
-
|
|
422
|
-
// ILockWindowAware
|
|
423
|
-
beginWindow(label: string): WindowToken;
|
|
424
|
-
endWindow(token: WindowToken): void;
|
|
420
|
+
getSessionIdentity(): string | null; // WHICH SAP session; null is not "disconnected"
|
|
425
421
|
```
|
|
426
422
|
|
|
427
423
|
Import those names from `@mcp-abap-adt/interfaces`, not from this package: a
|
|
428
424
|
contract type re-exported under a second name is a contract type that can drift.
|
|
429
425
|
|
|
426
|
+
**The connection does not track locks.** Deciding when to disconnect, and
|
|
427
|
+
preparing for it, belongs to the caller; pairing every LOCK with its UNLOCK
|
|
428
|
+
belongs to `@mcp-abap-adt/adt-clients`, which holds the handles. What this layer
|
|
429
|
+
owns is not being interrupted by a timeout mid-operation — see
|
|
430
|
+
`beginCriticalSection()` below.
|
|
431
|
+
|
|
430
432
|
See [docs/USAGE.md — Session Lifecycle](./docs/USAGE.md#session-lifecycle).
|
|
431
433
|
|
|
432
434
|
**Session Management:**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type IAdtResponse, type
|
|
1
|
+
import { type IAdtResponse, type ISessionLifecycleAware } from '@mcp-abap-adt/interfaces';
|
|
2
2
|
import type { SapConfig } from '../config/sapConfig.js';
|
|
3
3
|
import type { ILogger } from '../logger.js';
|
|
4
4
|
import { SessionLifecycle } from '../session/SessionLifecycle.js';
|
|
@@ -9,7 +9,7 @@ import type { AbapConnection, AbapRequestOptions } from './AbapConnection.js';
|
|
|
9
9
|
* the HTTP session's own, and naming them means a signature that drifts from
|
|
10
10
|
* the published contract fails to compile here instead of at the consumer.
|
|
11
11
|
*/
|
|
12
|
-
declare abstract class AbstractAbapConnection implements AbapConnection, ISessionLifecycleAware
|
|
12
|
+
declare abstract class AbstractAbapConnection implements AbapConnection, ISessionLifecycleAware {
|
|
13
13
|
private readonly config;
|
|
14
14
|
protected readonly logger: ILogger | null;
|
|
15
15
|
/**
|
|
@@ -108,10 +108,20 @@ declare abstract class AbstractAbapConnection implements AbapConnection, ISessio
|
|
|
108
108
|
*/
|
|
109
109
|
connect(): Promise<void>;
|
|
110
110
|
/**
|
|
111
|
-
* Tears the session down. Never throws
|
|
112
|
-
*
|
|
111
|
+
* Tears the session down. Never throws, and always settles.
|
|
112
|
+
*
|
|
113
|
+
* It waits for NOTHING. Deciding when to disconnect is the caller's, and so is
|
|
114
|
+
* preparing for it — finishing chains, releasing locks. Waiting here on a
|
|
115
|
+
* request whose caller chose no timeout is what made a teardown unbounded, and
|
|
116
|
+
* an unbounded teardown blocks every later transition on the serialized tail.
|
|
117
|
+
*
|
|
118
|
+
* Requests already in flight run to completion untouched. Generation fencing
|
|
119
|
+
* (see `SessionLifecycle.isCurrent`) keeps their results from reaching this
|
|
120
|
+
* connection afterwards.
|
|
121
|
+
*
|
|
122
|
+
* Sends no ADT session-close — see the design's D2.
|
|
113
123
|
*/
|
|
114
|
-
disconnect(): Promise<
|
|
124
|
+
disconnect(): Promise<void>;
|
|
115
125
|
isConnected(): boolean;
|
|
116
126
|
/**
|
|
117
127
|
* Fingerprint of the SAP-side session, or null when none is known.
|
|
@@ -124,9 +134,6 @@ declare abstract class AbstractAbapConnection implements AbapConnection, ISessio
|
|
|
124
134
|
* learned; only a CHANGED value means the session was replaced.
|
|
125
135
|
*/
|
|
126
136
|
getSessionIdentity(): string | null;
|
|
127
|
-
/** Opens a lock window. See the design: a lock outlives its request. */
|
|
128
|
-
beginWindow(label: string): WindowToken;
|
|
129
|
-
endWindow(token: WindowToken): void;
|
|
130
137
|
/**
|
|
131
138
|
* Discards the session at a caller's request: cancels queued recoveries and
|
|
132
139
|
* queues the cleanup rather than tearing down under a live request.
|
|
@@ -179,8 +186,8 @@ declare abstract class AbstractAbapConnection implements AbapConnection, ISessio
|
|
|
179
186
|
* immediately — a later comparison must see the change, and on a dead session
|
|
180
187
|
* the cookie is unchanged, so only the state can tell.
|
|
181
188
|
*
|
|
182
|
-
* Does not await: it is called from inside a request, and the
|
|
183
|
-
*
|
|
189
|
+
* Does not await: it is called from inside a request, and the cleanup must not
|
|
190
|
+
* wait for the very request that raised it.
|
|
184
191
|
*/
|
|
185
192
|
protected raiseSessionLost(reason: string): void;
|
|
186
193
|
/** The credential-renewal raiser; see raiseSessionLost(). */
|
|
@@ -207,11 +214,18 @@ declare abstract class AbstractAbapConnection implements AbapConnection, ISessio
|
|
|
207
214
|
/**
|
|
208
215
|
* Acts on what a response said about the session identity.
|
|
209
216
|
*
|
|
210
|
-
* A replacement is fatal
|
|
211
|
-
*
|
|
212
|
-
* windows
|
|
213
|
-
*
|
|
214
|
-
*
|
|
217
|
+
* A replacement is always fatal, and that is a narrowing: an earlier version
|
|
218
|
+
* tolerated it "when no lock is held", deciding from the connection's own
|
|
219
|
+
* lock windows. Those are gone, and rightly — this layer does not know that a
|
|
220
|
+
* lock exists, what object it covers or what would release it. Locks are
|
|
221
|
+
* tracked a layer up, per object, by the code that took them.
|
|
222
|
+
*
|
|
223
|
+
* So the rule is written from what this layer CAN know: the ABAP session we
|
|
224
|
+
* were speaking to is not the one we are speaking to now. Anything the caller
|
|
225
|
+
* held against the old one is dead, and continuing quietly would hand them a
|
|
226
|
+
* session they never opened — the failure this whole design exists to prevent.
|
|
227
|
+
* Being wrong in this direction costs a reconnect; being wrong the other way
|
|
228
|
+
* costs a lock nobody can find.
|
|
215
229
|
*/
|
|
216
230
|
private applyIdentityPolicy;
|
|
217
231
|
/**
|
|
@@ -244,7 +258,9 @@ declare abstract class AbstractAbapConnection implements AbapConnection, ISessio
|
|
|
244
258
|
* Fetch CSRF token from SAP system
|
|
245
259
|
* Protected method for use by concrete implementations in their connect() method
|
|
246
260
|
*/
|
|
247
|
-
protected fetchCsrfToken(url: string, retryCount?: number, retryDelay?: number
|
|
261
|
+
protected fetchCsrfToken(url: string, retryCount?: number, retryDelay?: number,
|
|
262
|
+
/** Fences the response effects; omitted during connect(), which has no lease. */
|
|
263
|
+
generation?: number): Promise<string>;
|
|
248
264
|
/**
|
|
249
265
|
* Fetch CSRF token from a specific endpoint with retries
|
|
250
266
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AbstractAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/AbstractAbapConnection.ts"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,YAAY,EACjB,KAAK,
|
|
1
|
+
{"version":3,"file":"AbstractAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/AbstractAbapConnection.ts"],"names":[],"mappings":"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"}
|
|
@@ -197,22 +197,27 @@ class AbstractAbapConnection {
|
|
|
197
197
|
});
|
|
198
198
|
}
|
|
199
199
|
/**
|
|
200
|
-
* Tears the session down. Never throws
|
|
201
|
-
*
|
|
200
|
+
* Tears the session down. Never throws, and always settles.
|
|
201
|
+
*
|
|
202
|
+
* It waits for NOTHING. Deciding when to disconnect is the caller's, and so is
|
|
203
|
+
* preparing for it — finishing chains, releasing locks. Waiting here on a
|
|
204
|
+
* request whose caller chose no timeout is what made a teardown unbounded, and
|
|
205
|
+
* an unbounded teardown blocks every later transition on the serialized tail.
|
|
206
|
+
*
|
|
207
|
+
* Requests already in flight run to completion untouched. Generation fencing
|
|
208
|
+
* (see `SessionLifecycle.isCurrent`) keeps their results from reaching this
|
|
209
|
+
* connection afterwards.
|
|
210
|
+
*
|
|
211
|
+
* Sends no ADT session-close — see the design's D2.
|
|
202
212
|
*/
|
|
203
213
|
async disconnect() {
|
|
214
|
+
// Synchronous, at the call: admission shuts and the generation moves before
|
|
215
|
+
// anything is queued, so a caller who has asked to disconnect cannot have
|
|
216
|
+
// requests still going through while this waits its turn.
|
|
204
217
|
this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: false });
|
|
205
|
-
|
|
206
|
-
// same one. Building it outside would leave every caller but the first
|
|
207
|
-
// reporting an empty teardown — the abandoned locks reported to nobody.
|
|
208
|
-
return this.lifecycle.transition('disconnect', async () => {
|
|
209
|
-
const drained = await this.lifecycle.drain();
|
|
218
|
+
await this.lifecycle.transition('disconnect', async () => {
|
|
210
219
|
this.clearSessionState();
|
|
211
220
|
this.lifecycle.markDisconnected();
|
|
212
|
-
return {
|
|
213
|
-
abandonedWindows: drained.abandonedWindows,
|
|
214
|
-
releasePending: false,
|
|
215
|
-
};
|
|
216
221
|
});
|
|
217
222
|
}
|
|
218
223
|
isConnected() {
|
|
@@ -231,13 +236,6 @@ class AbstractAbapConnection {
|
|
|
231
236
|
getSessionIdentity() {
|
|
232
237
|
return this.lifecycle.identity;
|
|
233
238
|
}
|
|
234
|
-
/** Opens a lock window. See the design: a lock outlives its request. */
|
|
235
|
-
beginWindow(label) {
|
|
236
|
-
return this.lifecycle.beginWindow(label);
|
|
237
|
-
}
|
|
238
|
-
endWindow(token) {
|
|
239
|
-
this.lifecycle.endWindow(token);
|
|
240
|
-
}
|
|
241
239
|
/**
|
|
242
240
|
* Discards the session at a caller's request: cancels queued recoveries and
|
|
243
241
|
* queues the cleanup rather than tearing down under a live request.
|
|
@@ -245,7 +243,6 @@ class AbstractAbapConnection {
|
|
|
245
243
|
reset() {
|
|
246
244
|
this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: true });
|
|
247
245
|
void this.lifecycle.transition('cleanup', async () => {
|
|
248
|
-
await this.lifecycle.drain();
|
|
249
246
|
this.clearSessionState();
|
|
250
247
|
this.lifecycle.markDisconnected();
|
|
251
248
|
});
|
|
@@ -285,6 +282,12 @@ class AbstractAbapConnection {
|
|
|
285
282
|
if (this.lifecycle.teardownEpoch !== baselineEpoch) {
|
|
286
283
|
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested for this connection');
|
|
287
284
|
}
|
|
285
|
+
// Whatever session arrives from here is one we are deliberately
|
|
286
|
+
// establishing, so it must not read as a replacement: the identity policy
|
|
287
|
+
// treats a changed fingerprint as fatal, and it cannot tell our own
|
|
288
|
+
// re-establishment from a session taken out from under us. Forgetting first
|
|
289
|
+
// makes the new fingerprint `established`, which is what it is.
|
|
290
|
+
this.lifecycle.forgetIdentity();
|
|
288
291
|
try {
|
|
289
292
|
await this.establishSession();
|
|
290
293
|
}
|
|
@@ -342,14 +345,13 @@ class AbstractAbapConnection {
|
|
|
342
345
|
* immediately — a later comparison must see the change, and on a dead session
|
|
343
346
|
* the cookie is unchanged, so only the state can tell.
|
|
344
347
|
*
|
|
345
|
-
* Does not await: it is called from inside a request, and the
|
|
346
|
-
*
|
|
348
|
+
* Does not await: it is called from inside a request, and the cleanup must not
|
|
349
|
+
* wait for the very request that raised it.
|
|
347
350
|
*/
|
|
348
351
|
raiseSessionLost(reason) {
|
|
349
352
|
this.logger?.warn(`Session lost: ${reason}`);
|
|
350
353
|
this.lifecycle.beginTeardown({ origin: 'internal', sessionLost: true });
|
|
351
354
|
void this.lifecycle.transition('cleanup', async () => {
|
|
352
|
-
await this.lifecycle.drain();
|
|
353
355
|
this.clearSessionState();
|
|
354
356
|
this.lifecycle.markDisconnected();
|
|
355
357
|
});
|
|
@@ -381,27 +383,44 @@ class AbstractAbapConnection {
|
|
|
381
383
|
* later check reads `unchanged`. That is one call site forgetting, and it
|
|
382
384
|
* happened — on the error path and on every retry response.
|
|
383
385
|
*/
|
|
384
|
-
observeResponse(headers) {
|
|
386
|
+
observeResponse(headers, generation) {
|
|
387
|
+
// Fenced by SESSION GENERATION, not by the teardown epoch. Only a
|
|
388
|
+
// caller-initiated teardown moves the epoch — a recovery deliberately does
|
|
389
|
+
// not — so after a session loss and a successful recovery, a request from
|
|
390
|
+
// the dead session carries the same epoch as the new one and would sail
|
|
391
|
+
// straight through. The generation moves whenever the current session does.
|
|
392
|
+
//
|
|
393
|
+
// `undefined` means "not issued against a session": the CSRF fetch during
|
|
394
|
+
// connect() has no lease and must apply, since it is establishing the very
|
|
395
|
+
// session this would compare against.
|
|
396
|
+
if (generation !== undefined &&
|
|
397
|
+
generation !== this.lifecycle.sessionGeneration) {
|
|
398
|
+
this.logger?.debug('Ignoring a response from a previous session: its effects are fenced');
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
385
401
|
this.applyIdentityPolicy(this.updateCookiesFromResponse(headers));
|
|
386
402
|
}
|
|
387
403
|
/**
|
|
388
404
|
* Acts on what a response said about the session identity.
|
|
389
405
|
*
|
|
390
|
-
* A replacement is fatal
|
|
391
|
-
*
|
|
392
|
-
* windows
|
|
393
|
-
*
|
|
394
|
-
*
|
|
406
|
+
* A replacement is always fatal, and that is a narrowing: an earlier version
|
|
407
|
+
* tolerated it "when no lock is held", deciding from the connection's own
|
|
408
|
+
* lock windows. Those are gone, and rightly — this layer does not know that a
|
|
409
|
+
* lock exists, what object it covers or what would release it. Locks are
|
|
410
|
+
* tracked a layer up, per object, by the code that took them.
|
|
411
|
+
*
|
|
412
|
+
* So the rule is written from what this layer CAN know: the ABAP session we
|
|
413
|
+
* were speaking to is not the one we are speaking to now. Anything the caller
|
|
414
|
+
* held against the old one is dead, and continuing quietly would hand them a
|
|
415
|
+
* session they never opened — the failure this whole design exists to prevent.
|
|
416
|
+
* Being wrong in this direction costs a reconnect; being wrong the other way
|
|
417
|
+
* costs a lock nobody can find.
|
|
395
418
|
*/
|
|
396
419
|
applyIdentityPolicy(classification) {
|
|
397
420
|
if (classification !== 'replaced')
|
|
398
421
|
return;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
this.raiseSessionLost('the session cookie changed while a lock was held');
|
|
404
|
-
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session was replaced while a lock was held; the lock handle is dead');
|
|
422
|
+
this.raiseSessionLost('the session cookie changed under us');
|
|
423
|
+
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session was replaced; anything held against the previous one is dead');
|
|
405
424
|
}
|
|
406
425
|
/**
|
|
407
426
|
* Whether the server is telling us the session it was given no longer exists.
|
|
@@ -476,13 +495,13 @@ class AbstractAbapConnection {
|
|
|
476
495
|
// shut the door.
|
|
477
496
|
const lease = this.lifecycle.admitRequest();
|
|
478
497
|
try {
|
|
479
|
-
return await this.performRequest(options);
|
|
498
|
+
return await this.performRequest(options, lease);
|
|
480
499
|
}
|
|
481
500
|
finally {
|
|
482
501
|
lease.release();
|
|
483
502
|
}
|
|
484
503
|
}
|
|
485
|
-
async performRequest(options) {
|
|
504
|
+
async performRequest(options, lease) {
|
|
486
505
|
const { url: endpoint, method, timeout, data, params, headers: customHeaders, } = options;
|
|
487
506
|
const normalizedMethod = method.toUpperCase();
|
|
488
507
|
// Build full URL: baseUrl + endpoint
|
|
@@ -578,7 +597,7 @@ class AbstractAbapConnection {
|
|
|
578
597
|
});
|
|
579
598
|
try {
|
|
580
599
|
const response = await this.getAxiosInstance()(requestConfig);
|
|
581
|
-
this.observeResponse(response.headers);
|
|
600
|
+
this.observeResponse(response.headers, lease.generation);
|
|
582
601
|
this.logger?.debug(`Request succeeded with status ${response.status}`, {
|
|
583
602
|
type: 'REQUEST_SUCCESS',
|
|
584
603
|
status: response.status,
|
|
@@ -588,6 +607,20 @@ class AbstractAbapConnection {
|
|
|
588
607
|
return response;
|
|
589
608
|
}
|
|
590
609
|
catch (error) {
|
|
610
|
+
// FENCE FIRST, before anything reads or writes shared state.
|
|
611
|
+
//
|
|
612
|
+
// Fencing observeResponse() alone was not enough, and the gap was wide:
|
|
613
|
+
// everything below acts on this connection, not on the request. A late
|
|
614
|
+
// 400 "session not found" would call raiseSessionLost() and tear down the
|
|
615
|
+
// healthy session established since; a late 401/403 would call
|
|
616
|
+
// invalidateSession(), write a fresh CSRF token, and RETRY — replaying a
|
|
617
|
+
// mutation from a dead session inside the live one.
|
|
618
|
+
//
|
|
619
|
+
// A stale request gets its error back and nothing else happens.
|
|
620
|
+
if (!this.lifecycle.isCurrent(lease)) {
|
|
621
|
+
this.logger?.debug('A request from a previous session failed; its recovery is fenced');
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
591
624
|
const errorDetails = {
|
|
592
625
|
type: 'REQUEST_ERROR',
|
|
593
626
|
message: error instanceof Error ? error.message : String(error),
|
|
@@ -601,7 +634,7 @@ class AbstractAbapConnection {
|
|
|
601
634
|
typeof error.response.data === 'string'
|
|
602
635
|
? error.response.data.slice(0, 200)
|
|
603
636
|
: JSON.stringify(error.response.data).slice(0, 200);
|
|
604
|
-
this.observeResponse(error.response.headers);
|
|
637
|
+
this.observeResponse(error.response.headers, lease.generation);
|
|
605
638
|
}
|
|
606
639
|
// The server telling us the session is gone is invisible to the identity
|
|
607
640
|
// comparison: the cookie, and therefore the fingerprint, is unchanged.
|
|
@@ -653,7 +686,7 @@ class AbstractAbapConnection {
|
|
|
653
686
|
delete requestHeaders.cookie;
|
|
654
687
|
}
|
|
655
688
|
try {
|
|
656
|
-
this.setCsrfToken(await this.fetchCsrfToken(requestUrl, 5, 2000));
|
|
689
|
+
this.setCsrfToken(await this.fetchCsrfToken(requestUrl, 5, 2000, lease.generation));
|
|
657
690
|
const refreshedToken = this.getCsrfToken();
|
|
658
691
|
if (refreshedToken) {
|
|
659
692
|
requestHeaders['x-csrf-token'] = refreshedToken;
|
|
@@ -663,7 +696,7 @@ class AbstractAbapConnection {
|
|
|
663
696
|
requestHeaders.Cookie = refreshedCookies;
|
|
664
697
|
}
|
|
665
698
|
const retryResponse = await this.getAxiosInstance()(requestConfig);
|
|
666
|
-
this.observeResponse(retryResponse.headers);
|
|
699
|
+
this.observeResponse(retryResponse.headers, lease.generation);
|
|
667
700
|
return retryResponse;
|
|
668
701
|
}
|
|
669
702
|
catch (retryError) {
|
|
@@ -691,19 +724,19 @@ class AbstractAbapConnection {
|
|
|
691
724
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - 401 on GET request, retrying with cookies from error response`);
|
|
692
725
|
requestHeaders.Cookie = this.cookies;
|
|
693
726
|
const retryResponse = await this.getAxiosInstance()(requestConfig);
|
|
694
|
-
this.observeResponse(retryResponse.headers);
|
|
727
|
+
this.observeResponse(retryResponse.headers, lease.generation);
|
|
695
728
|
return retryResponse;
|
|
696
729
|
}
|
|
697
730
|
// If no cookies, try to get them via CSRF token fetch
|
|
698
731
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - 401 on GET request, attempting to get cookies via CSRF token fetch`);
|
|
699
732
|
try {
|
|
700
733
|
// Try to get CSRF token (this will also get cookies)
|
|
701
|
-
this.csrfToken = await this.fetchCsrfToken(requestUrl, 3, 1000);
|
|
734
|
+
this.csrfToken = await this.fetchCsrfToken(requestUrl, 3, 1000, lease.generation);
|
|
702
735
|
if (this.cookies) {
|
|
703
736
|
requestHeaders.Cookie = this.cookies;
|
|
704
737
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Retrying GET request with cookies from CSRF fetch`);
|
|
705
738
|
const retryResponse = await this.getAxiosInstance()(requestConfig);
|
|
706
|
-
this.observeResponse(retryResponse.headers);
|
|
739
|
+
this.observeResponse(retryResponse.headers, lease.generation);
|
|
707
740
|
return retryResponse;
|
|
708
741
|
}
|
|
709
742
|
}
|
|
@@ -722,7 +755,9 @@ class AbstractAbapConnection {
|
|
|
722
755
|
* Fetch CSRF token from SAP system
|
|
723
756
|
* Protected method for use by concrete implementations in their connect() method
|
|
724
757
|
*/
|
|
725
|
-
async fetchCsrfToken(url, retryCount = csrfConfig_js_1.CSRF_CONFIG.RETRY_COUNT, retryDelay = csrfConfig_js_1.CSRF_CONFIG.RETRY_DELAY
|
|
758
|
+
async fetchCsrfToken(url, retryCount = csrfConfig_js_1.CSRF_CONFIG.RETRY_COUNT, retryDelay = csrfConfig_js_1.CSRF_CONFIG.RETRY_DELAY,
|
|
759
|
+
/** Fences the response effects; omitted during connect(), which has no lease. */
|
|
760
|
+
generation) {
|
|
726
761
|
// Try primary endpoint first, then fallback for older systems
|
|
727
762
|
const baseUrl = url.includes('/sap/bc/adt/')
|
|
728
763
|
? url.split('/sap/bc/adt')[0]
|
|
@@ -746,7 +781,7 @@ class AbstractAbapConnection {
|
|
|
746
781
|
let lastError;
|
|
747
782
|
for (const csrfUrl of endpoints) {
|
|
748
783
|
try {
|
|
749
|
-
return await this.fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay);
|
|
784
|
+
return await this.fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay, generation);
|
|
750
785
|
}
|
|
751
786
|
catch (error) {
|
|
752
787
|
// Third layer with a catch on this path, and the last one that could
|
|
@@ -766,7 +801,7 @@ class AbstractAbapConnection {
|
|
|
766
801
|
/**
|
|
767
802
|
* Fetch CSRF token from a specific endpoint with retries
|
|
768
803
|
*/
|
|
769
|
-
async fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay) {
|
|
804
|
+
async fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay, generation) {
|
|
770
805
|
this.logger?.debug(`Fetching CSRF token from: ${csrfUrl}`);
|
|
771
806
|
for (let attempt = 0; attempt <= retryCount; attempt++) {
|
|
772
807
|
try {
|
|
@@ -804,7 +839,7 @@ class AbstractAbapConnection {
|
|
|
804
839
|
headers,
|
|
805
840
|
timeout: (0, timeouts_js_1.getTimeout)('csrf'),
|
|
806
841
|
});
|
|
807
|
-
this.observeResponse(response.headers);
|
|
842
|
+
this.observeResponse(response.headers, generation);
|
|
808
843
|
const token = response.headers['x-csrf-token'];
|
|
809
844
|
if (!token) {
|
|
810
845
|
this.logger?.error('No CSRF token in response headers', {
|
|
@@ -818,7 +853,7 @@ class AbstractAbapConnection {
|
|
|
818
853
|
throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
|
|
819
854
|
}
|
|
820
855
|
if (response.headers['set-cookie']) {
|
|
821
|
-
this.observeResponse(response.headers);
|
|
856
|
+
this.observeResponse(response.headers, generation);
|
|
822
857
|
if (this.cookies) {
|
|
823
858
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Cookies received from CSRF response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
|
|
824
859
|
this.logger?.debug('Cookies extracted from response', {
|
|
@@ -842,7 +877,7 @@ class AbstractAbapConnection {
|
|
|
842
877
|
// Always try to extract cookies from error response, even on 401
|
|
843
878
|
// This ensures cookies are available for subsequent requests
|
|
844
879
|
if (error.response?.headers) {
|
|
845
|
-
this.observeResponse(error.response.headers);
|
|
880
|
+
this.observeResponse(error.response.headers, generation);
|
|
846
881
|
if (this.cookies) {
|
|
847
882
|
this.logger?.debug('Cookies extracted from error response', {
|
|
848
883
|
status: error.response.status,
|
|
@@ -861,14 +896,14 @@ class AbstractAbapConnection {
|
|
|
861
896
|
this.logger?.debug('CSRF: SAP returned 405 (Method Not Allowed) — not critical, token found in header');
|
|
862
897
|
const token = error.response.headers['x-csrf-token'];
|
|
863
898
|
if (token) {
|
|
864
|
-
this.observeResponse(error.response.headers);
|
|
899
|
+
this.observeResponse(error.response.headers, generation);
|
|
865
900
|
return token;
|
|
866
901
|
}
|
|
867
902
|
}
|
|
868
903
|
if (error.response?.headers['x-csrf-token']) {
|
|
869
904
|
this.logger?.debug(`Got CSRF token despite error (status: ${error.response?.status})`);
|
|
870
905
|
const token = error.response.headers['x-csrf-token'];
|
|
871
|
-
this.observeResponse(error.response.headers);
|
|
906
|
+
this.observeResponse(error.response.headers, generation);
|
|
872
907
|
return token;
|
|
873
908
|
}
|
|
874
909
|
if (error.response) {
|
|
@@ -1037,6 +1072,13 @@ class AbstractAbapConnection {
|
|
|
1037
1072
|
this.setCsrfToken(null);
|
|
1038
1073
|
this.cookies = null;
|
|
1039
1074
|
this.cookieStore.clear();
|
|
1075
|
+
// And the tracked identity, because WE discarded the session. Without this
|
|
1076
|
+
// the cookie that arrives next reads as a foreign replacement — and since a
|
|
1077
|
+
// replacement is now always fatal, our own deliberate re-authentication
|
|
1078
|
+
// would tear the connection down. The distinction that matters is not
|
|
1079
|
+
// "was a lock held" but "did we cause this": what we discarded on purpose
|
|
1080
|
+
// is not a session taken from under us.
|
|
1081
|
+
this.lifecycle.forgetIdentity();
|
|
1040
1082
|
}
|
|
1041
1083
|
shouldRetryCsrf(error) {
|
|
1042
1084
|
if (!(error instanceof axios_1.AxiosError)) {
|
package/dist/index.js
CHANGED
|
@@ -7,10 +7,10 @@ Object.defineProperty(exports, "FileCertificateMaterialLoader", { enumerable: tr
|
|
|
7
7
|
// Config utilities
|
|
8
8
|
var sapConfig_js_1 = require("./config/sapConfig.js");
|
|
9
9
|
Object.defineProperty(exports, "sapConfigSignature", { enumerable: true, get: function () { return sapConfig_js_1.sapConfigSignature; } });
|
|
10
|
-
// The session lifecycle vocabulary — ISessionLifecycleAware,
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// The session lifecycle vocabulary — ISessionLifecycleAware, ADT_SESSION_ERROR —
|
|
11
|
+
// is deliberately NOT exported here. It lives in @mcp-abap-adt/interfaces, and a
|
|
12
|
+
// consumer imports it from there: re-exporting a contract type gives it two
|
|
13
|
+
// names and lets the two drift.
|
|
14
14
|
// Connection classes - only final implementations
|
|
15
15
|
// Deprecated aliases for backward compatibility
|
|
16
16
|
var BaseAbapConnection_js_1 = require("./connection/BaseAbapConnection.js");
|