@mcp-abap-adt/connection 3.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +337 -1
- package/README.md +74 -31
- package/dist/auth/IAuthProvider.d.ts +84 -0
- package/dist/auth/IAuthProvider.d.ts.map +1 -0
- package/dist/auth/IAuthProvider.js +21 -0
- package/dist/auth/providers.d.ts +95 -0
- package/dist/auth/providers.d.ts.map +1 -0
- package/dist/auth/providers.js +140 -0
- package/dist/connection/AbstractAbapConnection.d.ts +222 -17
- package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
- package/dist/connection/AbstractAbapConnection.js +555 -31
- package/dist/connection/AdtCloudConnector.d.ts +29 -0
- package/dist/connection/AdtCloudConnector.d.ts.map +1 -0
- package/dist/connection/AdtCloudConnector.js +31 -0
- package/dist/connection/AdtOnPremConnector.d.ts +30 -0
- package/dist/connection/AdtOnPremConnector.d.ts.map +1 -0
- package/dist/connection/AdtOnPremConnector.js +32 -0
- package/dist/connection/BaseAbapConnection.d.ts +7 -1
- package/dist/connection/BaseAbapConnection.d.ts.map +1 -1
- package/dist/connection/BaseAbapConnection.js +8 -2
- package/dist/connection/CertificateAbapConnection.d.ts +11 -1
- package/dist/connection/CertificateAbapConnection.d.ts.map +1 -1
- package/dist/connection/CertificateAbapConnection.js +14 -2
- package/dist/connection/CredentialAbapConnection.d.ts +88 -0
- package/dist/connection/CredentialAbapConnection.d.ts.map +1 -0
- package/dist/connection/CredentialAbapConnection.js +195 -0
- package/dist/connection/JwtAbapConnection.d.ts +102 -9
- package/dist/connection/JwtAbapConnection.d.ts.map +1 -1
- package/dist/connection/JwtAbapConnection.js +266 -82
- package/dist/connection/KerberosAbapConnection.d.ts +9 -1
- package/dist/connection/KerberosAbapConnection.d.ts.map +1 -1
- package/dist/connection/KerberosAbapConnection.js +9 -1
- package/dist/connection/RfcAbapConnection.d.ts +0 -5
- package/dist/connection/RfcAbapConnection.d.ts.map +1 -1
- package/dist/connection/RfcAbapConnection.js +0 -7
- package/dist/connection/SamlAbapConnection.d.ts +7 -1
- package/dist/connection/SamlAbapConnection.d.ts.map +1 -1
- package/dist/connection/SamlAbapConnection.js +8 -2
- package/dist/connection/connectionFactory.d.ts +16 -0
- package/dist/connection/connectionFactory.d.ts.map +1 -1
- package/dist/connection/connectionFactory.js +52 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/session/CloudSecuritySessionStrategy.d.ts +32 -0
- package/dist/session/CloudSecuritySessionStrategy.d.ts.map +1 -0
- package/dist/session/CloudSecuritySessionStrategy.js +135 -0
- package/dist/session/IcfSessionStrategy.d.ts +27 -0
- package/dist/session/IcfSessionStrategy.d.ts.map +1 -0
- package/dist/session/IcfSessionStrategy.js +62 -0
- package/dist/session/SessionLifecycle.d.ts +17 -2
- package/dist/session/SessionLifecycle.d.ts.map +1 -1
- package/dist/session/SessionLifecycle.js +17 -2
- package/dist/session/SessionStrategy.d.ts +86 -0
- package/dist/session/SessionStrategy.d.ts.map +1 -0
- package/dist/session/SessionStrategy.js +33 -0
- package/dist/utils/cookies.d.ts +12 -0
- package/dist/utils/cookies.d.ts.map +1 -0
- package/dist/utils/cookies.js +24 -0
- package/dist/utils/timeouts.d.ts +16 -0
- package/dist/utils/timeouts.d.ts.map +1 -1
- package/dist/utils/timeouts.js +19 -0
- package/docs/INDEX.md +5 -0
- package/docs/INSTALLATION.md +6 -2
- package/docs/MIGRATION-2.0.md +1 -1
- package/docs/MIGRATION-4.0.md +95 -0
- package/docs/MIGRATION-5.0.md +116 -0
- package/docs/STATEFUL_SESSION_GUIDE.md +83 -11
- package/docs/USAGE.md +115 -24
- package/docs/superpowers/specs/2026-08-21-platform-connectors.md +108 -0
- package/examples/README.md +2 -1
- package/examples/jwt-with-token-refresh.js +11 -3
- package/package.json +1 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* How a session is opened on the server, and how the server is told we are
|
|
4
|
+
* done with it.
|
|
5
|
+
*
|
|
6
|
+
* Two systems, two mechanisms, one meaning. On ABAP Cloud a session is a
|
|
7
|
+
* resource: it is asked for at `/sap/bc/adt/core/http/sessions` and the
|
|
8
|
+
* response publishes its own address, which is what a `DELETE` is sent to. On
|
|
9
|
+
* on-prem there is no such resource — the logon call is the establishing
|
|
10
|
+
* request itself, and the platform's `/sap/public/bc/icf/logoff` is how it is
|
|
11
|
+
* given back.
|
|
12
|
+
*
|
|
13
|
+
* **Both are notifications, not commands.** They say "we have finished with
|
|
14
|
+
* this session". Whether the system frees it now, later, or keeps it to reuse
|
|
15
|
+
* is the system's business, and nothing here checks afterwards or depends on
|
|
16
|
+
* the answer. Measured on a trial: the `DELETE` is answered `200` and the
|
|
17
|
+
* session is still listed a moment later; the logoff is answered `200` and it
|
|
18
|
+
* is not. Neither is a failure — they are two systems deciding differently
|
|
19
|
+
* about a message they both accepted.
|
|
20
|
+
*
|
|
21
|
+
* Split by what the server publishes rather than by which system we think we
|
|
22
|
+
* are talking to: `openSession()` asks, and a system that has no such resource
|
|
23
|
+
* says so. Guessing the platform would be a guess; asking is not.
|
|
24
|
+
*/
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.SessionStrategy = void 0;
|
|
27
|
+
class SessionStrategy {
|
|
28
|
+
logger;
|
|
29
|
+
constructor(logger) {
|
|
30
|
+
this.logger = logger;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
exports.SessionStrategy = SessionStrategy;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Join two `Cookie` header values, later wins on a repeated name.
|
|
3
|
+
*
|
|
4
|
+
* Used wherever a request already carries cookies and more are added: a
|
|
5
|
+
* credential can BE cookies — a SAML session is handed over as `MYSAPSSO2` —
|
|
6
|
+
* so writing the session jar over the header drops the very thing the request
|
|
7
|
+
* authenticates with. Both belong on the wire: one says who we are, the other
|
|
8
|
+
* which session we are in. A name in both is the session's, because that is the
|
|
9
|
+
* one the server just issued.
|
|
10
|
+
*/
|
|
11
|
+
export declare function mergeCookieHeaders(existing: string | undefined, incoming: string | undefined): string;
|
|
12
|
+
//# sourceMappingURL=cookies.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookies.d.ts","sourceRoot":"","sources":["../../src/utils/cookies.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,QAAQ,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,CASR"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mergeCookieHeaders = mergeCookieHeaders;
|
|
4
|
+
/**
|
|
5
|
+
* Join two `Cookie` header values, later wins on a repeated name.
|
|
6
|
+
*
|
|
7
|
+
* Used wherever a request already carries cookies and more are added: a
|
|
8
|
+
* credential can BE cookies — a SAML session is handed over as `MYSAPSSO2` —
|
|
9
|
+
* so writing the session jar over the header drops the very thing the request
|
|
10
|
+
* authenticates with. Both belong on the wire: one says who we are, the other
|
|
11
|
+
* which session we are in. A name in both is the session's, because that is the
|
|
12
|
+
* one the server just issued.
|
|
13
|
+
*/
|
|
14
|
+
function mergeCookieHeaders(existing, incoming) {
|
|
15
|
+
const parts = [existing, incoming].filter(Boolean).join('; ');
|
|
16
|
+
const byName = new Map();
|
|
17
|
+
for (const part of parts.split(';')) {
|
|
18
|
+
const pair = part.trim();
|
|
19
|
+
if (!pair)
|
|
20
|
+
continue;
|
|
21
|
+
byName.set(pair.slice(0, pair.indexOf('=')), pair);
|
|
22
|
+
}
|
|
23
|
+
return [...byName.values()].join('; ');
|
|
24
|
+
}
|
package/dist/utils/timeouts.d.ts
CHANGED
|
@@ -12,4 +12,20 @@ export declare function getTimeout(type?: 'default' | 'csrf' | 'long' | number):
|
|
|
12
12
|
* permanently dead socket hanging forever.
|
|
13
13
|
*/
|
|
14
14
|
export declare function getCriticalSectionTimeout(): number;
|
|
15
|
+
/**
|
|
16
|
+
* How long `disconnect()` may spend telling the server the session is done.
|
|
17
|
+
*
|
|
18
|
+
* **Zero by default: after a disconnect nothing waits on the answer.** Waiting
|
|
19
|
+
* is for steps whose successor depends on the server having caught up — lock,
|
|
20
|
+
* update, unlock, activate, each needing the one before it to have landed.
|
|
21
|
+
* A teardown has no successor: the session is marked unneeded, the server
|
|
22
|
+
* reclaims it whenever it reclaims it, and a caller blocked on that round trip
|
|
23
|
+
* has bought nothing while holding up the serializing tail, where every later
|
|
24
|
+
* connect and disconnect queues behind it.
|
|
25
|
+
*
|
|
26
|
+
* The knob exists for a caller that wants a bounded wait anyway — a test
|
|
27
|
+
* asserting the logoff landed, a script that would rather see the failure —
|
|
28
|
+
* and is theirs to set per call via `ISessionLifecycleAware.disconnect`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function getReleaseDeadline(): number;
|
|
15
31
|
//# sourceMappingURL=timeouts.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"timeouts.d.ts","sourceRoot":"","sources":["../../src/utils/timeouts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAG/D,MAAM,MAAM,aAAa,GAAG,cAAc,CAAC;AAE3C,wBAAgB,gBAAgB,IAAI,cAAc,CAajD;AAED,wBAAgB,UAAU,CACxB,IAAI,GAAE,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,MAAkB,GACrD,MAAM,CAOR;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,IAAI,MAAM,CAElD"}
|
|
1
|
+
{"version":3,"file":"timeouts.d.ts","sourceRoot":"","sources":["../../src/utils/timeouts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAG/D,MAAM,MAAM,aAAa,GAAG,cAAc,CAAC;AAE3C,wBAAgB,gBAAgB,IAAI,cAAc,CAajD;AAED,wBAAgB,UAAU,CACxB,IAAI,GAAE,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,MAAkB,GACrD,MAAM,CAOR;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,IAAI,MAAM,CAElD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C"}
|
package/dist/utils/timeouts.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.getTimeoutConfig = getTimeoutConfig;
|
|
4
4
|
exports.getTimeout = getTimeout;
|
|
5
5
|
exports.getCriticalSectionTimeout = getCriticalSectionTimeout;
|
|
6
|
+
exports.getReleaseDeadline = getReleaseDeadline;
|
|
6
7
|
function getTimeoutConfig() {
|
|
7
8
|
const defaultTimeout = parseInt(process.env.SAP_TIMEOUT_DEFAULT || '45000', 10);
|
|
8
9
|
const csrfTimeout = parseInt(process.env.SAP_TIMEOUT_CSRF || '15000', 10);
|
|
@@ -32,3 +33,21 @@ function getTimeout(type = 'default') {
|
|
|
32
33
|
function getCriticalSectionTimeout() {
|
|
33
34
|
return parseInt(process.env.SAP_TIMEOUT_CRITICAL || '600000', 10);
|
|
34
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* How long `disconnect()` may spend telling the server the session is done.
|
|
38
|
+
*
|
|
39
|
+
* **Zero by default: after a disconnect nothing waits on the answer.** Waiting
|
|
40
|
+
* is for steps whose successor depends on the server having caught up — lock,
|
|
41
|
+
* update, unlock, activate, each needing the one before it to have landed.
|
|
42
|
+
* A teardown has no successor: the session is marked unneeded, the server
|
|
43
|
+
* reclaims it whenever it reclaims it, and a caller blocked on that round trip
|
|
44
|
+
* has bought nothing while holding up the serializing tail, where every later
|
|
45
|
+
* connect and disconnect queues behind it.
|
|
46
|
+
*
|
|
47
|
+
* The knob exists for a caller that wants a bounded wait anyway — a test
|
|
48
|
+
* asserting the logoff landed, a script that would rather see the failure —
|
|
49
|
+
* and is theirs to set per call via `ISessionLifecycleAware.disconnect`.
|
|
50
|
+
*/
|
|
51
|
+
function getReleaseDeadline() {
|
|
52
|
+
return parseInt(process.env.SAP_RELEASE_DEADLINE_MS || '0', 10);
|
|
53
|
+
}
|
package/docs/INDEX.md
CHANGED
|
@@ -15,6 +15,7 @@ mcp-abap-connection/
|
|
|
15
15
|
│ ├── INSTALLATION.md # Setup and installation guide
|
|
16
16
|
│ ├── USAGE.md # API documentation and examples
|
|
17
17
|
│ ├── MIGRATION-2.0.md # Moving to the explicit session lifecycle
|
|
18
|
+
│ ├── MIGRATION-4.0.md # JWT error classification: 401 refreshes, 403 propagates
|
|
18
19
|
│ ├── SCOPE.md # What this package does and does not own
|
|
19
20
|
│ ├── STATEFUL_SESSION_GUIDE.md # Stateful requests and lock windows
|
|
20
21
|
│ └── JWT_AUTH_TOOLS.md # CLI tool for authentication
|
|
@@ -41,6 +42,10 @@ mcp-abap-connection/
|
|
|
41
42
|
### Core Features
|
|
42
43
|
- 🔑 [JWT Auth Tools](./JWT_AUTH_TOOLS.md) - CLI tool for browser-based authentication
|
|
43
44
|
|
|
45
|
+
### Upgrading
|
|
46
|
+
- 🧱 [Migrating to 4.0.0](./MIGRATION-4.0.md) - JWT error classification: a 401 refreshes, a 403 propagates with the server's message
|
|
47
|
+
- 🧱 [Migrating to 2.0.0](./MIGRATION-2.0.md) - The explicit session lifecycle: `connect()` is required
|
|
48
|
+
|
|
44
49
|
### Version Information
|
|
45
50
|
- 📋 [CHANGELOG](../CHANGELOG.md) - Complete version history, including what the latest release changed
|
|
46
51
|
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -163,7 +163,9 @@ const logger = {
|
|
|
163
163
|
debug: (msg) => console.log('[DEBUG]', msg),
|
|
164
164
|
};
|
|
165
165
|
|
|
166
|
-
const connection = createAbapConnection(config, logger
|
|
166
|
+
const connection = createAbapConnection(config, logger, undefined, undefined, {
|
|
167
|
+
system: 'onprem', // or 'cloud' — said by you, never detected
|
|
168
|
+
});
|
|
167
169
|
|
|
168
170
|
connection.connect()
|
|
169
171
|
.then(() =>
|
|
@@ -231,7 +233,9 @@ const logger: ILogger = {
|
|
|
231
233
|
debug: (msg: string) => console.log(msg),
|
|
232
234
|
};
|
|
233
235
|
|
|
234
|
-
const connection = createAbapConnection(config, logger
|
|
236
|
+
const connection = createAbapConnection(config, logger, undefined, undefined, {
|
|
237
|
+
system: 'onprem', // or 'cloud' — said by you, never detected
|
|
238
|
+
});
|
|
235
239
|
```
|
|
236
240
|
|
|
237
241
|
## Troubleshooting
|
package/docs/MIGRATION-2.0.md
CHANGED
|
@@ -46,7 +46,7 @@ later, on the first request, as an unrelated-looking 401.
|
|
|
46
46
|
|
|
47
47
|
### 3. Requests are refused after a teardown
|
|
48
48
|
|
|
49
|
-
`disconnect()`
|
|
49
|
+
`disconnect()` stops the connection from serving requests until the
|
|
50
50
|
next `connect()`. An in-flight request is not cut off and not waited for either:
|
|
51
51
|
it runs to completion, and its result is fenced so it cannot touch a session
|
|
52
52
|
established since.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Migrating to 4.0.0: the server's error reaches you
|
|
2
|
+
|
|
3
|
+
A JWT connection used to answer some failures with an error of its own making:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Error: JWT token has expired. Please re-authenticate.
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It said that for a **401** and for a **403** alike, and it threw the original `AxiosError` away
|
|
10
|
+
along with the status and the body. Now the server's error reaches the caller unchanged, and only
|
|
11
|
+
a 401 is treated as a credential problem.
|
|
12
|
+
|
|
13
|
+
## What breaks, and what to do
|
|
14
|
+
|
|
15
|
+
### 1. That message is gone
|
|
16
|
+
|
|
17
|
+
If anything matches on it, it will stop matching.
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
// before
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.message.includes('JWT token has expired')) {
|
|
23
|
+
await reauthenticate();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// after
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.response?.status === 401) {
|
|
30
|
+
await reauthenticate();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`error.response.status` and `error.response.data` are available now — they were not before,
|
|
36
|
+
because the replacement error carried neither.
|
|
37
|
+
|
|
38
|
+
**Why it changed.** The message was wrong about half the cases it was used for. A 403 is not an
|
|
39
|
+
expired token, and telling a caller to re-authenticate for one sends them to fix the single thing
|
|
40
|
+
that is not broken.
|
|
41
|
+
|
|
42
|
+
### 2. A 403 no longer triggers a token refresh
|
|
43
|
+
|
|
44
|
+
It propagates as it arrived. If your code relied on a refresh happening after a 403 — for
|
|
45
|
+
instance to recover from an authorization gap that a *different* principal would not have — that
|
|
46
|
+
no longer happens, and it never worked for the reason you may have assumed: a refreshed token
|
|
47
|
+
belongs to the same caller and cannot change a permissions answer.
|
|
48
|
+
|
|
49
|
+
What a 403 usually carries is worth reading:
|
|
50
|
+
|
|
51
|
+
```xml
|
|
52
|
+
<exc:exception>
|
|
53
|
+
<type id="ExceptionResourceNoAuthorization"/>
|
|
54
|
+
<message lang="EN">You are not authorized to make changes (authorization object S_DEVELOP)</message>
|
|
55
|
+
</exc:exception>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The authorization object is named. That is actionable; "please re-authenticate" was not.
|
|
59
|
+
|
|
60
|
+
### 3. A 401 still refreshes, and now says so when it does not help
|
|
61
|
+
|
|
62
|
+
The happy path is unchanged: a 401 with an injected `ITokenRefresher` fetches a new token,
|
|
63
|
+
re-establishes the session and retries. What changed is the unhappy one — when the retry comes
|
|
64
|
+
back 401 as well, the original error is rethrown and an `ERROR`-level log line explains that the
|
|
65
|
+
credential may genuinely need re-authentication.
|
|
66
|
+
|
|
67
|
+
## What did not change
|
|
68
|
+
|
|
69
|
+
- `ITokenRefresher` and how it is injected;
|
|
70
|
+
- that a credential renewal replaces the SAP session — with a lock window open, a request still
|
|
71
|
+
fails with `ADT_SESSION_REPLACED` rather than continuing on a session your lock is not in (see
|
|
72
|
+
[STATEFUL_SESSION_GUIDE.md](./STATEFUL_SESSION_GUIDE.md));
|
|
73
|
+
- every other authentication type. `BaseAbapConnection`, `SamlAbapConnection`,
|
|
74
|
+
`CertificateAbapConnection` and `KerberosAbapConnection` never refreshed tokens and never
|
|
75
|
+
synthesised an error in place of the server's.
|
|
76
|
+
|
|
77
|
+
## Under the hood, if you are debugging
|
|
78
|
+
|
|
79
|
+
Concurrent requests that hit the same expired token now share **one** renewal — one token fetch
|
|
80
|
+
and one session re-establishment between them — rather than each running its own teardown and
|
|
81
|
+
recovery in sequence. If you have log-line counts or timing expectations built around the old
|
|
82
|
+
behaviour, they will change; the observable contract does not.
|
|
83
|
+
|
|
84
|
+
## Where this came from
|
|
85
|
+
|
|
86
|
+
Issue [#30](https://github.com/fr0ster/mcp-abap-connection/issues/30), found while probing ATC on
|
|
87
|
+
a cloud trial: creating a classic program was reported as an expired token on three consecutive
|
|
88
|
+
runs, while the same session was answering other requests with 200. It took bypassing the
|
|
89
|
+
connector on the same credential to see the 403 and the `S_DEVELOP` message underneath.
|
|
90
|
+
|
|
91
|
+
One question is deliberately left open and tracked in
|
|
92
|
+
[#32](https://github.com/fr0ster/mcp-abap-connection/issues/32): whether some BTP setup answers an
|
|
93
|
+
*invalid* token with 403 rather than 401. Nothing captured shows that it does, and preserving the
|
|
94
|
+
original error is what makes the assumption safe to be wrong about — a caller would then see the
|
|
95
|
+
server's own 403 instead of a misleading message.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Migrating to 5.0.0
|
|
2
|
+
|
|
3
|
+
Two things changed, and the second is why the first was possible.
|
|
4
|
+
|
|
5
|
+
**A connection now closes what it opened.** `disconnect()` tells the server the
|
|
6
|
+
session is finished instead of only dropping the cookie, and `connect()` fails
|
|
7
|
+
when the server opened no session rather than handing back a connection whose
|
|
8
|
+
first lock would be dead.
|
|
9
|
+
|
|
10
|
+
**The class you take says which system you are dialling, not which credential
|
|
11
|
+
you hold.** `AdtOnPremConnector` and `AdtCloudConnector` are handed an auth
|
|
12
|
+
provider; the five auth classes still work and are deprecated.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## If you use `createAbapConnection()`
|
|
17
|
+
|
|
18
|
+
It still works, unchanged, and now warns once per call that the connection is
|
|
19
|
+
being chosen from `authType`. Say which system instead:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// before
|
|
23
|
+
const conn = createAbapConnection(config, logger, undefined, tokenRefresher);
|
|
24
|
+
|
|
25
|
+
// after
|
|
26
|
+
const conn = createAbapConnection(config, logger, undefined, tokenRefresher, {
|
|
27
|
+
system: 'cloud', // or 'onprem'
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Nothing is detected. The two systems do not manage sessions the same way, and
|
|
32
|
+
asking the server which it is does not work: `/sap/bc/adt/core/http/sessions`
|
|
33
|
+
answers on on-prem too, and its `DELETE` there leaves the session open while the
|
|
34
|
+
platform logoff removes it. Only you know where you are pointing.
|
|
35
|
+
|
|
36
|
+
## If you build a connection class directly
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// before // after
|
|
40
|
+
new BaseAbapConnection(cfg, log) new AdtOnPremConnector(cfg, new BasicAuthProvider(user, pass), log)
|
|
41
|
+
new JwtAbapConnection(cfg, log, id, refr) new AdtCloudConnector(cfg, new TokenAuthProvider(refr), log)
|
|
42
|
+
new SamlAbapConnection(cfg, log) new AdtOnPremConnector(cfg, new SamlAuthProvider(cookies), log)
|
|
43
|
+
new CertificateAbapConnection(cfg, log) new AdtOnPremConnector(cfg, new CertificateAuthProvider(loader, cfg), log)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`KerberosAbapConnection` has no provider yet — its SPNEGO exchange *is* the CSRF
|
|
47
|
+
fetch — so keep using the class. See #35.
|
|
48
|
+
|
|
49
|
+
The old classes are not removed and not scheduled for removal here.
|
|
50
|
+
|
|
51
|
+
## `reset()` is gone — BREAKING
|
|
52
|
+
|
|
53
|
+
There is no local-only discard, because there is no local-only session: it lives
|
|
54
|
+
on the server, and dropping the cookie leaves it there.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
conn.reset(); // before
|
|
58
|
+
await conn.disconnect(); // after — and connect() again to carry on
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A caller that does not want to wait simply does not `await` it, which is what
|
|
62
|
+
`reset()` was mostly used for. `RfcAbapConnection` keeps `close()`.
|
|
63
|
+
|
|
64
|
+
## `connect()` can now fail where it used to warn — BREAKING
|
|
65
|
+
|
|
66
|
+
When the server authenticates the request but opens no session, `connect()`
|
|
67
|
+
rejects with `ADT_NOT_CONNECTED` and a message saying what happened, what still
|
|
68
|
+
works, and the usual cause. It used to log a warning and hand the connection
|
|
69
|
+
back — and the failure then surfaced a request later, as `400 Session not found`
|
|
70
|
+
with the object half-edited.
|
|
71
|
+
|
|
72
|
+
Verified rather than assumed: a connection that received no `SAP_SESSIONID` is
|
|
73
|
+
listed in the server's own session list as **nothing at all**.
|
|
74
|
+
|
|
75
|
+
Nothing is retried on your behalf. Whether to wait, retry, or release sessions
|
|
76
|
+
the user still holds depends on things only you know.
|
|
77
|
+
|
|
78
|
+
## `disconnect()` now makes a network call
|
|
79
|
+
|
|
80
|
+
It tells the server the session is finished. Two consequences:
|
|
81
|
+
|
|
82
|
+
- **It can wait.** By default it does not: `SAP_RELEASE_DEADLINE_MS` is `0`,
|
|
83
|
+
because waiting is for steps whose successor needs the server to have caught
|
|
84
|
+
up, and a teardown has none. Pass `disconnect({ deadlineMs })` if you want a
|
|
85
|
+
bounded wait — the deadline bounds the **wait**, never the request.
|
|
86
|
+
- **Requests still in flight will start failing.** The session they are running
|
|
87
|
+
on is the one being released. That is you having asked to disconnect, not a
|
|
88
|
+
race — finish or abandon your chains first.
|
|
89
|
+
|
|
90
|
+
It still never throws, including on a nonsense `deadlineMs`: its place is a
|
|
91
|
+
`finally`, where an exception would replace the error that sent you there.
|
|
92
|
+
|
|
93
|
+
## Token renewal, if you use a provider
|
|
94
|
+
|
|
95
|
+
Nothing to do, but worth knowing what changed. The connector no longer caches
|
|
96
|
+
the token it was given. It asks the provider per request — cheap, because the
|
|
97
|
+
provider caches — and on a `401` it tells the provider its answer was refused
|
|
98
|
+
(`refreshToken()`, per that contract), asks again, and only if the answer
|
|
99
|
+
changed rebuilds the session and retries once. An unchanged answer means the
|
|
100
|
+
server refused those credentials, and the `401` reaches you.
|
|
101
|
+
|
|
102
|
+
So a password does the right thing with no configuration, and a bearer token is
|
|
103
|
+
renewed without a class of its own.
|
|
104
|
+
|
|
105
|
+
## Sessions are a shared, per-user resource
|
|
106
|
+
|
|
107
|
+
Not an API change, a fact worth having. The limit is per user and the pool is
|
|
108
|
+
shared with everything else logged on as them, a SAP GUI session included. The
|
|
109
|
+
timeout is **idle-based**: a connection that keeps working keeps its session,
|
|
110
|
+
one that goes quiet past the window loses it. There is no keepalive timer here,
|
|
111
|
+
deliberately — how long to hold a session is yours to decide.
|
|
112
|
+
|
|
113
|
+
And the shape of the problem this release addresses, seen from the other end: a
|
|
114
|
+
process that opens a connection per check and never disconnects filled a
|
|
115
|
+
system's session list at one session every ten seconds. Releasing what you open
|
|
116
|
+
is half of it; opening one connection instead of one per check is the other.
|
|
@@ -29,7 +29,9 @@ The connection layer **does not** decide when to lock/unlock objects—that logi
|
|
|
29
29
|
```ts
|
|
30
30
|
import { createAbapConnection } from '@mcp-abap-adt/connection';
|
|
31
31
|
|
|
32
|
-
const connection = createAbapConnection(config, logger
|
|
32
|
+
const connection = createAbapConnection(config, logger, undefined, undefined, {
|
|
33
|
+
system: 'onprem', // or 'cloud' — said by you, never detected
|
|
34
|
+
});
|
|
33
35
|
await connection.connect(); // required before any request
|
|
34
36
|
|
|
35
37
|
// Enable stateful session mode (adds x-sap-adt-sessiontype: stateful header)
|
|
@@ -47,11 +49,11 @@ connection.setSessionType('stateless');
|
|
|
47
49
|
## Knowing Which Session You Are In
|
|
48
50
|
|
|
49
51
|
```ts
|
|
50
|
-
import {
|
|
52
|
+
import { AdtOnPremConnector, BasicAuthProvider } from '@mcp-abap-adt/connection';
|
|
51
53
|
|
|
52
54
|
// getSessionIdentity() is on the HTTP connection classes, NOT on the
|
|
53
55
|
// IAbapConnection type that createAbapConnection() returns.
|
|
54
|
-
const connection = new
|
|
56
|
+
const connection = new AdtOnPremConnector(config, new BasicAuthProvider(user, pass), logger);
|
|
55
57
|
await connection.connect();
|
|
56
58
|
|
|
57
59
|
// Which SAP session this connection is talking to. Changes only when the
|
|
@@ -82,6 +84,12 @@ Every ADT request issued through `makeAdtRequest` automatically:
|
|
|
82
84
|
|
|
83
85
|
This logic is transparent to callers (Builders, handlers, CLI scripts).
|
|
84
86
|
|
|
87
|
+
On a JWT connection one case is **not** transparent, and cannot be: a 401 that leads to a token
|
|
88
|
+
refresh also replaces the SAP session, because the renewed credential cannot keep the old one.
|
|
89
|
+
Inside a lock window that surfaces as `ADT_SESSION_REPLACED` rather than a request quietly
|
|
90
|
+
continuing on a session your lock is not in. A 403 never does this — it is an authorization
|
|
91
|
+
answer, not a credential one, and nothing is torn down for it.
|
|
92
|
+
|
|
85
93
|
---
|
|
86
94
|
|
|
87
95
|
## Interaction With ADT Clients
|
|
@@ -96,19 +104,83 @@ This logic is transparent to callers (Builders, handlers, CLI scripts).
|
|
|
96
104
|
|
|
97
105
|
---
|
|
98
106
|
|
|
107
|
+
## What This Layer Actually Solves
|
|
108
|
+
|
|
109
|
+
The link to the ABAP session is not guaranteed, and that is the whole problem:
|
|
110
|
+
|
|
111
|
+
- the ABAP session can be terminated by the system while this client still
|
|
112
|
+
believes it has one — the next lock-bound request answers
|
|
113
|
+
`400 "Session not found"`;
|
|
114
|
+
- or it is never created at all, because the system will not open another one
|
|
115
|
+
for this user right now.
|
|
116
|
+
|
|
117
|
+
Both are reported, neither is worked around. `connect()` fails with the reason
|
|
118
|
+
when no session was opened; a session lost afterwards surfaces as
|
|
119
|
+
`ADT_SESSION_REPLACED` rather than a request quietly continuing somewhere it
|
|
120
|
+
does not belong. What to do about either — wait, retry, reconnect, release
|
|
121
|
+
sessions the user still holds, carry on read-only — is the caller's decision,
|
|
122
|
+
made with the cookies and the answers in hand.
|
|
123
|
+
|
|
124
|
+
## A Lock Lives In The ABAP Session
|
|
125
|
+
|
|
126
|
+
There are two sessions here, and they are not the same thing:
|
|
127
|
+
|
|
128
|
+
- the **HTTP session** — the conversation this client is having. It exists as
|
|
129
|
+
long as the cookies do, and nothing about it is in doubt;
|
|
130
|
+
- the **ABAP session** — named by `SAP_SESSIONID_<SID>_<CLIENT>`, and the thing
|
|
131
|
+
locks are bound to.
|
|
132
|
+
|
|
133
|
+
The doubt is only ever about the second. It may not have been created, or it may
|
|
134
|
+
have been terminated while this client still holds the cookies and believes it
|
|
135
|
+
has one — and a lock is dead in either case, whether or not the client noticed.
|
|
136
|
+
|
|
137
|
+
Two ways to lose one, and they are different problems:
|
|
138
|
+
|
|
139
|
+
- **The server never opened one.** No `SAP_SESSIONID` came back, so there is no
|
|
140
|
+
ABAP session known to this connection and nothing a lock could be bound to —
|
|
141
|
+
while the HTTP side is perfectly fine, which is why it does not look like a
|
|
142
|
+
failure at all. `connect()` refuses rather than handing back a connection
|
|
143
|
+
whose first lock would be dead on arrival. Sessions are limited per user and shared with every other tool
|
|
144
|
+
logged on as them, so this says nothing about your code — it says the system
|
|
145
|
+
would not open another one right now.
|
|
146
|
+
- **It timed out while you were quiet.** The timeout is an idle one, and it is
|
|
147
|
+
the *silence* that spends it, not the elapsed time. Measured on an on-prem
|
|
148
|
+
system with a 30-minute window: a small request once a minute kept one session
|
|
149
|
+
alive for 45 minutes with its identity unchanged, straight past the mark.
|
|
150
|
+
|
|
151
|
+
So a long chain under a lock is safe while it is doing something, and at risk
|
|
152
|
+
while it waits. **Any request in the session resets the window** — a poll, a
|
|
153
|
+
read, a status check. There is deliberately no keepalive timer in this package:
|
|
154
|
+
holding a session alive means holding a scarce, shared slot, and deciding to do
|
|
155
|
+
that belongs to the caller who knows why the session is worth keeping.
|
|
156
|
+
|
|
157
|
+
**The cookies are the session.** Nothing else ties a caller to one, so a second
|
|
158
|
+
connection given the same cookie jar works in the same ABAP session and can use
|
|
159
|
+
the locks taken in it — that is what makes handing them over a way to continue
|
|
160
|
+
someone else's work. It cuts both ways: `disconnect()` ends the session for
|
|
161
|
+
everyone holding those cookies, not just for the object it was called on, and no
|
|
162
|
+
connection can see the copies. Deciding who may hold them, and who is allowed to
|
|
163
|
+
close, belongs to whoever passes them around.
|
|
164
|
+
|
|
165
|
+
The server never tells the client how long it has: the session cookie carries no
|
|
166
|
+
expiry and no response header mentions one. The only honest signals are the ones
|
|
167
|
+
you get by asking — `getSessionIdentity()` for which session you are in, and
|
|
168
|
+
`ADT_SESSION_REPLACED` when the one you were in is gone.
|
|
169
|
+
|
|
99
170
|
## Troubleshooting
|
|
100
171
|
|
|
101
|
-
- **CSRF token errors**: discard the session and establish a new one.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
172
|
+
- **CSRF token errors**: discard the session and establish a new one. That is
|
|
173
|
+
`disconnect()` followed by `connect()` — there is no local-only discard, because
|
|
174
|
+
dropping the cookie leaves the ABAP session open on the server. Both live on
|
|
175
|
+
the HTTP connection classes and are **not** on the `IAbapConnection` type
|
|
176
|
+
`createAbapConnection()` returns — reach them through a concrete type:
|
|
105
177
|
|
|
106
178
|
```ts
|
|
107
|
-
import {
|
|
179
|
+
import { AdtOnPremConnector, BasicAuthProvider } from '@mcp-abap-adt/connection';
|
|
108
180
|
|
|
109
|
-
const connection = new
|
|
110
|
-
connection.
|
|
111
|
-
await connection.connect();
|
|
181
|
+
const connection = new AdtOnPremConnector(config, new BasicAuthProvider(user, pass), logger);
|
|
182
|
+
await connection.disconnect(); // ends the session on the server, then clears
|
|
183
|
+
await connection.connect(); // a new session, explicitly
|
|
112
184
|
```
|
|
113
185
|
- **Session expired**: reauthenticate to obtain a new session.
|
|
114
186
|
- **Multiple connections**: each `createAbapConnection` instance maintains its own cookie jar; share the instance if you need continuity.
|