@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +337 -1
  2. package/README.md +74 -31
  3. package/dist/auth/IAuthProvider.d.ts +84 -0
  4. package/dist/auth/IAuthProvider.d.ts.map +1 -0
  5. package/dist/auth/IAuthProvider.js +21 -0
  6. package/dist/auth/providers.d.ts +95 -0
  7. package/dist/auth/providers.d.ts.map +1 -0
  8. package/dist/auth/providers.js +140 -0
  9. package/dist/connection/AbstractAbapConnection.d.ts +222 -17
  10. package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
  11. package/dist/connection/AbstractAbapConnection.js +555 -31
  12. package/dist/connection/AdtCloudConnector.d.ts +29 -0
  13. package/dist/connection/AdtCloudConnector.d.ts.map +1 -0
  14. package/dist/connection/AdtCloudConnector.js +31 -0
  15. package/dist/connection/AdtOnPremConnector.d.ts +30 -0
  16. package/dist/connection/AdtOnPremConnector.d.ts.map +1 -0
  17. package/dist/connection/AdtOnPremConnector.js +32 -0
  18. package/dist/connection/BaseAbapConnection.d.ts +7 -1
  19. package/dist/connection/BaseAbapConnection.d.ts.map +1 -1
  20. package/dist/connection/BaseAbapConnection.js +8 -2
  21. package/dist/connection/CertificateAbapConnection.d.ts +11 -1
  22. package/dist/connection/CertificateAbapConnection.d.ts.map +1 -1
  23. package/dist/connection/CertificateAbapConnection.js +14 -2
  24. package/dist/connection/CredentialAbapConnection.d.ts +88 -0
  25. package/dist/connection/CredentialAbapConnection.d.ts.map +1 -0
  26. package/dist/connection/CredentialAbapConnection.js +195 -0
  27. package/dist/connection/JwtAbapConnection.d.ts +102 -9
  28. package/dist/connection/JwtAbapConnection.d.ts.map +1 -1
  29. package/dist/connection/JwtAbapConnection.js +266 -82
  30. package/dist/connection/KerberosAbapConnection.d.ts +9 -1
  31. package/dist/connection/KerberosAbapConnection.d.ts.map +1 -1
  32. package/dist/connection/KerberosAbapConnection.js +9 -1
  33. package/dist/connection/RfcAbapConnection.d.ts +0 -5
  34. package/dist/connection/RfcAbapConnection.d.ts.map +1 -1
  35. package/dist/connection/RfcAbapConnection.js +0 -7
  36. package/dist/connection/SamlAbapConnection.d.ts +7 -1
  37. package/dist/connection/SamlAbapConnection.d.ts.map +1 -1
  38. package/dist/connection/SamlAbapConnection.js +8 -2
  39. package/dist/connection/connectionFactory.d.ts +16 -0
  40. package/dist/connection/connectionFactory.d.ts.map +1 -1
  41. package/dist/connection/connectionFactory.js +52 -0
  42. package/dist/index.d.ts +4 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +10 -1
  45. package/dist/session/CloudSecuritySessionStrategy.d.ts +32 -0
  46. package/dist/session/CloudSecuritySessionStrategy.d.ts.map +1 -0
  47. package/dist/session/CloudSecuritySessionStrategy.js +135 -0
  48. package/dist/session/IcfSessionStrategy.d.ts +27 -0
  49. package/dist/session/IcfSessionStrategy.d.ts.map +1 -0
  50. package/dist/session/IcfSessionStrategy.js +62 -0
  51. package/dist/session/SessionLifecycle.d.ts +17 -2
  52. package/dist/session/SessionLifecycle.d.ts.map +1 -1
  53. package/dist/session/SessionLifecycle.js +17 -2
  54. package/dist/session/SessionStrategy.d.ts +86 -0
  55. package/dist/session/SessionStrategy.d.ts.map +1 -0
  56. package/dist/session/SessionStrategy.js +33 -0
  57. package/dist/utils/cookies.d.ts +12 -0
  58. package/dist/utils/cookies.d.ts.map +1 -0
  59. package/dist/utils/cookies.js +24 -0
  60. package/dist/utils/timeouts.d.ts +16 -0
  61. package/dist/utils/timeouts.d.ts.map +1 -1
  62. package/dist/utils/timeouts.js +19 -0
  63. package/docs/INDEX.md +5 -0
  64. package/docs/INSTALLATION.md +6 -2
  65. package/docs/MIGRATION-2.0.md +1 -1
  66. package/docs/MIGRATION-4.0.md +95 -0
  67. package/docs/MIGRATION-5.0.md +116 -0
  68. package/docs/STATEFUL_SESSION_GUIDE.md +83 -11
  69. package/docs/USAGE.md +115 -24
  70. package/docs/superpowers/specs/2026-08-21-platform-connectors.md +108 -0
  71. package/examples/README.md +2 -1
  72. package/examples/jwt-with-token-refresh.js +11 -3
  73. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,340 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.0.0] - 2026-08-21
11
+
12
+ A connection now says which system it is, closes what it opens, and is handed its
13
+ credential instead of being one. See [Migration to 5.0](./docs/MIGRATION-5.0.md).
14
+
15
+ ### Added
16
+
17
+ - **`AdtOnPremConnector` and `AdtCloudConnector`.** The class you take states which SYSTEM you
18
+ are dialling; the auth provider states how you authenticate there. The two are independent, and
19
+ both were reachable combinations that the old shape got wrong: a communication user against
20
+ ABAP Cloud, and a bearer token against an on-prem system.
21
+
22
+ Nothing is detected. `/sap/bc/adt/core/http/sessions` answers on **on-prem too**, publishing
23
+ both the session resource and the ICF logoff in one document, and its `DELETE` there leaves the
24
+ session listed while the logoff removes it — a probe would have chosen the mechanism that
25
+ releases nothing.
26
+
27
+ - **Auth providers** — `BasicAuthProvider`, `TokenAuthProvider`, `SamlAuthProvider`,
28
+ `CertificateAuthProvider` — and `IAuthProvider`, the contract they satisfy. A token provider
29
+ renews on its own, so nothing is cached here: the header is asked for per request, and on a
30
+ `401` the provider is told its answer was refused (`refreshToken()`) before being asked again.
31
+
32
+ - **`createAbapConnection(..., { system })`** builds the connector you name, with a provider from
33
+ the config. Without it, the old choice by `authType`, warned about once per call.
34
+
35
+ - **`disconnect({ deadlineMs })`** — the parameter `ISessionLifecycleAware` published and nothing
36
+ implemented. It bounds the **wait**, never the request: handed to axios it would abort the
37
+ socket and cancel the release it was waiting for.
38
+
39
+ ### Changed — BREAKING
40
+
41
+ - **`connect()` fails when the server opened no session**, where it used to warn and hand the
42
+ connection back. A lock is held by the ABAP session, so a connection without one can read but
43
+ can hold nothing, and the failure surfaced a request later as `400 Session not found` with the
44
+ object half-edited. Verified rather than inferred: a connection that received no
45
+ `SAP_SESSIONID` is listed in the server's session list as nothing at all.
46
+
47
+ - **`disconnect()` makes a network call**, telling the server the session is finished. It does
48
+ not wait by default — `SAP_RELEASE_DEADLINE_MS` is `0` — because waiting is for steps whose
49
+ successor needs the server to have caught up, and a teardown has none. Requests still in flight
50
+ are running on the session being released and will start failing; that is the caller having
51
+ asked to disconnect.
52
+
53
+ - **The five auth connection classes are deprecated** and keep working.
54
+
55
+ ### Removed — BREAKING
56
+
57
+ - **`reset()`**, from `AbstractAbapConnection` and `RfcAbapConnection`. There is no local-only
58
+ discard because there is no local-only session: it lives on the server, and dropping the cookie
59
+ leaves it there. `await disconnect()`, then `connect()` again.
60
+
61
+ ### Fixed
62
+
63
+ - **Requests stay on the server the session lives on.** A session belongs to one application
64
+ server, so on a multi-node system a request landing elsewhere gets a different session and any
65
+ lock held on the first dies — no inactivity, nobody at fault. `sap-adt-saplb` is asked for and
66
+ sent back, as Eclipse does.
67
+
68
+ - **A late `401` no longer tears down a healthy session.** The comparison is bound to the session
69
+ the request went out on, so a refusal answered by a session that has since been replaced is
70
+ retried rather than acted on.
71
+
72
+ - **A credential that is cookies reaches the wire.** A SAML provider's cookies are part of the
73
+ contract and merged with the session jar rather than written over by it — one says who we are,
74
+ the other which session we are in.
75
+
76
+ - **A session opened before a failed `connect()` is not abandoned**, and the logoff does not cut
77
+ a lock chain held open with `beginCriticalSection()`.
78
+
79
+ ### Measured
80
+
81
+ - 25 connects in a row on-prem: **24-25** given a session with the logoff, **2** without.
82
+ - The server's session list shows the row appear at the second of the call, and go on
83
+ `disconnect()`; without one it would sit for 30 more minutes.
84
+ - The timeout is **idle-based**: 45 small requests a minute apart held one session straight
85
+ through a 30-minute window, identity unchanged. There is no keepalive timer here on purpose.
86
+ - Cloud: `AdtCloudConnector` with a token provider — session opened through ADT, three stateful
87
+ requests holding it, closed by `DELETE` on the address the server published.
88
+
89
+ ### Fixed
90
+
91
+ - **Every session of a reconnect cycle is released, not only the first.** A release already on
92
+ its way was treated as "the release still owed" whoever it belonged to, so an in-flight logoff
93
+ for a previous session suppressed the current one's entirely: `connect → disconnect → connect →
94
+ disconnect` sent **one** logoff and left the second session open. Not an edge case — the
95
+ default deadline is `0`, so `disconnect()` does not wait for the logoff and a release is
96
+ routinely still in flight when the next `connect()` happens, which made this the normal path on
97
+ any server that does not answer instantly. Releases are now keyed by the session they belong
98
+ to, and both completion handlers clear by that key, so a late answer about one session cannot
99
+ discard what is owed for another. Sessions still owed are kept as a set rather than one slot,
100
+ because more than one can be outstanding and the older was being overwritten.
101
+
102
+ ### Removed — BREAKING
103
+
104
+ - **`reset()` is gone**, from `AbstractAbapConnection` and `RfcAbapConnection`. There is no
105
+ local-only discard, because there is no local-only session: the session lives on the server,
106
+ and dropping the cookie leaves it there. The lifecycle is `connect()` / `disconnect()`,
107
+ repeatable, and both say what they do to the server.
108
+
109
+ It carried nothing `disconnect()` lacks — it cleared the fingerprint at the start of teardown
110
+ rather than at its end, did not join the transition tail, and returned `void`. That last one
111
+ is the point: a teardown that reports nothing cannot tell the caller whether the session was
112
+ released, which is the whole subject of this release.
113
+
114
+ No callers outside tests, and it is in neither `IAbapConnection` nor `ISessionLifecycleAware`,
115
+ so consumers programming against the published contracts are unaffected. `RfcAbapConnection`
116
+ keeps `close()`, which is its own teardown and always was.
117
+
118
+ **Migration:** `conn.reset()` → `await conn.disconnect()`, and `connect()` again to carry on —
119
+ the connection is reusable. A caller that does not want to wait simply does not `await` it,
120
+ which is what `reset()` was really used for.
121
+
122
+ ### Fixed
123
+
124
+ - **`disconnect()` tells the server the session is done, not only the client.** Dropping the cookie
125
+ left the ABAP session alive until its own timeout — default `http/security_session_timeout`,
126
+ 1800 s — so a process that connects repeatedly left one behind every time. Measured on S/4HANA
127
+ on-prem: 25 connects in a row with the logoff, 24–25 of them were given a session; without it,
128
+ 2. The server-side view is unambiguous — SM04 showed 25 HTTP sessions for the same user, one per
129
+ `connect()`, each holding ~12.8 MB, all of them opened by `P=/sap/bc/adt/discovery`, which is the
130
+ establishing call.
131
+
132
+ The logoff says the session is no longer needed; **when the server reclaims it is the
133
+ server's business** — possibly not until the next `connect()` asks for one — and nothing
134
+ here waits on that or depends on it. So `disconnect()` **does not wait by default**:
135
+ waiting is for steps whose successor needs the server to have caught up, and a teardown
136
+ has no successor. A caller that wants a bounded wait passes it —
137
+ `disconnect({ deadlineMs })`, the parameter `ISessionLifecycleAware` has published all
138
+ along and which nothing implemented; the default comes from `SAP_RELEASE_DEADLINE_MS`,
139
+ which is `0`.
140
+
141
+ **The deadline bounds the wait, never the request.** When it expires the waiting stops and
142
+ the logoff carries on to the server — the contract's word is *detach*. Handed to axios as a
143
+ request timeout instead, it would abort the socket and cancel the very release it was waiting
144
+ for: `deadlineMs: 200` against a server answering in 500 ms left the session open, and the
145
+ default of `0` was more reliable than any small positive value.
146
+
147
+ **Each caller waits its own deadline.** Concurrent disconnects join one transition and share
148
+ its promise, so a wait placed inside it was the first caller's wait imposed on everyone — a
149
+ caller passing `0` sat through another's 30-second budget, the one guarantee the parameter
150
+ exists to make. The transition now carries only what must happen once: dispatching the logoff
151
+ and clearing the local state.
152
+
153
+ **A repeat call finishes what is still owed**, as `ISessionLifecycleAware` promises. It could
154
+ not: `clearSessionState()` drops the cookies, so a second call found nothing to send and the
155
+ session lived out its 1800 s. The cookies of an incomplete release are kept aside for exactly
156
+ that retry and dropped as soon as one succeeds; a release already on its way is joined rather
157
+ than duplicated.
158
+
159
+ **The logoff does not cut a lock chain in flight.** It ends the session that chain is running
160
+ on, so a consumer's `finally` firing on shutdown mid-unlock would leave the object locked and
161
+ inactive — the damage this release exists to prevent, caused by the release itself.
162
+ `beginCriticalSection()` is honoured here as it already is for timeouts: the local teardown
163
+ still happens, the session is recorded as still owed, and calling `disconnect()` again once
164
+ the chain has finished releases it.
165
+
166
+ - **A malformed `SAP_RELEASE_DEADLINE_MS` is refused at construction, not at teardown.** It
167
+ reached `parseInt`, came out `NaN`, and threw from **every** `disconnect()` in the process —
168
+ blaming a `deadlineMs` argument nobody had passed. It is a startup fault: the same on every
169
+ call, not the caller's argument, and worth refusing a connection over. `Number()` rather than
170
+ `parseInt()`, which read `"5s"` as `5` and travelled on as a silently wrong bound.
171
+
172
+ And `disconnect()` no longer throws at all, which is what it and the interface both promise.
173
+ Its place is a `finally` — a connection that was connected must be disconnected — and an
174
+ exception raised there replaces the error that sent the caller into it. A nonsense per-call
175
+ `deadlineMs` is reported and the default used instead.
176
+
177
+ It surfaces as anything but a session problem: once the server stops issuing sessions it still
178
+ authenticates every request, so stateless reads and writes keep working and only the
179
+ lock-bound write fails — `200` for the LOCK, a handle, then `400 Session not found` on the next
180
+ request and a half-edited object.
181
+
182
+ ICF rather than ADT because ADT publishes no session-close: its discovery document lists none on
183
+ any reachable system — on-prem, cloud, or legacy — and the ADT logon is the discovery call
184
+ itself. Best effort and never throwing: `disconnect()` must always settle, and a session we
185
+ could not close beats a teardown that hangs.
186
+
187
+ How many sessions a system tolerates is the server's business and is not guessed at here. Using
188
+ few connections, and reusing them, stays the consumer's decision.
189
+
190
+ - **A connection the server gave no session now warns.** `sessionFingerprint()` tracks
191
+ `SAP_SESSIONID*` only, so a server that issued none leaves it empty — and an empty fingerprint
192
+ can never be classified `replaced`: `observe()` returns `established` or `unchanged` forever,
193
+ `applyIdentityPolicy()` never fires, `getSessionIdentity()` names nothing. Refusing to connect
194
+ would be the honest answer and is deliberately not done yet: whether cloud ABAP issues this
195
+ cookie is unverified, and a rule that wrong would break every cloud consumer to fix an on-prem
196
+ fault.
197
+
198
+ ### Documentation
199
+
200
+ - **The guides stop recommending `reset()`**, which this release removes. `USAGE.md` had a
201
+ runnable `connection.reset()` under *Connection Reset*, `STATEFUL_SESSION_GUIDE.md` offered it
202
+ as the remedy for CSRF errors, and `MIGRATION-2.0.md` described its teardown — anyone following
203
+ them got `connection.reset is not a function`. They now say what replaces it and why: starting
204
+ over means telling the server, and dropping a cookie does not.
205
+ - A doc block left dangling by the same removal had `close()` in `RfcAbapConnection` documented as
206
+ "Reset the connection … Provides interface compatibility with HTTP connections" — an API that no
207
+ longer exists.
208
+
209
+ ### Tests
210
+
211
+ - The stub in `sessionComposition.test.ts` answered every route instantly, `/sap/bc/adt/slow`
212
+ included, so *does not wait for an in-flight request* held whenever `disconnect()` performed no
213
+ I/O rather than because the teardown declined to wait. That route now takes 300 ms and the test
214
+ asserts what its name says.
215
+ - `sessionTeardown.test.ts` covers the teardown contract from the caller's side: the logoff goes
216
+ out with the session cookies and without a request timeout at any budget; it is detached rather
217
+ than aborted when a deadline expires; a failing logoff still disconnects; a repeat call re-sends
218
+ what is owed and sends nothing once it succeeded; two concurrent disconnects share one logoff
219
+ and keep separate deadlines; a critical section defers it; and a malformed
220
+ `SAP_RELEASE_DEADLINE_MS` refuses construction.
221
+
222
+ ### Fixed
223
+
224
+ - **`disconnect()` releases the session this connection holds, and nothing else.** What grew
225
+ around that sentence — a map of owed sessions, a map of releases in flight, an attempt counter,
226
+ a give-up rule, a retry across reconnects, and the waiting rules to go with them — is gone. Four
227
+ review rounds found a defect in each round's own fix, every one of them in that machinery, and
228
+ none of it was needed: **a connection holds one session**. `connect()` opens it, `disconnect()`
229
+ closes it, a repeat `connect()` is a NEW session with a new `SAP_SESSIONID`, and an earlier
230
+ session is not this connection's business — its logoff is already on the wire, or the system
231
+ times it out.
232
+
233
+ Nothing retries, counts, limits or keeps a list. How many connections to run, how frugally, and
234
+ what to do when a release did not land are the caller's, and were never knowable from inside a
235
+ single connection.
236
+
237
+ The session a release belongs to is now its `SAP_SESSIONID`, not the cookie header it is sent
238
+ with. The header also carries `sap-XSRF_*`, which rotates within one and the same session, so
239
+ comparing headers made a session stop recognising itself after a token refresh.
240
+
241
+ - **A logoff that cannot even be assembled no longer escapes the teardown.** Building it can throw
242
+ on its own — a certificate connection whose material is not loaded throws while building the
243
+ agent — and `disconnect()` is documented never to throw and is called from a `finally`, where a
244
+ throw replaces the error that sent the caller there.
245
+
246
+ ### Documentation
247
+
248
+ - **The cookies are the session, and a logoff ends it for everyone holding them.** A second
249
+ connection given the same cookie jar works in the same ABAP session and can use the locks taken
250
+ in it; `disconnect()` closes that session for all of them, and no connection can see the copies.
251
+ Written down in `STATEFUL_SESSION_GUIDE.md` and on `disconnect()` itself.
252
+
253
+ ### Changed — BREAKING
254
+
255
+ - **`connect()` fails when the server opened no session**, instead of warning and handing back a
256
+ connection whose first lock would be dead on arrival. Locks are held by the ABAP session, so a
257
+ connection without one can read but can hold nothing; the failure used to surface a request
258
+ later, as `400 Session not found` with the object half-edited.
259
+
260
+ Verified rather than inferred: a connection that received no `SAP_SESSIONID` was held open
261
+ against an on-prem system and the session list showed **nothing** for it, while one that
262
+ received the cookie appeared there. No cookie, no session.
263
+
264
+ Reported, not decided on. The message says what the server did, what still works, what does
265
+ not, the usual cause — sessions are limited per user and shared with every other tool logged on
266
+ as them — and that nothing is retried here, because whether to wait, retry, or release sessions
267
+ the user still holds depends on what only the caller knows.
268
+
269
+ Every transport, not only basic: splitting by authentication type would encode a guess about
270
+ cloud ABAP, whose ADT endpoint would not answer the bearer obtainable here. If a cloud system
271
+ turns out to hold sessions without issuing this cookie, this is the rule to revisit.
272
+
273
+ ### Documentation
274
+
275
+ - **`STATEFUL_SESSION_GUIDE.md` gains "A Lock Lives In The Session".** That a lock dies with the
276
+ session that took it; that the timeout is an idle one, spent by silence rather than by elapsed
277
+ time — one small request a minute kept a session alive for 45 minutes past a 30-minute window,
278
+ identity unchanged; and that any request in the session resets it, which is why this package
279
+ holds no keepalive timer. Holding a session alive holds a scarce shared slot, and that is the
280
+ caller's decision to make.
281
+
282
+ ## [4.0.0] - 2026-08-16
283
+
284
+ A JWT connection stops answering with an error of its own making. See
285
+ [docs/MIGRATION-4.0.md](docs/MIGRATION-4.0.md).
286
+
287
+ ### Breaking
288
+
289
+ - **A 403 no longer triggers a token refresh.** It means the server authenticated the caller and
290
+ refused the action anyway, so a new token is the same caller and cannot change the answer.
291
+ - **`Error('JWT token has expired. Please re-authenticate.')` is gone.** Both a 401 and a 403 were
292
+ reported with it, and the original `AxiosError` was discarded along with the status and the
293
+ body. The server's error now reaches the caller unchanged. Code matching on that message must
294
+ branch on `error.response.status` instead — which it can now do.
295
+
296
+ ### Fixed
297
+
298
+ - The substring guard meant to let permission failures past matched three literal strings and SAP
299
+ sends none of them: the type is `ExceptionResourceNoAuthorization`, not `...NoAccess`, and the
300
+ message reads "not authorized", not "No authorization". The list is deleted rather than
301
+ extended — it enumerated prose, so the next unlisted wording would have been the same defect
302
+ ([#30](https://github.com/fr0ster/mcp-abap-connection/issues/30)).
303
+ - A 401 that survives a renewal is now logged at ERROR. The previous log fired only when the
304
+ renewal never happened, so the case the deleted message was actually about passed silently.
305
+ - `establishSession` no longer refreshes or recurses into itself. `fetchCsrfToken` owns recovery
306
+ during establishment; a second refresh at the outer level asked the same refresher the same
307
+ question, and the recursion was unbounded whenever the refresher kept resolving.
308
+ - `JwtAbapConnection.fetchCsrfToken` declared three parameters where the base declares four and
309
+ called `super` with three, silently dropping the `generation` that fences response effects.
310
+ TypeScript accepts that — fewer parameters are assignable to more. Latent rather than live:
311
+ both call sites that pass one are gated on basic auth.
312
+
313
+ ### Changed
314
+
315
+ - **One credential renewal per caller-visible operation, session included.** Concurrent requests
316
+ meeting the same expired token now share a single token fetch *and* a single session
317
+ re-establishment. Previously each ran its own teardown and recovery — and since `recover` and
318
+ `cleanup` never join, one request's cleanup could tear down the session another had just
319
+ rebuilt, leaving its retry at a closed door.
320
+ - The renewal decision is made against the credential state the operation started with, carried
321
+ in a per-connection `AsyncLocalStorage`. A nested token-only refresh no longer passes for a
322
+ session recovery.
323
+ - A retry is abandoned with `ADT_NOT_CONNECTED` if the connection was torn down between the last
324
+ lifecycle check and the retry itself.
325
+ - `establishSession` takes its CSRF retry defaults from `CSRF_CONFIG` at all four authentication
326
+ types instead of repeating `3, 1000` in each.
327
+
328
+ ### Documentation
329
+
330
+ - New [MIGRATION-4.0.md](docs/MIGRATION-4.0.md), linked from the README and the docs index, which
331
+ now has an *Upgrading* section — the 2.0 migration guide was in the tree but linked from
332
+ nowhere but a directory listing.
333
+ - `USAGE.md` gains the classification table; `STATEFUL_SESSION_GUIDE.md` notes that a 401-driven
334
+ refresh replaces the SAP session and surfaces as `ADT_SESSION_REPLACED` inside a lock window,
335
+ while a 403 tears down nothing; the README and both examples no longer promise a refresh on
336
+ 403.
337
+
338
+ ### Known
339
+
340
+ - Whether some BTP setup answers an *invalid* token with 403 rather than 401 is unobserved and
341
+ tracked in [#32](https://github.com/fr0ster/mcp-abap-connection/issues/32). Preserving the
342
+ original error is what makes the assumption safe to be wrong about.
343
+
10
344
  ## [3.0.0] - 2026-08-03
11
345
 
12
346
  Undoes two mistakes from 2.0.0, four days old. Everything else that release
@@ -786,7 +1120,9 @@ const connection = createAbapConnection(config, logger);
786
1120
  - JWT token refresh now properly handles connection errors (401/403 during initial connect)
787
1121
  - Permission errors (403 with "ExceptionResourceNoAccess") no longer trigger JWT refresh loops
788
1122
  - Proper separation: base class handles HTTP/session, concrete classes handle auth-specific errors
789
- [Unreleased]: https://github.com/fr0ster/mcp-abap-connection/compare/v3.0.0...HEAD
1123
+ [Unreleased]: https://github.com/fr0ster/mcp-abap-connection/compare/v5.0.0...HEAD
1124
+ [5.0.0]: https://github.com/fr0ster/mcp-abap-connection/compare/v4.0.0...v5.0.0
1125
+ [4.0.0]: https://github.com/fr0ster/mcp-abap-connection/compare/v3.0.0...v4.0.0
790
1126
  [3.0.0]: https://github.com/fr0ster/mcp-abap-connection/compare/v2.0.0...v3.0.0
791
1127
  [2.0.0]: https://github.com/fr0ster/mcp-abap-connection/compare/v1.10.2...v2.0.0
792
1128
  [1.10.2]: https://github.com/fr0ster/mcp-abap-connection/compare/v1.10.1...v1.10.2
package/README.md CHANGED
@@ -17,8 +17,8 @@ ABAP connection layer for MCP ABAP ADT server. Provides a unified interface for
17
17
  - Session headers management (cookies, CSRF tokens)
18
18
  - Session state persistence is handled by `@mcp-abap-adt/auth-broker` package
19
19
  - 🏗️ **Clean Architecture**:
20
- - Abstract base class for common HTTP/session logic
21
- - Auth-type specific implementations (BaseAbapConnection, JwtAbapConnection, SamlAbapConnection)
20
+ - One connector per SYSTEM (`AdtOnPremConnector`, `AdtCloudConnector`), handed an auth provider
21
+ - Authentication is a parameter, not a subclass — nothing about the system is inferred from it
22
22
  - Proper separation of concerns - no JWT logic in base class
23
23
  - 🔌 **Realtime Transport Scaffold**:
24
24
  - Generic `GenericWebSocketTransport` with pluggable WS factory
@@ -39,21 +39,31 @@ The package uses a clean separation of concerns:
39
39
  - CSRF token fetching with retry
40
40
  - Auth-agnostic - knows nothing about Basic or JWT
41
41
 
42
- - **`BaseAbapConnection`** (concrete, exported):
43
- - Basic Authentication implementation
44
- - `connect()` establishes the session (required before any request)
45
- - Suitable for on-premise SAP systems
46
-
47
- - **`JwtAbapConnection`** (concrete, exported):
48
- - JWT/OAuth2 Authentication implementation
49
- - `connect()` establishes the session with the JWT token (required before any request)
50
- - Suitable for SAP BTP ABAP Environment
51
- - Token refresh handled by auth-broker package
52
-
53
- - **`SamlAbapConnection`** (concrete, exported):
54
- - Session-cookie-based authentication (`authType: "saml"`)
55
- - Uses existing SSO/SAML session cookies
56
- - Fetches CSRF token and executes ADT requests in same HTTP model
42
+ - **`AdtOnPremConnector`** (concrete, exported):
43
+ - An on-prem system: the session arrives with the establishing call, and the platform's
44
+ ICF logoff is how it is given back
45
+ - Takes an auth provider — basic, SAML, certificate, a bearer token, whatever you hold
46
+
47
+ - **`AdtCloudConnector`** (concrete, exported):
48
+ - An ABAP Cloud system: a session is a resource, opened at
49
+ `/sap/bc/adt/core/http/sessions` and given back by `DELETE` on the address it publishes
50
+ - Takes an auth provider, same as above
51
+
52
+ **Which one you take is how you say where you are dialling.** Nothing is probed:
53
+ the session resource answers on on-prem too, and its `DELETE` there leaves the
54
+ session open while the logoff removes it — so asking the server would pick the
55
+ mechanism that releases nothing.
56
+
57
+ - **Auth providers** (`BasicAuthProvider`, `TokenAuthProvider`, `SamlAuthProvider`,
58
+ `CertificateAuthProvider`):
59
+ - What a connection authenticates with, passed in
60
+ - A token provider renews on its own; the connector asks it per request and, on a
61
+ `401`, tells it the answer was refused before asking again
62
+
63
+ - **`BaseAbapConnection`, `JwtAbapConnection`, `SamlAbapConnection`,
64
+ `CertificateAbapConnection`, `KerberosAbapConnection`** (deprecated, still exported):
65
+ - The previous shape, where the class stated your credential and the session
66
+ mechanism came with it. See [Migration to 5.0](./docs/MIGRATION-5.0.md)
57
67
 
58
68
  - **`GenericWebSocketTransport`** (concrete, exported):
59
69
  - Transport abstraction for realtime WS message flows
@@ -113,6 +123,7 @@ This package interacts with external packages **ONLY through interfaces**:
113
123
 
114
124
  - 📦 **[Installation Guide](./docs/INSTALLATION.md)** - Setup and installation instructions
115
125
  - 📚 **[Usage Guide](./docs/USAGE.md)** - Detailed usage examples and API documentation
126
+ - 🚚 **[Migration to 4.0.0](./docs/MIGRATION-4.0.md)** - a 401 refreshes the token, a 403 reaches you with the server's message; the synthesised "JWT token has expired" is gone
116
127
  - 🚚 **[Migration: the explicit session lifecycle](./docs/MIGRATION-2.0.md)** - `connect()` is now required; start here if you are coming from 1.x
117
128
  - 💡 **[Examples](./examples/)** - Working code examples
118
129
 
@@ -157,7 +168,9 @@ const logger = {
157
168
  };
158
169
 
159
170
  // Create connection
160
- const connection = createAbapConnection(config, logger);
171
+ const connection = createAbapConnection(config, logger, undefined, undefined, {
172
+ system: "onprem", // which SYSTEM this is — said, never detected
173
+ });
161
174
  await connection.connect(); // required before any request
162
175
 
163
176
  // Make ADT request
@@ -188,7 +201,9 @@ const logger = {
188
201
  };
189
202
 
190
203
  // Logger is optional - if not provided, no logging output
191
- const connection = createAbapConnection(config, logger);
204
+ const connection = createAbapConnection(config, logger, undefined, undefined, {
205
+ system: "cloud", // which SYSTEM this is — said, never detected
206
+ });
192
207
  await connection.connect();
193
208
 
194
209
  // Note: Token refresh is handled by @mcp-abap-adt/auth-broker package
@@ -209,7 +224,9 @@ const config: SapConfig = {
209
224
  sessionCookies: "MYSAPSSO2=...; SAP_SESSIONID=...",
210
225
  };
211
226
 
212
- const connection = createAbapConnection(config, logger);
227
+ const connection = createAbapConnection(config, logger, undefined, undefined, {
228
+ system: "onprem", // which SYSTEM this is — said, never detected
229
+ });
213
230
  await connection.connect();
214
231
 
215
232
  const response = await connection.makeAdtRequest({
@@ -220,10 +237,14 @@ const response = await connection.makeAdtRequest({
220
237
 
221
238
  ### Cloud Usage with Automatic Token Refresh
222
239
 
223
- For automatic token refresh on 401/403 errors, inject `ITokenRefresher`:
240
+ For automatic token refresh on **401** errors, inject `ITokenRefresher`:
224
241
 
225
242
  ```typescript
226
- import { JwtAbapConnection, SapConfig } from "@mcp-abap-adt/connection";
243
+ import {
244
+ AdtCloudConnector,
245
+ TokenAuthProvider,
246
+ SapConfig,
247
+ } from "@mcp-abap-adt/connection";
227
248
  import type { ITokenRefresher } from "@mcp-abap-adt/interfaces";
228
249
 
229
250
  // Token refresher provides token acquisition and refresh
@@ -233,26 +254,44 @@ const tokenRefresher: ITokenRefresher = {
233
254
  refreshToken: async () => { /* refresh and return new token */ },
234
255
  };
235
256
 
236
- // JWT configuration
237
257
  const config: SapConfig = {
238
258
  url: "https://your-instance.abap.cloud.sap",
239
259
  authType: "jwt",
240
- jwtToken: await tokenRefresher.getToken(), // Get initial token
241
260
  };
242
261
 
243
- // Create connection with token refresher - 401/403 handled automatically
244
- const connection = new JwtAbapConnection(config, logger, undefined, tokenRefresher);
262
+ // The connector says which SYSTEM this is; the provider says how to
263
+ // authenticate. Neither decides the other.
264
+ const connection = new AdtCloudConnector(
265
+ config,
266
+ new TokenAuthProvider(tokenRefresher),
267
+ logger,
268
+ );
245
269
  await connection.connect();
246
270
 
247
- // Requests automatically retry with refreshed token on auth errors. A refresh
248
- // replaces the SAP session, so if a lock window is open the request fails with
249
- // ADT_SESSION_REPLACED instead of continuing on a session your lock is not in.
271
+ // On a 401 the connector tells the provider its token was refused, asks again,
272
+ // and only if the answer changed rebuilds the session and retries once. An
273
+ // unchanged answer means the server refused these credentials, and the 401
274
+ // reaches you. A refresh replaces the SAP session, so if a lock window is open
275
+ // the request fails with ADT_SESSION_REPLACED rather than continuing on a
276
+ // session your lock is not in.
250
277
  const response = await connection.makeAdtRequest({
251
278
  method: "GET",
252
279
  url: "/sap/bc/adt/programs/programs/your-program",
253
280
  });
254
281
  ```
255
282
 
283
+ **A 403 is never treated as an expired token.** It means the server
284
+ authenticated the caller and refused the action anyway, so no credential can
285
+ change the answer. It propagates unchanged — `error.response.status` and the
286
+ server's message, which usually names the authorization object — rather than
287
+ being reported as an expired token.
288
+
289
+ Earlier versions reported both 401 and 403 as
290
+ `JWT token has expired. Please re-authenticate.` and discarded the original
291
+ error. Code matching on that message must branch on `error.response.status`
292
+ instead — which it can now do, since the status is no longer thrown away.
293
+ See [MIGRATION-4.0.md](./docs/MIGRATION-4.0.md).
294
+
256
295
  ### Stateful Sessions
257
296
 
258
297
  For operations that require session state (e.g., object modifications), you can enable stateful sessions:
@@ -260,7 +299,9 @@ For operations that require session state (e.g., object modifications), you can
260
299
  ```typescript
261
300
  import { createAbapConnection } from "@mcp-abap-adt/connection";
262
301
 
263
- const connection = createAbapConnection(config, logger);
302
+ const connection = createAbapConnection(config, logger, undefined, undefined, {
303
+ system: "onprem", // which SYSTEM this is — said, never detected
304
+ });
264
305
  await connection.connect();
265
306
 
266
307
  // Enable stateful session mode (adds x-sap-adt-sessiontype: stateful header)
@@ -308,7 +349,9 @@ class MyLogger implements ILogger {
308
349
  }
309
350
 
310
351
  const logger = new MyLogger();
311
- const connection = createAbapConnection(config, logger);
352
+ const connection = createAbapConnection(config, logger, undefined, undefined, {
353
+ system: "onprem", // which SYSTEM this is — said, never detected
354
+ });
312
355
  ```
313
356
 
314
357
  ## CLI Tool
@@ -0,0 +1,84 @@
1
+ /**
2
+ * How a connection proves who it is — passed in, not inherited.
3
+ *
4
+ * The class a consumer takes says which SYSTEM it is dialling; this says how it
5
+ * authenticates there. The two are independent: a communication user against
6
+ * ABAP Cloud and a bearer token against on-prem are both ordinary, and a design
7
+ * where the credential picks the system's session mechanism gets one of them
8
+ * wrong whichever way it guesses.
9
+ *
10
+ * Deliberately not "give me a token". Four of the five ways in are not tokens:
11
+ * basic is a header built from a username, a certificate is TLS material and no
12
+ * header at all, and SPNEGO is a negotiation with the server. What every one of
13
+ * them has in common is small, and it is this.
14
+ *
15
+ * `establishSession()` is NOT here. Measured across all five implementations it
16
+ * is the same work — fetch a CSRF token from `/sap/bc/adt/discovery`, keep it,
17
+ * tolerate a failure — with the only difference being a step before it, which is
18
+ * `prepare()`.
19
+ */
20
+ import type { AgentOptions } from 'node:https';
21
+ export interface IAuthProvider {
22
+ /** For logs, so which credential ran is never inferred from behaviour. */
23
+ readonly kind: string;
24
+ /**
25
+ * Get ready before anything is sent: load key material, unlock a store.
26
+ * Called once per establishment, before the first request.
27
+ *
28
+ * Not for tokens. A stateful token provider caches and renews on its own, so
29
+ * asking it IS the preparation — and holding what it returned would hide the
30
+ * renewal it exists to do.
31
+ *
32
+ * Optional because most credentials are ready as constructed. A credential
33
+ * that throws here fails the connect, which is correct — it has nothing to
34
+ * authenticate with.
35
+ */
36
+ prepare?(): Promise<void>;
37
+ /**
38
+ * The `Authorization` header value, or `''` when this credential is not a
39
+ * header — a certificate authenticates through TLS and has none.
40
+ *
41
+ * **Asked per request, and asynchronous, because the answer can change.** A
42
+ * token provider checks expiry and renews behind this call; anything cached
43
+ * on this side would serve the stale token and defeat it. Cheap in the
44
+ * ordinary case for the same reason: the provider holds the token and only
45
+ * goes to the network when it has expired.
46
+ */
47
+ authorizationHeader(): Promise<string>;
48
+ /**
49
+ * The server refused what this last handed out; get a new one.
50
+ *
51
+ * Separate from `authorizationHeader()` because the two questions are
52
+ * different, and the token contract says so: `getToken()` "may return cached
53
+ * token if still valid", while `refreshToken()` is documented as the one to
54
+ * call "when getToken() returned a token that was rejected by server". Asking
55
+ * the first again after a 401 gets the same rejected token back, and the
56
+ * renewal never happens.
57
+ *
58
+ * Omitted by credentials that cannot be renewed — a password is a password.
59
+ */
60
+ renew?(): Promise<void>;
61
+ /**
62
+ * Cookies this credential authenticates with, for the ways in where the
63
+ * session was negotiated elsewhere and handed over — SAML is one.
64
+ *
65
+ * Part of the contract rather than a method a provider happens to have: the
66
+ * first version left it off, so `SamlAuthProvider` held the cookies, nothing
67
+ * ever asked for them, and the recommended replacement authenticated with
68
+ * nothing at all.
69
+ */
70
+ cookies?(): string;
71
+ /**
72
+ * TLS options for credentials that live in the transport rather than in a
73
+ * header. Omitted by everything else.
74
+ */
75
+ httpsAgentOptions?(): AgentOptions;
76
+ /**
77
+ * Fetch the CSRF token this credential's own way.
78
+ *
79
+ * Only SPNEGO needs it: its token is consumed by one request and the exchange
80
+ * is the fetch. Everything else leaves it out and gets the shared path.
81
+ */
82
+ fetchCsrfToken?(url: string): Promise<string>;
83
+ }
84
+ //# sourceMappingURL=IAuthProvider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"IAuthProvider.d.ts","sourceRoot":"","sources":["../../src/auth/IAuthProvider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,MAAM,WAAW,aAAa;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAE1B;;;;;;;;;OASG;IACH,mBAAmB,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAEvC;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAExB;;;;;;;;OAQG;IACH,OAAO,CAAC,IAAI,MAAM,CAAC;IAEnB;;;OAGG;IACH,iBAAiB,CAAC,IAAI,YAAY,CAAC;IAEnC;;;;;OAKG;IACH,cAAc,CAAC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAC/C"}
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ /**
3
+ * How a connection proves who it is — passed in, not inherited.
4
+ *
5
+ * The class a consumer takes says which SYSTEM it is dialling; this says how it
6
+ * authenticates there. The two are independent: a communication user against
7
+ * ABAP Cloud and a bearer token against on-prem are both ordinary, and a design
8
+ * where the credential picks the system's session mechanism gets one of them
9
+ * wrong whichever way it guesses.
10
+ *
11
+ * Deliberately not "give me a token". Four of the five ways in are not tokens:
12
+ * basic is a header built from a username, a certificate is TLS material and no
13
+ * header at all, and SPNEGO is a negotiation with the server. What every one of
14
+ * them has in common is small, and it is this.
15
+ *
16
+ * `establishSession()` is NOT here. Measured across all five implementations it
17
+ * is the same work — fetch a CSRF token from `/sap/bc/adt/discovery`, keep it,
18
+ * tolerate a failure — with the only difference being a step before it, which is
19
+ * `prepare()`.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });