@mcp-abap-adt/connection 2.0.0 → 4.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.
@@ -1,14 +1,14 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Owns the session lifecycle: what state the session is in, who may use it, and
4
- * when it is safe to tear it down.
4
+ * when it stops being current.
5
5
  *
6
6
  * Deliberately knows nothing about SAP, HTTP, RFC, cookies or ADT. Identities
7
- * are opaque maps of name → value that someone else computed; windows are
8
- * opaque labels. That is what makes this unit testable without a server, and it
9
- * is where the hard part of the design lives: ordering, admission, deadlines.
7
+ * are opaque maps of name → value that someone else computed. That is what makes
8
+ * this unit testable without a server, and it is where the hard part lives:
9
+ * ordering, admission, and telling one session from the next.
10
10
  *
11
- * Design: docs/superpowers/specs/2026-07-27-session-lifecycle-design.md in
11
+ * Design: docs/superpowers/specs/2026-07-31-teardown-policy-design.md in
12
12
  * @mcp-abap-adt/adt-clients.
13
13
  */
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -24,28 +24,14 @@ class SessionLifecycle {
24
24
  state = 'disconnected';
25
25
  fingerprint = new Map();
26
26
  epoch = 0;
27
+ generation = 0;
27
28
  teardownPending = false;
28
- teardownLostSession = false;
29
- teardownAt = null;
30
- /** Set at expiry: admission is shut regardless of open windows. */
31
- admissionForcedShut = false;
32
- windows = new Map();
33
29
  inFlight = 0;
34
30
  tail = Promise.resolve();
35
31
  tailKind = null;
36
32
  tailPromise = null;
37
- waiters = [];
38
- ceilingMs;
39
- now;
40
- constructor(options = {}) {
41
- this.ceilingMs = options.ceilingMs ?? 600_000;
42
- this.now = options.now ?? (() => Date.now());
43
- }
44
33
  // ---------------------------------------------------------------- state ---
45
- /**
46
- * Whether a caller may start work. False throughout a teardown, including
47
- * while a grandfathered window is still finishing: finishing is not starting.
48
- */
34
+ /** Whether a caller may start work. False throughout a pending teardown. */
49
35
  get connected() {
50
36
  return this.state === 'connected' && !this.teardownPending;
51
37
  }
@@ -61,42 +47,55 @@ class SessionLifecycle {
61
47
  get teardownEpoch() {
62
48
  return this.epoch;
63
49
  }
64
- /** One entry per open window, abandoned ones included. */
65
- get openWindows() {
66
- return [...this.windows.values()].map((w) => w.label);
50
+ /**
51
+ * Which session is current, counted from zero.
52
+ *
53
+ * Two counters answer two different questions, and one cannot do both:
54
+ *
55
+ * - `epoch` — did the CALLER ask to stop? It moves only on a caller-initiated
56
+ * teardown, because a recovery must not cancel itself.
57
+ * - `generation` — is this still the session you were issued against? It moves
58
+ * on every change of which session is current, however caused.
59
+ *
60
+ * A fence built on `epoch` misses every internal teardown: after a session
61
+ * loss and a successful recovery, requests from the dead session carry the
62
+ * same epoch as the new one and sail straight through.
63
+ */
64
+ get sessionGeneration() {
65
+ return this.generation;
67
66
  }
68
67
  /**
69
68
  * Publishes a freshly established session, which also ENDS any teardown that
70
69
  * was pending: the flags describe the session being torn down, and this is a
71
70
  * different one. Without this, `connect()` after `disconnect()` — which the
72
71
  * design allows explicitly — leaves the lifecycle permanently unusable.
73
- *
74
- * Windows are dropped for the same reason: they belonged to the old session,
75
- * nothing here can close them, and a teardown that gave up on them has
76
- * already carried their labels out in its report. Keeping them would make
77
- * every future drain re-report locks from a session that no longer exists.
78
72
  */
79
73
  markConnected(fingerprint = new Map()) {
80
74
  this.state = 'connected';
81
75
  this.fingerprint = new Map(fingerprint);
82
76
  this.teardownPending = false;
83
- this.teardownLostSession = false;
84
- this.admissionForcedShut = false;
85
- this.teardownAt = null;
86
- this.windows.clear();
87
- this.wake();
77
+ this.generation += 1;
78
+ }
79
+ /**
80
+ * Drops the tracked identity without touching state or generation.
81
+ *
82
+ * For a session the caller discarded ON PURPOSE — a credential renewal, a
83
+ * cache invalidation. The next fingerprint then reads as `established` rather
84
+ * than `replaced`, which is the truth: nothing was taken from us.
85
+ */
86
+ forgetIdentity() {
87
+ this.fingerprint.clear();
88
88
  }
89
89
  markDisconnected() {
90
90
  this.state = 'disconnected';
91
91
  this.fingerprint.clear();
92
- this.wake();
93
92
  }
94
93
  /**
95
94
  * Classifies a freshly observed fingerprint.
96
95
  *
97
96
  * Additive: a name appearing where none was tracked is `established`, never
98
97
  * `replaced` — otherwise the identifier a LOCK response adds would read as a
99
- * new session and blow up the window it just opened.
98
+ * new session and condemn the operation it just covered.
100
99
  */
101
100
  observe(fingerprint) {
102
101
  let established = false;
@@ -114,22 +113,8 @@ class SessionLifecycle {
114
113
  return established ? 'established' : 'unchanged';
115
114
  }
116
115
  // ------------------------------------------------------------ admission ---
117
- /** True while an already-open window is allowed to finish its work. */
118
- get finishingWindowOpen() {
119
- return (this.teardownPending &&
120
- !this.teardownLostSession &&
121
- !this.admissionForcedShut &&
122
- this.windows.size > 0);
123
- }
124
- get admits() {
125
- if (this.state !== 'connected')
126
- return false;
127
- if (!this.teardownPending)
128
- return true;
129
- return this.finishingWindowOpen;
130
- }
131
116
  assertUsable() {
132
- if (!this.admits) {
117
+ if (this.state !== 'connected' || this.teardownPending) {
133
118
  throw sessionError(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED);
134
119
  }
135
120
  }
@@ -138,107 +123,52 @@ class SessionLifecycle {
138
123
  this.assertUsable();
139
124
  this.inFlight += 1;
140
125
  const epoch = this.epoch;
126
+ const generation = this.generation;
141
127
  let released = false;
142
128
  return {
143
129
  epoch,
130
+ generation,
144
131
  release: () => {
145
132
  if (released)
146
133
  return;
147
134
  released = true;
148
135
  this.inFlight -= 1;
149
- this.wake();
150
136
  },
151
137
  };
152
138
  }
153
- // -------------------------------------------------------------- windows ---
139
+ /** How many admitted requests have not settled. Diagnostics only — nothing waits on it. */
140
+ get requestsInFlight() {
141
+ return this.inFlight;
142
+ }
154
143
  /**
155
- * A lock outlives the request that takes it, so it is the one thing a pending
156
- * teardown refuses outright.
144
+ * Whether a lease may still touch shared state.
145
+ *
146
+ * A request outliving its session is ordinary now that a teardown does not
147
+ * wait: its response must not write cookies over a newer session's, and must
148
+ * not be read as a replacement — which would raise a session-lost teardown
149
+ * against a session that is perfectly healthy.
157
150
  */
158
- beginWindow(label) {
159
- if (this.state !== 'connected' || this.teardownPending) {
160
- throw sessionError(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED);
161
- }
162
- const token = Symbol(label);
163
- this.windows.set(token, { label, abandoned: false });
164
- return token;
165
- }
166
- /** A token matching no open window is ignored: double close, foreign token. */
167
- endWindow(token) {
168
- if (!this.windows.delete(token))
169
- return;
170
- this.wake();
151
+ isCurrent(lease) {
152
+ return lease.generation === this.generation;
171
153
  }
172
154
  // ------------------------------------------------------------- teardown ---
173
155
  beginTeardown({ origin, sessionLost }) {
174
- if (!this.teardownPending) {
175
- this.teardownAt = this.now();
176
- }
177
156
  this.teardownPending = true;
178
157
  if (origin === 'caller') {
179
158
  this.epoch += 1;
180
159
  }
160
+ // The moment a session stops being current is the moment leases against it
161
+ // go stale — whether or not a replacement exists yet. Counting only at
162
+ // markConnected() leaves the gap between a teardown and the next connect,
163
+ // where a late response would write into state just cleared.
164
+ this.generation += 1;
181
165
  if (sessionLost) {
182
- this.teardownLostSession = true;
183
- // Nothing can finish over a session that is gone: give up on every window
184
- // now, and drop the identity so a later comparison sees the change.
185
- for (const entry of this.windows.values())
186
- entry.abandoned = true;
187
166
  this.fingerprint.clear();
188
167
  }
189
- this.wake();
190
168
  }
191
169
  get teardownRequested() {
192
170
  return this.teardownPending;
193
171
  }
194
- /**
195
- * Resolves when nothing is in flight and no window is still worth waiting
196
- * for. Bounded by the ceiling measured from the teardown request — absolute,
197
- * never extended by request activity.
198
- *
199
- * On expiry, before resolving and without yielding in between: shuts
200
- * admission, gives up on the remaining windows, then waits once more for the
201
- * already-admitted requests to settle.
202
- */
203
- async drain() {
204
- // `??`, not `||`: a teardown requested at timestamp 0 is a real teardown,
205
- // and treating it as absent would restart the ceiling from drain() — making
206
- // the deadline relative to the wrong event, which is the sliding behaviour
207
- // this design rejects.
208
- const deadline = (this.teardownAt ?? this.now()) + this.ceilingMs;
209
- while (this.inFlight > 0 || this.liveWindows > 0) {
210
- const remaining = deadline - this.now();
211
- if (remaining <= 0)
212
- break;
213
- await this.changed(remaining);
214
- }
215
- if (this.inFlight === 0 && this.liveWindows === 0) {
216
- return { abandonedWindows: this.abandonedLabels() };
217
- }
218
- // Expiry. Synchronous, before any await: no request may enter after this.
219
- this.admissionForcedShut = true;
220
- for (const entry of this.windows.values())
221
- entry.abandoned = true;
222
- while (this.inFlight > 0) {
223
- await this.changed();
224
- }
225
- return { abandonedWindows: this.abandonedLabels() };
226
- }
227
- get liveWindows() {
228
- let live = 0;
229
- for (const entry of this.windows.values())
230
- if (!entry.abandoned)
231
- live += 1;
232
- return live;
233
- }
234
- abandonedLabels() {
235
- const labels = new Set();
236
- for (const entry of this.windows.values()) {
237
- if (entry.abandoned)
238
- labels.add(entry.label);
239
- }
240
- return [...labels];
241
- }
242
172
  // ---------------------------------------------------------- transitions ---
243
173
  /**
244
174
  * Runs a transition on the serializing tail.
@@ -248,16 +178,11 @@ class SessionLifecycle {
248
178
  * when nothing is queued behind it, so a join can never overtake a queued
249
179
  * transition of another kind. `recover` and `cleanup` never join and are
250
180
  * never joined: a recovery carries its own request's baseline, and an
251
- * internal cleanup owes its result to nobody while a caller's teardown owes a
252
- * report.
181
+ * internal cleanup owes its result to nobody.
253
182
  */
254
183
  transition(kind, run) {
255
184
  const joinable = kind === 'connect' || kind === 'disconnect';
256
185
  if (joinable && this.tailKind === kind && this.tailPromise) {
257
- // Joining shares the ANSWER, not just the execution: a joiner that got a
258
- // resolved promise carrying nothing would have to invent a result, and
259
- // for a teardown that means reporting no abandoned locks when there were
260
- // some. Callers of one kind ask the same question, so they get one reply.
261
186
  return this.tailPromise;
262
187
  }
263
188
  const queued = this.tail.then(run, run);
@@ -273,29 +198,5 @@ class SessionLifecycle {
273
198
  queued.then(settle, settle);
274
199
  return queued;
275
200
  }
276
- // ----------------------------------------------------------- internals ---
277
- wake() {
278
- const waiters = this.waiters;
279
- this.waiters = [];
280
- for (const resolve of waiters)
281
- resolve();
282
- }
283
- changed(timeoutMs) {
284
- return new Promise((resolve) => {
285
- let done = false;
286
- const finish = () => {
287
- if (done)
288
- return;
289
- done = true;
290
- resolve();
291
- };
292
- this.waiters.push(finish);
293
- if (timeoutMs !== undefined) {
294
- const timer = setTimeout(finish, timeoutMs);
295
- if (typeof timer.unref === 'function')
296
- timer.unref();
297
- }
298
- });
299
- }
300
201
  }
301
202
  exports.SessionLifecycle = SessionLifecycle;
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
 
@@ -46,50 +46,39 @@ 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()` and `reset()` stop the connection from serving requests until
50
- the next `connect()`. An in-flight request is not cut off: a teardown drains
51
- before it clears anything.
49
+ `disconnect()` and `reset()` stop the connection from serving requests until the
50
+ next `connect()`. An in-flight request is not cut off and not waited for either:
51
+ it runs to completion, and its result is fenced so it cannot touch a session
52
+ established since.
52
53
 
53
- ### 4. If you hold locks, tell the connection
54
+ ### 4. If you hold locks, do not let a timeout cut the span
54
55
 
55
- Available on the HTTP connection classes; see the availability note in
56
- [USAGE.md — Session Lifecycle](./USAGE.md#session-lifecycle).
56
+ The connection does **not** track your locks. It does not know one exists, what
57
+ object it covers, or what would release it — pairing every LOCK with its UNLOCK
58
+ belongs to `@mcp-abap-adt/adt-clients`, which holds the handles.
57
59
 
58
- ```typescript
59
- const token = connection.beginWindow('Class/ZCL_MY_CLASS');
60
-
61
- let lockHandle: string | undefined;
62
- try {
63
- lockHandle = await lock(connection, 'ZCL_MY_CLASS');
64
- } catch (error) {
65
- connection.endWindow(token); // confirmed NOT locked: nothing is held
66
- throw error;
67
- }
60
+ What this layer can do is not let a short timeout abort a request mid-span, which
61
+ would leave an operation whose outcome you cannot determine — a `LOCK` that may
62
+ have succeeded server-side, with a handle you never received:
68
63
 
64
+ ```typescript
65
+ connection.beginCriticalSection();
69
66
  try {
70
- await update(connection, 'ZCL_MY_CLASS', lockHandle);
71
- await unlock(connection, 'ZCL_MY_CLASS', lockHandle);
72
- connection.endWindow(token); // confirmed unlocked: the lock is released
73
- } catch (error) {
74
- // Left open on purpose: the unlock may have failed, never gone out, or come
75
- // back with an unknown outcome. Do NOT use a finally here.
76
- throw error;
67
+ const handle = await lock(connection, 'ZCL_MY_CLASS');
68
+ await update(connection, 'ZCL_MY_CLASS', handle);
69
+ await unlock(connection, 'ZCL_MY_CLASS', handle);
70
+ } finally {
71
+ connection.endCriticalSection();
77
72
  }
78
73
  ```
79
74
 
80
- You are not required to use windows at all. Without them a teardown cannot know
81
- a lock is open, so it will not wait for your unlock and will not report the lock
82
- if it gives up — it simply tears down, and the object stays locked on the server.
83
-
84
- But do not open a window and forget to close it on success: every later teardown
85
- then waits out `SAP_TIMEOUT_CRITICAL` before reporting it as abandoned.
75
+ This is not new in 2.x — it has been available since 1.9.0 and is unchanged.
86
76
 
87
77
  ### 5. A new error you should handle
88
78
 
89
- `ADT_SESSION_REPLACED` means the SAP session is gone. It is raised when a
90
- replacement happens **while you hold a lock window**, and whenever the server
91
- itself says the session no longer exists — that second case regardless of any
92
- window. **Your lock handle is dead**: unlocking with it will not work, and the
79
+ `ADT_SESSION_REPLACED` means the SAP session is gone. It is raised whenever the
80
+ session identity changes under us, and whenever the server itself says the session
81
+ no longer exists. **Your lock handle is dead**: unlocking with it will not work, and the
93
82
  object may still be locked on the server. The connector does not retry such
94
83
  a request — retrying blindly is what produced further orphaned locks in the
95
84
  field.
@@ -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.
@@ -2,10 +2,11 @@
2
2
 
3
3
  > **The header is not what tracks your lock.** `setSessionType('stateful')` sets
4
4
  > a per-request header and nothing more — another handler can flip it back while
5
- > your lock is genuinely open, and a batch never sets it at all. Tell the
6
- > connection about the lock itself with `beginWindow()` / `endWindow()`: that is
7
- > what a teardown waits for, what refuses to be abandoned silently, and what
8
- > makes a replaced session fatal instead of unnoticed. See
5
+ > your lock is genuinely open, and a batch never sets it at all. Nothing in this
6
+ > layer tracks locks: that belongs to `@mcp-abap-adt/adt-clients`, which holds the
7
+ > handles and pairs each LOCK with its UNLOCK per object. What the connection
8
+ > offers is `beginCriticalSection()` / `endCriticalSection()`, so a short timeout
9
+ > cannot abort a request mid-span. See
9
10
  > [USAGE.md — Session Lifecycle](./USAGE.md#session-lifecycle).
10
11
 
11
12
 
@@ -81,6 +82,12 @@ Every ADT request issued through `makeAdtRequest` automatically:
81
82
 
82
83
  This logic is transparent to callers (Builders, handlers, CLI scripts).
83
84
 
85
+ On a JWT connection one case is **not** transparent, and cannot be: a 401 that leads to a token
86
+ refresh also replaces the SAP session, because the renewed credential cannot keep the old one.
87
+ Inside a lock window that surfaces as `ADT_SESSION_REPLACED` rather than a request quietly
88
+ continuing on a session your lock is not in. A 403 never does this — it is an authorization
89
+ answer, not a credential one, and nothing is torn down for it.
90
+
84
91
  ---
85
92
 
86
93
  ## Interaction With ADT Clients