@arsedizioni/ars-utils 22.5.14 → 22.5.16

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.
@@ -2185,6 +2185,19 @@ function isAuthEndpoint(url) {
2185
2185
  function isSessionExpired(errorStatus, url) {
2186
2186
  return errorStatus === 401 && !isAuthEndpoint(url);
2187
2187
  }
2188
+ /**
2189
+ * Returns whether the URL is the silent link probe, whose failures must never reach the user.
2190
+ *
2191
+ * `/session/status` runs unconditionally at every bootstrap, including for users who never linked
2192
+ * Clipper at all. When the API is unreachable, reporting that failure would show "Clipper non
2193
+ * disponibile" to somebody who never asked Clipper for anything: the probe simply stays unanswered
2194
+ * and the link state keeps whatever the stored hint says.
2195
+ * @param url - The request URL.
2196
+ * @returns True when the URL is the status probe.
2197
+ */
2198
+ function isStatusProbe(url) {
2199
+ return url.includes('/session/status');
2200
+ }
2188
2201
  /**
2189
2202
  * Resolves a user-facing error message based on the HTTP status code.
2190
2203
  *
@@ -2331,7 +2344,11 @@ function clipperAuthInterceptor(serviceUri, flags = ClipperServiceFlags.None) {
2331
2344
  // at all. Everything that gets here is a Clipper request anyway: the others returned
2332
2345
  // before the pipe was built.
2333
2346
  const errorStatus = parseInt(error?.status ?? '0');
2334
- if (isServiceUnavailable(error)) {
2347
+ if (isStatusProbe(authenticatedRequest.url)) {
2348
+ // The silent probe stays silent: its caller reads the outcome from the stream, nobody
2349
+ // else needs to hear about it.
2350
+ }
2351
+ else if (isServiceUnavailable(error)) {
2335
2352
  // Three attempts over ten seconds and the API is still not answering. There is nothing
2336
2353
  // left to retry, so the host application is told once, however many requests were in
2337
2354
  // flight.
@@ -2383,16 +2400,26 @@ class ClipperLoginService {
2383
2400
  /** Whether {@link initialize} has already run. */
2384
2401
  this.initialized = false;
2385
2402
  }
2403
+ /**
2404
+ * Gets the signed-in account.
2405
+ * @returns The current {@link ClipperUserInfo}, or `undefined` when there is no session.
2406
+ */
2407
+ get user() { return this.core.user; }
2386
2408
  ////
2387
2409
  // BOOTSTRAP
2388
2410
  ////
2389
2411
  /**
2390
- * Validates a context that survived a page refresh before trusting it.
2412
+ * Establishes the session state at bootstrap by asking the API — the only party that can read the
2413
+ * HttpOnly cookie — whether this browser still holds one.
2391
2414
  *
2392
2415
  * Called by `ClipperService.initialize`, right after `ClipperCoreService.initialize`, so a
2393
- * consumer gets the restore for free and never has to know this method exists. The auth cookie is
2394
- * HttpOnly and may have expired while the stored context lived on, so a stored context only ever
2395
- * means "there WAS a session" — this is what turns it into "there is one".
2416
+ * consumer gets the restore for free and never has to know this method exists.
2417
+ *
2418
+ * The probe now runs UNCONDITIONALLY, where it used to run only when a stored context existed.
2419
+ * The stored context is just a hint — "this browser signed in at some point" — and the cookie can
2420
+ * outlive it: local storage cleared while the month-long cookie stayed put made this library
2421
+ * declare "no link" with a perfectly valid session in the browser. The API is the only source of
2422
+ * truth on the matter, so it is asked every time.
2396
2423
  *
2397
2424
  * A host application that drives its own return from an external provider, or that starts on its
2398
2425
  * own sign-in page, should call `core.setLoggedIn(false)` before bootstrapping rather than let
@@ -2403,27 +2430,54 @@ class ClipperLoginService {
2403
2430
  if (this.initialized)
2404
2431
  return;
2405
2432
  this.initialized = true;
2406
- if (this.core.loggedIn()) {
2407
- this.core.loggingIn.set(true);
2408
- this.restoreSession();
2409
- }
2433
+ this.core.loggingIn.set(true);
2434
+ this.restoreSession();
2410
2435
  }
2411
2436
  /**
2412
- * Asks `/session/me` whether the restored session is still alive. On success the login is
2413
- * finalised from the current claims; on a dead session the interceptor's 401 handling takes over.
2437
+ * Asks `/session/status` whether this browser holds a live session, and hydrates the context
2438
+ * through {@link me} when it does.
2439
+ *
2440
+ * The probe is silent by construction: `/session/status` never answers 401, so a visitor who
2441
+ * never signed in — or a cookie whose session has died — comes back as `connected: false` with no
2442
+ * error broadcast and no dialog. Only then is the stale stored context cleared, so the `loggedIn`
2443
+ * signal tells the truth at every bootstrap.
2414
2444
  *
2415
- * Nothing is cleared on failure, and that is deliberate: a failure here is not proof of a dead
2416
- * session — the API may be unreachable, the proxy may have answered 502, the device may be
2417
- * offline. Clearing on any of those would sign the user out with a perfectly valid cookie.
2445
+ * Nothing is cleared on a transport failure, and that is deliberate: an unreachable API is not
2446
+ * proof of a dead session — the proxy may have answered 502, the device may be offline — and
2447
+ * clearing on any of those would sign the user out with a perfectly valid cookie.
2418
2448
  * @returns void
2419
2449
  */
2420
2450
  restoreSession() {
2421
- this.me()
2422
- .pipe(finalize(() => this.core.loggingIn.set(false)))
2423
- .subscribe(r => {
2424
- if (r?.success) {
2425
- this.broadcastService.sendMessage(ClipperMessages.LOGIN_COMPLETED);
2426
- }
2451
+ // Whether the probe handed over to `/session/me`, which then owns lowering `loggingIn`.
2452
+ let restoring = false;
2453
+ this.status()
2454
+ .pipe(finalize(() => { if (!restoring) {
2455
+ this.core.loggingIn.set(false);
2456
+ } }))
2457
+ .subscribe({
2458
+ next: r => {
2459
+ if (r?.success && r.value?.connected) {
2460
+ restoring = true;
2461
+ this.me()
2462
+ .pipe(finalize(() => this.core.loggingIn.set(false)))
2463
+ .subscribe({
2464
+ next: m => {
2465
+ if (m?.success) {
2466
+ this.broadcastService.sendMessage(ClipperMessages.LOGIN_COMPLETED);
2467
+ }
2468
+ },
2469
+ // Consumed so the rethrown error is not reported a second time as an unhandled one.
2470
+ error: () => { }
2471
+ });
2472
+ }
2473
+ else if (this.core.loggedIn()) {
2474
+ // The stored context outlived its session: drop it, or the next bootstrap would keep
2475
+ // claiming a session that is gone.
2476
+ this.core.clear(true);
2477
+ }
2478
+ },
2479
+ // Transport failure: keep the stored hint, the next bootstrap will ask again.
2480
+ error: () => { }
2427
2481
  });
2428
2482
  }
2429
2483
  ////
@@ -2487,6 +2541,18 @@ class ClipperLoginService {
2487
2541
  .post(this.core.serviceUri + '/session/logout', {})
2488
2542
  .pipe(finalize(() => this.core.clear(true)), catchError(() => of({ success: false })));
2489
2543
  }
2544
+ /**
2545
+ * Asks the API whether this browser holds a live session, without ever failing with a 401: the
2546
+ * route is anonymous and answers `connected: false` to a caller with no session. This is the one
2547
+ * honest way to know the link state — the auth cookie is HttpOnly and belongs to the API's host,
2548
+ * so it cannot be read from here, and its presence would prove nothing anyway, since the session
2549
+ * also lives server-side and may have been revoked or evicted.
2550
+ * @returns An observable emitting the API result wrapping the session status.
2551
+ */
2552
+ status() {
2553
+ return this.httpClient
2554
+ .get(this.core.serviceUri + '/session/status');
2555
+ }
2490
2556
  /**
2491
2557
  * Reads the current session from the server, which resolves it from the auth cookie claims
2492
2558
  * without touching the database. Used to restore the state after a page refresh instead of
@@ -2645,6 +2711,11 @@ class ClipperCoreService {
2645
2711
  }
2646
2712
  return this._loginInfo;
2647
2713
  }
2714
+ /**
2715
+ * Gets the signed-in account.
2716
+ * @returns The current {@link ClipperUserInfo}, or `undefined` when there is no session.
2717
+ */
2718
+ get user() { return this.loginInfo?.context; }
2648
2719
  ////
2649
2720
  // SHARED MUTATORS
2650
2721
  ////
@@ -2682,7 +2753,7 @@ class ClipperCoreService {
2682
2753
  // Through the getter, never the field: the context is loaded from storage lazily, so a caller
2683
2754
  // that stores before anyone has read would otherwise find the field empty and DELETE a
2684
2755
  // perfectly good stored context.
2685
- if (!this.loginInfo?.context) {
2756
+ if (!this.user) {
2686
2757
  // Nothing to store. Falling through would call `JSON.stringify(undefined)`, whose result is
2687
2758
  // the value `undefined`, which local storage writes as the nine-letter STRING "undefined" --
2688
2759
  // a key that exists, parses to nothing, and makes the next bootstrap believe a session was
@@ -2720,7 +2791,7 @@ class ClipperCoreService {
2720
2791
  if (this.loginInfo) {
2721
2792
  const channels = [];
2722
2793
  this.loginInfo.channels?.forEach(n => {
2723
- const channelSubscription = this.loginInfo?.context?.channels?.find(x => x.channel === n.channelId);
2794
+ const channelSubscription = this.user?.channels?.find(x => x.channel === n.channelId);
2724
2795
  n.isSuspended = channelSubscription?.isSuspended === true;
2725
2796
  const channel = ClipperChannels.find(x => x.value === n.channelId);
2726
2797
  if (channel) {
@@ -3260,6 +3331,8 @@ class ClipperService {
3260
3331
  get flags() { return this.core.flags; }
3261
3332
  /** @returns The current login context, or `undefined` if not authenticated. */
3262
3333
  get loginInfo() { return this.core.loginInfo; }
3334
+ /** @returns The signed-in account, or `undefined` when there is no session. */
3335
+ get user() { return this.core.user; }
3263
3336
  /** @returns A read-only signal indicating whether the user is logged in. */
3264
3337
  get loggedIn() { return this.core.loggedIn; }
3265
3338
  /** @returns A signal indicating whether a login is in progress. */
@@ -3297,10 +3370,12 @@ class ClipperService {
3297
3370
  */
3298
3371
  initialize(serviceUri, appUri, flags = ClipperServiceFlags.None) {
3299
3372
  this.core.initialize(serviceUri, appUri, flags);
3300
- // Second, and in this order: the core sets the base URI that `/session/me` is built from, and
3301
- // it no longer declares a session from what local storage holds. Validating that context is
3302
- // what `ClipperLoginService.initialize` does, and doing it from here means a consumer keeps
3303
- // calling one method to bootstrap, exactly as before.
3373
+ // Second, and in this order: the core sets the base URI that `/session/status` and
3374
+ // `/session/me` are built from, and it no longer declares a session from what local storage
3375
+ // holds. Asking the API whether a session actually exists is what
3376
+ // `ClipperLoginService.initialize` does — unconditionally, since the HttpOnly cookie can
3377
+ // outlive any local hint — and doing it from here means a consumer keeps calling one method to
3378
+ // bootstrap, exactly as before.
3304
3379
  this.session.initialize();
3305
3380
  }
3306
3381
  /**