@mcp-abap-adt/connection 1.10.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +827 -0
- package/README.md +44 -11
- package/dist/__tests__/helpers/session.d.ts +15 -0
- package/dist/__tests__/helpers/session.d.ts.map +1 -0
- package/dist/__tests__/helpers/session.js +19 -0
- package/dist/auth/ntlm.d.ts +15 -0
- package/dist/auth/ntlm.d.ts.map +1 -1
- package/dist/auth/ntlm.js +38 -0
- package/dist/connection/AbstractAbapConnection.d.ts +180 -12
- package/dist/connection/AbstractAbapConnection.d.ts.map +1 -1
- package/dist/connection/AbstractAbapConnection.js +398 -19
- package/dist/connection/BaseAbapConnection.d.ts +5 -1
- package/dist/connection/BaseAbapConnection.d.ts.map +1 -1
- package/dist/connection/BaseAbapConnection.js +11 -1
- package/dist/connection/CertificateAbapConnection.d.ts +5 -1
- package/dist/connection/CertificateAbapConnection.d.ts.map +1 -1
- package/dist/connection/CertificateAbapConnection.js +11 -1
- package/dist/connection/JwtAbapConnection.d.ts +3 -2
- package/dist/connection/JwtAbapConnection.d.ts.map +1 -1
- package/dist/connection/JwtAbapConnection.js +19 -8
- package/dist/connection/KerberosAbapConnection.d.ts +5 -1
- package/dist/connection/KerberosAbapConnection.d.ts.map +1 -1
- package/dist/connection/KerberosAbapConnection.js +54 -3
- package/dist/connection/SamlAbapConnection.d.ts +5 -1
- package/dist/connection/SamlAbapConnection.d.ts.map +1 -1
- package/dist/connection/SamlAbapConnection.js +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/session/SessionLifecycle.d.ts +50 -70
- package/dist/session/SessionLifecycle.d.ts.map +1 -1
- package/dist/session/SessionLifecycle.js +59 -158
- package/docs/INDEX.md +106 -0
- package/docs/INSTALLATION.md +304 -0
- package/docs/JWT_AUTH_TOOLS.md +142 -0
- package/docs/MIGRATION-2.0.md +114 -0
- package/docs/SCOPE.md +44 -0
- package/docs/STATEFUL_SESSION_GUIDE.md +122 -0
- package/docs/USAGE.md +745 -0
- package/examples/README.md +112 -0
- package/examples/basic-connection.js +55 -0
- package/examples/jwt-with-token-refresh.js +87 -0
- package/examples/saml-connection.js +52 -0
- package/examples/websocket-transport.js +87 -0
- package/package.json +11 -4
|
@@ -38,11 +38,24 @@ const node_crypto_1 = require("node:crypto");
|
|
|
38
38
|
const node_https_1 = require("node:https");
|
|
39
39
|
const interfaces_1 = require("@mcp-abap-adt/interfaces");
|
|
40
40
|
const axios_1 = __importStar(require("axios"));
|
|
41
|
+
const SessionLifecycle_js_1 = require("../session/SessionLifecycle.js");
|
|
41
42
|
const timeouts_js_1 = require("../utils/timeouts.js");
|
|
42
43
|
const csrfConfig_js_1 = require("./csrfConfig.js");
|
|
44
|
+
/**
|
|
45
|
+
* Declares the capabilities explicitly rather than satisfying them by accident.
|
|
46
|
+
* `AbapConnection` is the base contract every transport honours; these two are
|
|
47
|
+
* the HTTP session's own, and naming them means a signature that drifts from
|
|
48
|
+
* the published contract fails to compile here instead of at the consumer.
|
|
49
|
+
*/
|
|
43
50
|
class AbstractAbapConnection {
|
|
44
51
|
config;
|
|
45
52
|
logger;
|
|
53
|
+
/**
|
|
54
|
+
* Owns session state, admission and teardown ordering. Composed rather than
|
|
55
|
+
* inherited: RfcAbapConnection implements the interface directly, so the two
|
|
56
|
+
* transports share this unit instead of a base class.
|
|
57
|
+
*/
|
|
58
|
+
lifecycle = new SessionLifecycle_js_1.SessionLifecycle();
|
|
46
59
|
axiosInstance = null;
|
|
47
60
|
csrfToken = null;
|
|
48
61
|
cookies = null;
|
|
@@ -161,7 +174,278 @@ class AbstractAbapConnection {
|
|
|
161
174
|
getConfig() {
|
|
162
175
|
return this.config;
|
|
163
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Establishes the session, once, under the lifecycle.
|
|
179
|
+
*
|
|
180
|
+
* Idempotent, and concurrent callers share one establishment: the transition
|
|
181
|
+
* joins the tail of its own kind. A teardown requested while establishment
|
|
182
|
+
* was in flight means the caller asked to stop, so usability is NOT published
|
|
183
|
+
* and what was established is released — otherwise a slow connect would hand
|
|
184
|
+
* back a session someone had already discarded.
|
|
185
|
+
*/
|
|
186
|
+
async connect() {
|
|
187
|
+
// Captured HERE, not inside the transition: the callback runs when this
|
|
188
|
+
// reaches the front of the queue, by which time a teardown the caller
|
|
189
|
+
// requested afterwards has already bumped the epoch — and comparing it
|
|
190
|
+
// against itself would let the connect publish a session the caller had
|
|
191
|
+
// asked to stop. The baseline is "when the caller asked to connect".
|
|
192
|
+
const baselineEpoch = this.lifecycle.teardownEpoch;
|
|
193
|
+
await this.lifecycle.transition('connect', async () => {
|
|
194
|
+
if (this.lifecycle.connected)
|
|
195
|
+
return;
|
|
196
|
+
await this.establishAndCommit(baselineEpoch);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Tears the session down. Never throws, and always settles.
|
|
201
|
+
*
|
|
202
|
+
* It waits for NOTHING. Deciding when to disconnect is the caller's, and so is
|
|
203
|
+
* preparing for it — finishing chains, releasing locks. Waiting here on a
|
|
204
|
+
* request whose caller chose no timeout is what made a teardown unbounded, and
|
|
205
|
+
* an unbounded teardown blocks every later transition on the serialized tail.
|
|
206
|
+
*
|
|
207
|
+
* Requests already in flight run to completion untouched. Generation fencing
|
|
208
|
+
* (see `SessionLifecycle.isCurrent`) keeps their results from reaching this
|
|
209
|
+
* connection afterwards.
|
|
210
|
+
*
|
|
211
|
+
* Sends no ADT session-close — see the design's D2.
|
|
212
|
+
*/
|
|
213
|
+
async disconnect() {
|
|
214
|
+
// Synchronous, at the call: admission shuts and the generation moves before
|
|
215
|
+
// anything is queued, so a caller who has asked to disconnect cannot have
|
|
216
|
+
// requests still going through while this waits its turn.
|
|
217
|
+
this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: false });
|
|
218
|
+
await this.lifecycle.transition('disconnect', async () => {
|
|
219
|
+
this.clearSessionState();
|
|
220
|
+
this.lifecycle.markDisconnected();
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
isConnected() {
|
|
224
|
+
return this.lifecycle.connected;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Fingerprint of the SAP-side session, or null when none is known.
|
|
228
|
+
*
|
|
229
|
+
* `null` is NOT a statement about the connection. Two situations produce it:
|
|
230
|
+
* no session exists, or the connection is live over a server that issued no
|
|
231
|
+
* session cookie. Use {@link isConnected} for connection state.
|
|
232
|
+
*
|
|
233
|
+
* It follows that null → non-null is not a replacement but an identity being
|
|
234
|
+
* learned; only a CHANGED value means the session was replaced.
|
|
235
|
+
*/
|
|
236
|
+
getSessionIdentity() {
|
|
237
|
+
return this.lifecycle.identity;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Discards the session at a caller's request: cancels queued recoveries and
|
|
241
|
+
* queues the cleanup rather than tearing down under a live request.
|
|
242
|
+
*/
|
|
164
243
|
reset() {
|
|
244
|
+
this.lifecycle.beginTeardown({ origin: 'caller', sessionLost: true });
|
|
245
|
+
void this.lifecycle.transition('cleanup', async () => {
|
|
246
|
+
this.clearSessionState();
|
|
247
|
+
this.lifecycle.markDisconnected();
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Re-establishes the session for a request that is recovering from a
|
|
252
|
+
* credential renewal, then lets that request retry.
|
|
253
|
+
*
|
|
254
|
+
* Runs as its own `recover` transition, which never joins another: each
|
|
255
|
+
* recovery carries the baseline of its own request. It yields to a caller's
|
|
256
|
+
* teardown — if the epoch moved since `baselineEpoch`, someone asked to stop
|
|
257
|
+
* while this was being prepared, and a retry must not resurrect a session
|
|
258
|
+
* they discarded.
|
|
259
|
+
*
|
|
260
|
+
* The transition queues behind the cleanup that the renewal itself raised, so
|
|
261
|
+
* it never re-establishes on top of stale transport state.
|
|
262
|
+
*/
|
|
263
|
+
async recoverSession(baselineEpoch) {
|
|
264
|
+
await this.lifecycle.transition('recover', async () => {
|
|
265
|
+
await this.establishAndCommit(baselineEpoch);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Establishes a session and publishes it — but only if nobody asked to stop
|
|
270
|
+
* meanwhile.
|
|
271
|
+
*
|
|
272
|
+
* The epoch is checked BEFORE, so a teardown already requested costs no round
|
|
273
|
+
* trip, and AFTER, because establishment takes time and a caller can ask to
|
|
274
|
+
* stop during it. Checking only before is the defect this exists to prevent:
|
|
275
|
+
* markConnected() would then clear the teardown state and hand back a session
|
|
276
|
+
* the caller had already discarded.
|
|
277
|
+
*
|
|
278
|
+
* Shared by connect() and recoverSession() rather than written twice —
|
|
279
|
+
* the two drifted apart once already, and a third caller would drift again.
|
|
280
|
+
*/
|
|
281
|
+
async establishAndCommit(baselineEpoch) {
|
|
282
|
+
if (this.lifecycle.teardownEpoch !== baselineEpoch) {
|
|
283
|
+
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested for this connection');
|
|
284
|
+
}
|
|
285
|
+
// Whatever session arrives from here is one we are deliberately
|
|
286
|
+
// establishing, so it must not read as a replacement: the identity policy
|
|
287
|
+
// treats a changed fingerprint as fatal, and it cannot tell our own
|
|
288
|
+
// re-establishment from a session taken out from under us. Forgetting first
|
|
289
|
+
// makes the new fingerprint `established`, which is what it is.
|
|
290
|
+
this.lifecycle.forgetIdentity();
|
|
291
|
+
try {
|
|
292
|
+
await this.establishSession();
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
// A failed establishment leaves debris that poisons the next attempt: the
|
|
296
|
+
// 401 that rejected us may still have carried a Set-Cookie, and every
|
|
297
|
+
// subclass treats a cookie as proof that auth is already settled —
|
|
298
|
+
// buildAuthorizationHeader() returns '' once one exists. So the next
|
|
299
|
+
// connect() would go out with NO credentials at all, and be rejected for
|
|
300
|
+
// a reason that has nothing to do with why the first one failed.
|
|
301
|
+
//
|
|
302
|
+
// Safe to clear here, unlike the abandonment path below: establishSession()
|
|
303
|
+
// threw, so no session was published, and admission requires a connected
|
|
304
|
+
// lifecycle — nothing can be in flight over what this drops.
|
|
305
|
+
this.invalidateSession();
|
|
306
|
+
// And the identity with it. The rejecting response was still observed, so
|
|
307
|
+
// its cookie was recorded as a session that had just been established —
|
|
308
|
+
// leaving getSessionIdentity() naming a session that never existed while
|
|
309
|
+
// isConnected() says false. Two answers to one question is worse than
|
|
310
|
+
// either.
|
|
311
|
+
this.lifecycle.markDisconnected();
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
if (this.lifecycle.teardownEpoch !== baselineEpoch) {
|
|
315
|
+
// Abandon WITHOUT clearing: the teardown that bumped the epoch is already
|
|
316
|
+
// queued, and it clears after draining. Clearing here would pull cookies,
|
|
317
|
+
// the CSRF token and the axios instance out from under a request that is
|
|
318
|
+
// still in flight — breaking the guarantee this whole change rests on,
|
|
319
|
+
// from inside the guard meant to protect it.
|
|
320
|
+
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED, 'Establishment abandoned: a teardown was requested while it was in flight');
|
|
321
|
+
}
|
|
322
|
+
// establishSession() throws on failure, so reaching here means a session
|
|
323
|
+
// exists. There is no third outcome: no "connected but unusable", no
|
|
324
|
+
// resolved promise over an empty jar.
|
|
325
|
+
this.lifecycle.markConnected(this.sessionFingerprint());
|
|
326
|
+
}
|
|
327
|
+
/** The teardown epoch, for a recovery to capture before it starts. */
|
|
328
|
+
get teardownEpoch() {
|
|
329
|
+
return this.lifecycle.teardownEpoch;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Raises a session-lost teardown from inside request handling.
|
|
333
|
+
*
|
|
334
|
+
* There are exactly three things that can cost us the ABAP session, and they
|
|
335
|
+
* were found one at a time precisely because they were written apart. They go
|
|
336
|
+
* through here so a fourth joins the list instead of inventing its own
|
|
337
|
+
* sequence:
|
|
338
|
+
*
|
|
339
|
+
* - the credential was renewed (the injected auth says so);
|
|
340
|
+
* - the server says the session is gone (a dead-session response);
|
|
341
|
+
* - the tracked cookie changed under us while a lock was held.
|
|
342
|
+
*
|
|
343
|
+
* `internal` origin, so it does not cancel the recovery that raised it, and
|
|
344
|
+
* `sessionLost`, so admission shuts at once and the identity is dropped
|
|
345
|
+
* immediately — a later comparison must see the change, and on a dead session
|
|
346
|
+
* the cookie is unchanged, so only the state can tell.
|
|
347
|
+
*
|
|
348
|
+
* Does not await: it is called from inside a request, and the cleanup must not
|
|
349
|
+
* wait for the very request that raised it.
|
|
350
|
+
*/
|
|
351
|
+
raiseSessionLost(reason) {
|
|
352
|
+
this.logger?.warn(`Session lost: ${reason}`);
|
|
353
|
+
this.lifecycle.beginTeardown({ origin: 'internal', sessionLost: true });
|
|
354
|
+
void this.lifecycle.transition('cleanup', async () => {
|
|
355
|
+
this.clearSessionState();
|
|
356
|
+
this.lifecycle.markDisconnected();
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
/** The credential-renewal raiser; see raiseSessionLost(). */
|
|
360
|
+
discardSession() {
|
|
361
|
+
this.raiseSessionLost('the credential backing it was renewed');
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Whether an error is this connection's own verdict about the session rather
|
|
365
|
+
* than something the server said about a request.
|
|
366
|
+
*
|
|
367
|
+
* A retry path that swallows one of these and rethrows the original error
|
|
368
|
+
* turns "your lock is dead" back into "your request 403'd", which is the very
|
|
369
|
+
* information the caller needs and the only one it cannot recover itself.
|
|
370
|
+
*/
|
|
371
|
+
isSessionVerdict(error) {
|
|
372
|
+
const code = error?.code;
|
|
373
|
+
return (code === interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED ||
|
|
374
|
+
code === interfaces_1.ADT_SESSION_ERROR.NOT_CONNECTED ||
|
|
375
|
+
code === interfaces_1.ADT_SESSION_ERROR.RELEASE_PENDING);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Folds a response into the session state AND acts on what it means, in one
|
|
379
|
+
* step.
|
|
380
|
+
*
|
|
381
|
+
* Never call updateCookiesFromResponse() directly: it MUTATES the fingerprint,
|
|
382
|
+
* so discarding its classification absorbs a replacement silently and every
|
|
383
|
+
* later check reads `unchanged`. That is one call site forgetting, and it
|
|
384
|
+
* happened — on the error path and on every retry response.
|
|
385
|
+
*/
|
|
386
|
+
observeResponse(headers, generation) {
|
|
387
|
+
// Fenced by SESSION GENERATION, not by the teardown epoch. Only a
|
|
388
|
+
// caller-initiated teardown moves the epoch — a recovery deliberately does
|
|
389
|
+
// not — so after a session loss and a successful recovery, a request from
|
|
390
|
+
// the dead session carries the same epoch as the new one and would sail
|
|
391
|
+
// straight through. The generation moves whenever the current session does.
|
|
392
|
+
//
|
|
393
|
+
// `undefined` means "not issued against a session": the CSRF fetch during
|
|
394
|
+
// connect() has no lease and must apply, since it is establishing the very
|
|
395
|
+
// session this would compare against.
|
|
396
|
+
if (generation !== undefined &&
|
|
397
|
+
generation !== this.lifecycle.sessionGeneration) {
|
|
398
|
+
this.logger?.debug('Ignoring a response from a previous session: its effects are fenced');
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
this.applyIdentityPolicy(this.updateCookiesFromResponse(headers));
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Acts on what a response said about the session identity.
|
|
405
|
+
*
|
|
406
|
+
* A replacement is always fatal, and that is a narrowing: an earlier version
|
|
407
|
+
* tolerated it "when no lock is held", deciding from the connection's own
|
|
408
|
+
* lock windows. Those are gone, and rightly — this layer does not know that a
|
|
409
|
+
* lock exists, what object it covers or what would release it. Locks are
|
|
410
|
+
* tracked a layer up, per object, by the code that took them.
|
|
411
|
+
*
|
|
412
|
+
* So the rule is written from what this layer CAN know: the ABAP session we
|
|
413
|
+
* were speaking to is not the one we are speaking to now. Anything the caller
|
|
414
|
+
* held against the old one is dead, and continuing quietly would hand them a
|
|
415
|
+
* session they never opened — the failure this whole design exists to prevent.
|
|
416
|
+
* Being wrong in this direction costs a reconnect; being wrong the other way
|
|
417
|
+
* costs a lock nobody can find.
|
|
418
|
+
*/
|
|
419
|
+
applyIdentityPolicy(classification) {
|
|
420
|
+
if (classification !== 'replaced')
|
|
421
|
+
return;
|
|
422
|
+
this.raiseSessionLost('the session cookie changed under us');
|
|
423
|
+
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session was replaced; anything held against the previous one is dead');
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Whether the server is telling us the session it was given no longer exists.
|
|
427
|
+
*
|
|
428
|
+
* The E19 shape was HTTP 400 with "Session not found", answered in ~60 ms
|
|
429
|
+
* with the cookie present — which is why identity comparison cannot see this:
|
|
430
|
+
* the cookie, and therefore the fingerprint, is completely unchanged. The
|
|
431
|
+
* exact match is landscape-specific and is one of the live probes this design
|
|
432
|
+
* still owes.
|
|
433
|
+
*/
|
|
434
|
+
isDeadSessionResponse(error) {
|
|
435
|
+
if (!(error instanceof axios_1.AxiosError) || !error.response)
|
|
436
|
+
return false;
|
|
437
|
+
if (error.response.status !== 400)
|
|
438
|
+
return false;
|
|
439
|
+
const text = [
|
|
440
|
+
error.response.statusText,
|
|
441
|
+
typeof error.response.data === 'string' ? error.response.data : '',
|
|
442
|
+
]
|
|
443
|
+
.join(' ')
|
|
444
|
+
.toLowerCase();
|
|
445
|
+
return text.includes('session not found');
|
|
446
|
+
}
|
|
447
|
+
/** Drops everything that described the session. Not a lifecycle transition. */
|
|
448
|
+
clearSessionState() {
|
|
165
449
|
if (this.axiosInstance) {
|
|
166
450
|
this.axiosInstance.interceptors.request.clear();
|
|
167
451
|
this.axiosInstance.interceptors.response.clear();
|
|
@@ -172,6 +456,23 @@ class AbstractAbapConnection {
|
|
|
172
456
|
this.cookieStore.clear();
|
|
173
457
|
// Note: baseUrl is not reset as it's derived from immutable config
|
|
174
458
|
}
|
|
459
|
+
/**
|
|
460
|
+
* The session-bearing cookies, and only those.
|
|
461
|
+
*
|
|
462
|
+
* `sap-XSRF_*` is excluded deliberately: it changes on a token refresh WITHIN
|
|
463
|
+
* the same session, so including it would report an ordinary refresh as a new
|
|
464
|
+
* session and fail exactly where nothing is wrong. `sap-usercontext` is ours,
|
|
465
|
+
* overwritten on every response.
|
|
466
|
+
*/
|
|
467
|
+
sessionFingerprint() {
|
|
468
|
+
const fingerprint = new Map();
|
|
469
|
+
for (const [name, value] of this.cookieStore) {
|
|
470
|
+
if (name.startsWith('SAP_SESSIONID')) {
|
|
471
|
+
fingerprint.set(name, value);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return fingerprint;
|
|
475
|
+
}
|
|
175
476
|
async getBaseUrl() {
|
|
176
477
|
return this.baseUrl;
|
|
177
478
|
}
|
|
@@ -187,6 +488,20 @@ class AbstractAbapConnection {
|
|
|
187
488
|
return headers;
|
|
188
489
|
}
|
|
189
490
|
async makeAdtRequest(options) {
|
|
491
|
+
// Admission first, synchronously, before any await: the check and the
|
|
492
|
+
// count must happen in one step, or a request could be admitted and still
|
|
493
|
+
// be invisible to a teardown draining at that instant. Throws
|
|
494
|
+
// NOT_CONNECTED when the caller never connected, or when a teardown has
|
|
495
|
+
// shut the door.
|
|
496
|
+
const lease = this.lifecycle.admitRequest();
|
|
497
|
+
try {
|
|
498
|
+
return await this.performRequest(options, lease);
|
|
499
|
+
}
|
|
500
|
+
finally {
|
|
501
|
+
lease.release();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
async performRequest(options, lease) {
|
|
190
505
|
const { url: endpoint, method, timeout, data, params, headers: customHeaders, } = options;
|
|
191
506
|
const normalizedMethod = method.toUpperCase();
|
|
192
507
|
// Build full URL: baseUrl + endpoint
|
|
@@ -282,7 +597,7 @@ class AbstractAbapConnection {
|
|
|
282
597
|
});
|
|
283
598
|
try {
|
|
284
599
|
const response = await this.getAxiosInstance()(requestConfig);
|
|
285
|
-
this.
|
|
600
|
+
this.observeResponse(response.headers, lease.generation);
|
|
286
601
|
this.logger?.debug(`Request succeeded with status ${response.status}`, {
|
|
287
602
|
type: 'REQUEST_SUCCESS',
|
|
288
603
|
status: response.status,
|
|
@@ -292,6 +607,20 @@ class AbstractAbapConnection {
|
|
|
292
607
|
return response;
|
|
293
608
|
}
|
|
294
609
|
catch (error) {
|
|
610
|
+
// FENCE FIRST, before anything reads or writes shared state.
|
|
611
|
+
//
|
|
612
|
+
// Fencing observeResponse() alone was not enough, and the gap was wide:
|
|
613
|
+
// everything below acts on this connection, not on the request. A late
|
|
614
|
+
// 400 "session not found" would call raiseSessionLost() and tear down the
|
|
615
|
+
// healthy session established since; a late 401/403 would call
|
|
616
|
+
// invalidateSession(), write a fresh CSRF token, and RETRY — replaying a
|
|
617
|
+
// mutation from a dead session inside the live one.
|
|
618
|
+
//
|
|
619
|
+
// A stale request gets its error back and nothing else happens.
|
|
620
|
+
if (!this.lifecycle.isCurrent(lease)) {
|
|
621
|
+
this.logger?.debug('A request from a previous session failed; its recovery is fenced');
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
295
624
|
const errorDetails = {
|
|
296
625
|
type: 'REQUEST_ERROR',
|
|
297
626
|
message: error instanceof Error ? error.message : String(error),
|
|
@@ -305,7 +634,17 @@ class AbstractAbapConnection {
|
|
|
305
634
|
typeof error.response.data === 'string'
|
|
306
635
|
? error.response.data.slice(0, 200)
|
|
307
636
|
: JSON.stringify(error.response.data).slice(0, 200);
|
|
308
|
-
this.
|
|
637
|
+
this.observeResponse(error.response.headers, lease.generation);
|
|
638
|
+
}
|
|
639
|
+
// The server telling us the session is gone is invisible to the identity
|
|
640
|
+
// comparison: the cookie, and therefore the fingerprint, is unchanged.
|
|
641
|
+
// Only the state can see it, and it must say so at once — otherwise a
|
|
642
|
+
// later unlockAll() finds a match and unlocks over a dead session.
|
|
643
|
+
if (this.isDeadSessionResponse(error)) {
|
|
644
|
+
this.raiseSessionLost('the server reports the session no longer exists');
|
|
645
|
+
// No internal retry: a blind retry here is what produced further locks
|
|
646
|
+
// in the field. The caller decides.
|
|
647
|
+
throw (0, SessionLifecycle_js_1.sessionError)(interfaces_1.ADT_SESSION_ERROR.SESSION_REPLACED, 'The SAP session no longer exists; any lock handle from it is dead');
|
|
309
648
|
}
|
|
310
649
|
// Check if this is a network error (connection refused, timeout, DNS, etc.)
|
|
311
650
|
// Don't retry for network errors - these indicate infrastructure/VPN issues
|
|
@@ -347,7 +686,7 @@ class AbstractAbapConnection {
|
|
|
347
686
|
delete requestHeaders.cookie;
|
|
348
687
|
}
|
|
349
688
|
try {
|
|
350
|
-
this.setCsrfToken(await this.fetchCsrfToken(requestUrl, 5, 2000));
|
|
689
|
+
this.setCsrfToken(await this.fetchCsrfToken(requestUrl, 5, 2000, lease.generation));
|
|
351
690
|
const refreshedToken = this.getCsrfToken();
|
|
352
691
|
if (refreshedToken) {
|
|
353
692
|
requestHeaders['x-csrf-token'] = refreshedToken;
|
|
@@ -357,10 +696,16 @@ class AbstractAbapConnection {
|
|
|
357
696
|
requestHeaders.Cookie = refreshedCookies;
|
|
358
697
|
}
|
|
359
698
|
const retryResponse = await this.getAxiosInstance()(requestConfig);
|
|
360
|
-
this.
|
|
699
|
+
this.observeResponse(retryResponse.headers, lease.generation);
|
|
361
700
|
return retryResponse;
|
|
362
701
|
}
|
|
363
702
|
catch (retryError) {
|
|
703
|
+
// A session verdict outranks the error that started the retry: the
|
|
704
|
+
// caller can retry a 403 itself, but it cannot discover that its lock
|
|
705
|
+
// handle is dead from a 403.
|
|
706
|
+
if (this.isSessionVerdict(retryError)) {
|
|
707
|
+
throw retryError;
|
|
708
|
+
}
|
|
364
709
|
this.logger?.debug(`CSRF retry failed; rethrowing original error: ${retryError instanceof Error
|
|
365
710
|
? retryError.message
|
|
366
711
|
: String(retryError)}`);
|
|
@@ -379,23 +724,26 @@ class AbstractAbapConnection {
|
|
|
379
724
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - 401 on GET request, retrying with cookies from error response`);
|
|
380
725
|
requestHeaders.Cookie = this.cookies;
|
|
381
726
|
const retryResponse = await this.getAxiosInstance()(requestConfig);
|
|
382
|
-
this.
|
|
727
|
+
this.observeResponse(retryResponse.headers, lease.generation);
|
|
383
728
|
return retryResponse;
|
|
384
729
|
}
|
|
385
730
|
// If no cookies, try to get them via CSRF token fetch
|
|
386
731
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - 401 on GET request, attempting to get cookies via CSRF token fetch`);
|
|
387
732
|
try {
|
|
388
733
|
// Try to get CSRF token (this will also get cookies)
|
|
389
|
-
this.csrfToken = await this.fetchCsrfToken(requestUrl, 3, 1000);
|
|
734
|
+
this.csrfToken = await this.fetchCsrfToken(requestUrl, 3, 1000, lease.generation);
|
|
390
735
|
if (this.cookies) {
|
|
391
736
|
requestHeaders.Cookie = this.cookies;
|
|
392
737
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Retrying GET request with cookies from CSRF fetch`);
|
|
393
738
|
const retryResponse = await this.getAxiosInstance()(requestConfig);
|
|
394
|
-
this.
|
|
739
|
+
this.observeResponse(retryResponse.headers, lease.generation);
|
|
395
740
|
return retryResponse;
|
|
396
741
|
}
|
|
397
742
|
}
|
|
398
743
|
catch (csrfError) {
|
|
744
|
+
if (this.isSessionVerdict(csrfError)) {
|
|
745
|
+
throw csrfError;
|
|
746
|
+
}
|
|
399
747
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Failed to get CSRF token for 401 retry: ${csrfError instanceof Error ? csrfError.message : String(csrfError)}`);
|
|
400
748
|
// Fall through to throw original error
|
|
401
749
|
}
|
|
@@ -407,7 +755,9 @@ class AbstractAbapConnection {
|
|
|
407
755
|
* Fetch CSRF token from SAP system
|
|
408
756
|
* Protected method for use by concrete implementations in their connect() method
|
|
409
757
|
*/
|
|
410
|
-
async fetchCsrfToken(url, retryCount = csrfConfig_js_1.CSRF_CONFIG.RETRY_COUNT, retryDelay = csrfConfig_js_1.CSRF_CONFIG.RETRY_DELAY
|
|
758
|
+
async fetchCsrfToken(url, retryCount = csrfConfig_js_1.CSRF_CONFIG.RETRY_COUNT, retryDelay = csrfConfig_js_1.CSRF_CONFIG.RETRY_DELAY,
|
|
759
|
+
/** Fences the response effects; omitted during connect(), which has no lease. */
|
|
760
|
+
generation) {
|
|
411
761
|
// Try primary endpoint first, then fallback for older systems
|
|
412
762
|
const baseUrl = url.includes('/sap/bc/adt/')
|
|
413
763
|
? url.split('/sap/bc/adt')[0]
|
|
@@ -431,9 +781,16 @@ class AbstractAbapConnection {
|
|
|
431
781
|
let lastError;
|
|
432
782
|
for (const csrfUrl of endpoints) {
|
|
433
783
|
try {
|
|
434
|
-
return await this.fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay);
|
|
784
|
+
return await this.fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay, generation);
|
|
435
785
|
}
|
|
436
786
|
catch (error) {
|
|
787
|
+
// Third layer with a catch on this path, and the last one that could
|
|
788
|
+
// bury a verdict: falling through to the fallback endpoint would open
|
|
789
|
+
// ANOTHER session, and by then the teardown has cleared the fingerprint
|
|
790
|
+
// so the new one reads as `established` and the loss disappears.
|
|
791
|
+
if (this.isSessionVerdict(error)) {
|
|
792
|
+
throw error;
|
|
793
|
+
}
|
|
437
794
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
438
795
|
this.logger?.debug(`CSRF token not available from ${csrfUrl}, trying next endpoint...`);
|
|
439
796
|
}
|
|
@@ -444,7 +801,7 @@ class AbstractAbapConnection {
|
|
|
444
801
|
/**
|
|
445
802
|
* Fetch CSRF token from a specific endpoint with retries
|
|
446
803
|
*/
|
|
447
|
-
async fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay) {
|
|
804
|
+
async fetchCsrfTokenFromEndpoint(csrfUrl, retryCount, retryDelay, generation) {
|
|
448
805
|
this.logger?.debug(`Fetching CSRF token from: ${csrfUrl}`);
|
|
449
806
|
for (let attempt = 0; attempt <= retryCount; attempt++) {
|
|
450
807
|
try {
|
|
@@ -482,7 +839,7 @@ class AbstractAbapConnection {
|
|
|
482
839
|
headers,
|
|
483
840
|
timeout: (0, timeouts_js_1.getTimeout)('csrf'),
|
|
484
841
|
});
|
|
485
|
-
this.
|
|
842
|
+
this.observeResponse(response.headers, generation);
|
|
486
843
|
const token = response.headers['x-csrf-token'];
|
|
487
844
|
if (!token) {
|
|
488
845
|
this.logger?.error('No CSRF token in response headers', {
|
|
@@ -496,7 +853,7 @@ class AbstractAbapConnection {
|
|
|
496
853
|
throw new Error(csrfConfig_js_1.CSRF_ERROR_MESSAGES.NOT_IN_HEADERS);
|
|
497
854
|
}
|
|
498
855
|
if (response.headers['set-cookie']) {
|
|
499
|
-
this.
|
|
856
|
+
this.observeResponse(response.headers, generation);
|
|
500
857
|
if (this.cookies) {
|
|
501
858
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Cookies received from CSRF response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
|
|
502
859
|
this.logger?.debug('Cookies extracted from response', {
|
|
@@ -508,11 +865,19 @@ class AbstractAbapConnection {
|
|
|
508
865
|
return token;
|
|
509
866
|
}
|
|
510
867
|
catch (error) {
|
|
868
|
+
// A session verdict is not a failed token fetch and must not be
|
|
869
|
+
// retried into silence: the retry would observe the SAME new session,
|
|
870
|
+
// read it as `unchanged`, and the replacement this raised would be gone
|
|
871
|
+
// for good. It leaves immediately, past the loop and past the caller's
|
|
872
|
+
// recovery.
|
|
873
|
+
if (this.isSessionVerdict(error)) {
|
|
874
|
+
throw error;
|
|
875
|
+
}
|
|
511
876
|
if (error instanceof axios_1.AxiosError) {
|
|
512
877
|
// Always try to extract cookies from error response, even on 401
|
|
513
878
|
// This ensures cookies are available for subsequent requests
|
|
514
879
|
if (error.response?.headers) {
|
|
515
|
-
this.
|
|
880
|
+
this.observeResponse(error.response.headers, generation);
|
|
516
881
|
if (this.cookies) {
|
|
517
882
|
this.logger?.debug('Cookies extracted from error response', {
|
|
518
883
|
status: error.response.status,
|
|
@@ -531,14 +896,14 @@ class AbstractAbapConnection {
|
|
|
531
896
|
this.logger?.debug('CSRF: SAP returned 405 (Method Not Allowed) — not critical, token found in header');
|
|
532
897
|
const token = error.response.headers['x-csrf-token'];
|
|
533
898
|
if (token) {
|
|
534
|
-
this.
|
|
899
|
+
this.observeResponse(error.response.headers, generation);
|
|
535
900
|
return token;
|
|
536
901
|
}
|
|
537
902
|
}
|
|
538
903
|
if (error.response?.headers['x-csrf-token']) {
|
|
539
904
|
this.logger?.debug(`Got CSRF token despite error (status: ${error.response?.status})`);
|
|
540
905
|
const token = error.response.headers['x-csrf-token'];
|
|
541
|
-
this.
|
|
906
|
+
this.observeResponse(error.response.headers, generation);
|
|
542
907
|
return token;
|
|
543
908
|
}
|
|
544
909
|
if (error.response) {
|
|
@@ -597,13 +962,19 @@ class AbstractAbapConnection {
|
|
|
597
962
|
setInitialCookies(cookies) {
|
|
598
963
|
this.cookies = cookies;
|
|
599
964
|
}
|
|
965
|
+
/**
|
|
966
|
+
* Folds a response's cookies into the jar and classifies what that means for
|
|
967
|
+
* the session identity. Returns the classification rather than acting on it:
|
|
968
|
+
* cookie parsing stays free of policy, and no exception fires in the middle
|
|
969
|
+
* of a state update. The caller decides.
|
|
970
|
+
*/
|
|
600
971
|
updateCookiesFromResponse(headers) {
|
|
601
972
|
if (!headers) {
|
|
602
|
-
return;
|
|
973
|
+
return 'unchanged';
|
|
603
974
|
}
|
|
604
975
|
const setCookie = headers['set-cookie'];
|
|
605
976
|
if (!setCookie) {
|
|
606
|
-
return;
|
|
977
|
+
return 'unchanged';
|
|
607
978
|
}
|
|
608
979
|
const cookiesArray = Array.isArray(setCookie) ? setCookie : [setCookie];
|
|
609
980
|
for (const entry of cookiesArray) {
|
|
@@ -633,16 +1004,17 @@ class AbstractAbapConnection {
|
|
|
633
1004
|
this.cookieStore.set('sap-usercontext', `sap-client=${this.config.client}`);
|
|
634
1005
|
}
|
|
635
1006
|
if (this.cookieStore.size === 0) {
|
|
636
|
-
return;
|
|
1007
|
+
return 'unchanged';
|
|
637
1008
|
}
|
|
638
1009
|
const combined = Array.from(this.cookieStore.entries())
|
|
639
1010
|
.map(([name, value]) => (value ? `${name}=${value}` : name))
|
|
640
1011
|
.join('; ');
|
|
641
1012
|
if (!combined) {
|
|
642
|
-
return;
|
|
1013
|
+
return 'unchanged';
|
|
643
1014
|
}
|
|
644
1015
|
this.cookies = combined;
|
|
645
1016
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Updated cookies from response (first 100 chars): ${this.cookies.substring(0, 100)}...`);
|
|
1017
|
+
return this.lifecycle.observe(this.sessionFingerprint());
|
|
646
1018
|
}
|
|
647
1019
|
/**
|
|
648
1020
|
* Subclasses override to inject extra https.Agent options (e.g. mTLS cert/key/pfx).
|
|
@@ -700,6 +1072,13 @@ class AbstractAbapConnection {
|
|
|
700
1072
|
this.setCsrfToken(null);
|
|
701
1073
|
this.cookies = null;
|
|
702
1074
|
this.cookieStore.clear();
|
|
1075
|
+
// And the tracked identity, because WE discarded the session. Without this
|
|
1076
|
+
// the cookie that arrives next reads as a foreign replacement — and since a
|
|
1077
|
+
// replacement is now always fatal, our own deliberate re-authentication
|
|
1078
|
+
// would tear the connection down. The distinction that matters is not
|
|
1079
|
+
// "was a lock held" but "did we cause this": what we discarded on purpose
|
|
1080
|
+
// is not a session taken from under us.
|
|
1081
|
+
this.lifecycle.forgetIdentity();
|
|
703
1082
|
}
|
|
704
1083
|
shouldRetryCsrf(error) {
|
|
705
1084
|
if (!(error instanceof axios_1.AxiosError)) {
|
|
@@ -12,7 +12,11 @@ export declare class BaseAbapConnection extends AbstractAbapConnection {
|
|
|
12
12
|
* Connect to SAP system with Basic Auth
|
|
13
13
|
* Fetches CSRF token which also establishes session cookies
|
|
14
14
|
*/
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Establishes the session for this auth type. Called by
|
|
17
|
+
* AbstractAbapConnection.connect(), which owns the lifecycle around it.
|
|
18
|
+
*/
|
|
19
|
+
protected establishSession(): Promise<void>;
|
|
16
20
|
protected buildAuthorizationHeader(): string;
|
|
17
21
|
private static validateConfig;
|
|
18
22
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"BaseAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/BaseAbapConnection.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,sBAAsB;gBAE1D,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAA;KAAE;IAMzC;;;OAGG;
|
|
1
|
+
{"version":3,"file":"BaseAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/BaseAbapConnection.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,sBAAsB;gBAE1D,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,OAAO,CAAA;KAAE;IAMzC;;;OAGG;IACH;;;OAGG;cACa,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IA6CjD,SAAS,CAAC,wBAAwB,IAAI,MAAM;IAU5C,OAAO,CAAC,MAAM,CAAC,cAAc;CAmB9B"}
|
|
@@ -15,7 +15,11 @@ class BaseAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnect
|
|
|
15
15
|
* Connect to SAP system with Basic Auth
|
|
16
16
|
* Fetches CSRF token which also establishes session cookies
|
|
17
17
|
*/
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Establishes the session for this auth type. Called by
|
|
20
|
+
* AbstractAbapConnection.connect(), which owns the lifecycle around it.
|
|
21
|
+
*/
|
|
22
|
+
async establishSession() {
|
|
19
23
|
const baseUrl = await this.getBaseUrl();
|
|
20
24
|
const discoveryUrl = `${baseUrl}/sap/bc/adt/discovery`;
|
|
21
25
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Connecting to SAP system: ${discoveryUrl}`);
|
|
@@ -41,6 +45,12 @@ class BaseAbapConnection extends AbstractAbapConnection_js_1.AbstractAbapConnect
|
|
|
41
45
|
this.logger?.debug(`[DEBUG] BaseAbapConnection - Cookies extracted from error response during connect (first 100 chars): ${this.getCookies()?.substring(0, 100)}...`);
|
|
42
46
|
}
|
|
43
47
|
}
|
|
48
|
+
// Rethrow: a resolved connect() must mean a usable session exists. This
|
|
49
|
+
// used to swallow and resolve anyway, deferring establishment to the
|
|
50
|
+
// first request — coherent only while that lazy path existed. Without it,
|
|
51
|
+
// swallowing would leave a connection that reports success, holds
|
|
52
|
+
// nothing, and refuses every request.
|
|
53
|
+
throw error;
|
|
44
54
|
}
|
|
45
55
|
}
|
|
46
56
|
buildAuthorizationHeader() {
|
|
@@ -14,7 +14,11 @@ export declare class CertificateAbapConnection extends AbstractAbapConnection {
|
|
|
14
14
|
* Loads certificate material and primes the session. MUST be called before the first
|
|
15
15
|
* request — the TLS agent is built lazily and needs the client cert present.
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Establishes the session for this auth type. Called by
|
|
19
|
+
* AbstractAbapConnection.connect(), which owns the lifecycle around it.
|
|
20
|
+
*/
|
|
21
|
+
protected establishSession(): Promise<void>;
|
|
18
22
|
protected getHttpsAgentOptions(): AgentOptions;
|
|
19
23
|
protected buildAuthorizationHeader(): string;
|
|
20
24
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CertificateAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/CertificateAbapConnection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAEV,0BAA0B,EAC3B,MAAM,0BAA0B,CAAC;AAGlC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE,mFAAmF;AACnF,qBAAa,yBAA0B,SAAQ,sBAAsB;IACnE,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,QAAQ,CAAqC;gBAGnD,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,MAAM,CAAC,EAAE,0BAA0B;IAOrC,OAAO,CAAC,MAAM,CAAC,cAAc;cAyBb,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAM/C;;;OAGG;
|
|
1
|
+
{"version":3,"file":"CertificateAbapConnection.d.ts","sourceRoot":"","sources":["../../src/connection/CertificateAbapConnection.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAEV,0BAA0B,EAC3B,MAAM,0BAA0B,CAAC;AAGlC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE,mFAAmF;AACnF,qBAAa,yBAA0B,SAAQ,sBAAsB;IACnE,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,QAAQ,CAAqC;gBAGnD,MAAM,EAAE,SAAS,EACjB,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,MAAM,CAAC,EAAE,0BAA0B;IAOrC,OAAO,CAAC,MAAM,CAAC,cAAc;cAyBb,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAM/C;;;OAGG;IACH;;;OAGG;cACa,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IA6BjD,SAAS,CAAC,oBAAoB,IAAI,YAAY;IAU9C,SAAS,CAAC,wBAAwB,IAAI,MAAM;CAG7C"}
|