@franzzemen/brokenstock-shell 0.4.3 → 0.4.5

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.
@@ -2,7 +2,7 @@ import * as i0 from '@angular/core';
2
2
  import { InjectionToken, makeEnvironmentProviders, signal, computed, Injectable, inject, input, Directive, isDevMode, effect, untracked, HostListener, ViewChild, ViewEncapsulation, ChangeDetectionStrategy, Component, NgZone, viewChild, afterNextRender } from '@angular/core';
3
3
  import * as i2$1 from '@angular/router';
4
4
  import { Router, RouterLink, RouterLinkActive, ActivatedRoute, RouterOutlet } from '@angular/router';
5
- import { HttpHeader, BEARER_TOKEN_REGEX, USERNAME_REGEX, PASSWORD_REGEX } from '@franzzemen/utility';
5
+ import { BEARER_TOKEN_REGEX, HttpHeader, USERNAME_REGEX, PASSWORD_REGEX } from '@franzzemen/utility';
6
6
  import { validateCredentials } from '@franzzemen/identity';
7
7
  import { HttpClient } from '@angular/common/http';
8
8
  import { catchError, from, switchMap, throwError, firstValueFrom } from 'rxjs';
@@ -41,6 +41,18 @@ const SHELL_AUTH_URLS = new InjectionToken('SHELL_AUTH_URLS');
41
41
  */
42
42
  const SHELL_COMMANDS = new InjectionToken('SHELL_COMMANDS');
43
43
  const SHELL_POST_LOGIN_ROUTE = new InjectionToken('SHELL_POST_LOGIN_ROUTE');
44
+ /**
45
+ * Whether this app shares one session across every window of the browser
46
+ * (multiple-windows PRD D2/D7).
47
+ *
48
+ * ⚠ OFF unless an app opts in, and only `broken-stock` does. Turning it on puts
49
+ * the sign-in token in browser storage, where any script on the origin can read
50
+ * it and it persists on disk. The admin apps send their renewal cookie
51
+ * cross-site and can change other people's data, which makes that a materially
52
+ * different exposure — extending this to them is a separate decision, not a
53
+ * default.
54
+ */
55
+ const SHELL_SHARED_SESSION = new InjectionToken('SHELL_SHARED_SESSION');
44
56
 
45
57
  function provideShellConfig(config) {
46
58
  return makeEnvironmentProviders([
@@ -48,6 +60,7 @@ function provideShellConfig(config) {
48
60
  { provide: SHELL_MENU_ITEMS, useValue: config.menuItems },
49
61
  { provide: SHELL_AUTH_URLS, useValue: config.authUrls },
50
62
  { provide: SHELL_COMMANDS, useValue: config.commands ?? [] },
63
+ { provide: SHELL_SHARED_SESSION, useValue: config.sharedSession ?? false },
51
64
  ]);
52
65
  }
53
66
 
@@ -76,7 +89,257 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
76
89
  args: [{ providedIn: 'root' }]
77
90
  }] });
78
91
 
92
+ /*
93
+ Created by Franz Zemen 2026-08-29
94
+ License Type: UNLICENSED
95
+
96
+ The sign-in token's home in browser storage (multiple-windows PRD D2).
97
+
98
+ WHY THIS IS ITS OWN FILE, AND WHY IT DOES NOT USE `LocalStorageService`.
99
+ That service swallows a failed write on purpose — losing a remembered pane width
100
+ must never break the screen that was trying to remember it. That policy is
101
+ exactly wrong here. A silently dropped token write is a silently lost session:
102
+ the window keeps working on the token it holds in memory, every other window
103
+ never learns it, and the failure surfaces minutes later somewhere else entirely.
104
+ So this writes directly and THROWS.
105
+
106
+ READS ARE THE SOURCE OF TRUTH, NOT A CACHE. `AuthenticationService.auth` reads
107
+ through to this on every access rather than keeping a copy. That is the whole
108
+ reason the design works with several windows open: the tape socket asks for the
109
+ token on a sixty-second heartbeat and a window that is frozen or backgrounded may
110
+ never receive a queued `storage` event, so a copy refreshed by an event is only
111
+ as fresh as the last event delivered. Reading through removes the question.
112
+
113
+ ⚠ NOT under the `bs.pref.` namespace. `LocalStorageService` wipes that namespace
114
+ wholesale, and a preference wipe must never sign the user out.
115
+ */
116
+ /** Deliberately outside `bs.pref.` — see the header. */
117
+ const SESSION_TOKEN_KEY = 'bs.auth.token';
118
+ class SessionTokenStore {
119
+ /** False during prerendering, where there is no `window` at all. */
120
+ #available = typeof window !== 'undefined' && !!window.localStorage;
121
+ get available() {
122
+ return this.#available;
123
+ }
124
+ /**
125
+ * The stored token, or undefined.
126
+ *
127
+ * A stored value that is not a well-formed bearer is treated as absent and
128
+ * removed. It cannot be authenticated with, and leaving it there would make
129
+ * every later read fail the same way with nothing to explain it.
130
+ */
131
+ read() {
132
+ if (!this.#available)
133
+ return undefined;
134
+ let raw;
135
+ try {
136
+ raw = window.localStorage.getItem(SESSION_TOKEN_KEY);
137
+ }
138
+ catch {
139
+ // Storage disabled by the browser. Not recoverable here, and not fatal —
140
+ // the caller falls back to its in-memory token and this window works
141
+ // alone, which is exactly today's behaviour.
142
+ return undefined;
143
+ }
144
+ if (!raw)
145
+ return undefined;
146
+ if (!BEARER_TOKEN_REGEX.test(raw)) {
147
+ this.clear();
148
+ return undefined;
149
+ }
150
+ return raw;
151
+ }
152
+ /**
153
+ * Stores the token. THROWS on failure — see the header.
154
+ *
155
+ * @throws if the value is not a bearer token, or the write does not stick.
156
+ */
157
+ write(token) {
158
+ if (!this.#available)
159
+ return;
160
+ if (!BEARER_TOKEN_REGEX.test(token)) {
161
+ throw new Error('SessionTokenStore.write refused a value that is not a bearer token');
162
+ }
163
+ try {
164
+ window.localStorage.setItem(SESSION_TOKEN_KEY, token);
165
+ }
166
+ catch (error) {
167
+ throw new Error(`Could not store the session token: ${String(error)}`);
168
+ }
169
+ }
170
+ clear() {
171
+ if (!this.#available)
172
+ return;
173
+ try {
174
+ window.localStorage.removeItem(SESSION_TOKEN_KEY);
175
+ }
176
+ catch {
177
+ // Nothing useful to do. The in-memory state is cleared by the caller
178
+ // regardless, so this window is signed out either way.
179
+ }
180
+ }
181
+ /**
182
+ * Calls back when ANOTHER window changes the token.
183
+ *
184
+ * `storage` does not fire in the window that made the change, which is
185
+ * correct — that window is the one doing it and has already acted.
186
+ *
187
+ * ⚠ `event.key === null` is what `localStorage.clear()` produces, and it means
188
+ * everything went, this key included. Treating it as "not our key" is how a
189
+ * whole-storage clear silently fails to sign the user out.
190
+ */
191
+ onExternalChange(handler) {
192
+ if (!this.#available)
193
+ return () => undefined;
194
+ const listener = (event) => {
195
+ if (event.key !== null && event.key !== SESSION_TOKEN_KEY)
196
+ return;
197
+ handler(this.read());
198
+ };
199
+ window.addEventListener('storage', listener);
200
+ return () => window.removeEventListener('storage', listener);
201
+ }
202
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: SessionTokenStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
203
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: SessionTokenStore, providedIn: 'root' }); }
204
+ }
205
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: SessionTokenStore, decorators: [{
206
+ type: Injectable,
207
+ args: [{ providedIn: 'root' }]
208
+ }] });
209
+
210
+ /*
211
+ Created by Franz Zemen 2026-08-29
212
+ License Type: UNLICENSED
213
+
214
+ Run something in exactly one window (multiple-windows PRD D5).
215
+
216
+ THE PROBLEM. Several windows share one session. When the token expires they all
217
+ notice at once and all try to renew. The server serialises renewal correctly and
218
+ REFUSES the losers, so without coordination every window but one is signed out —
219
+ and it happens every token lifetime, several times a day.
220
+
221
+ WHY `ifAvailable` RATHER THAN WAITING ON THE LOCK. A waiter blocks on a peer it
222
+ cannot supervise. If the window holding the lock is frozen, backgrounded or its
223
+ request hangs, the waiters wait forever — screens simply stop updating, with
224
+ nothing logged and nothing on screen. `ifAvailable` refuses immediately instead,
225
+ which turns "wait on a peer" into "wait for a value to appear, with my own
226
+ bound". A bound you own is a bound you can honour.
227
+
228
+ ⚠ A lock held by a DESTROYED document is released by the browser, so closing a
229
+ window is safe. A merely frozen document is NOT destroyed — which is why every
230
+ path here is bounded regardless.
231
+
232
+ ⚠ `navigator.locks` needs a secure context and is absent in some. Absent means
233
+ fall back to running uncoordinated — today's behaviour — never throw. A missing
234
+ API must not be the reason nobody can sign in.
235
+ */
236
+ /**
237
+ * Checked at CALL time, not once at module load.
238
+ *
239
+ * Module-level would be evaluated during import — before `navigator` exists at
240
+ * all under prerendering, and before a test can arrange anything — permanently
241
+ * fixing the answer to "no" for the life of the process. Found by writing the
242
+ * test for the fallback path, which could not be reached any other way.
243
+ */
244
+ function locksAvailable() {
245
+ return typeof navigator !== 'undefined' && typeof navigator.locks?.request === 'function';
246
+ }
247
+ /**
248
+ * Runs `work` if this window can take the lock right now.
249
+ *
250
+ * Returns `{ran: false}` when another window holds it — the caller decides what
251
+ * to do instead, which is the point: only the caller knows whether waiting for
252
+ * the peer's result or proceeding alone is correct.
253
+ *
254
+ * When `navigator.locks` is unavailable the work RUNS, uncoordinated. That is a
255
+ * deliberate degradation to today's behaviour rather than a refusal.
256
+ */
257
+ async function runExclusive(name, work) {
258
+ if (!locksAvailable()) {
259
+ console.warn(`[shell] navigator.locks unavailable; "${name}" runs without cross-window coordination`);
260
+ return { ran: true, value: await work() };
261
+ }
262
+ let outcome = { ran: false };
263
+ await navigator.locks.request(name, { ifAvailable: true }, async (lock) => {
264
+ if (!lock)
265
+ return;
266
+ outcome = { ran: true, value: await work() };
267
+ });
268
+ return outcome;
269
+ }
270
+ /**
271
+ * Polls `read` until it returns something other than `unchanged`, or the bound
272
+ * elapses.
273
+ *
274
+ * Polling rather than listening for `storage` on purpose: the event does not
275
+ * fire in a window that is frozen at the moment it is dispatched, and this is
276
+ * the path that recovers such a window. A poll asks the question again.
277
+ *
278
+ * Resolves `undefined` on timeout — "the peer did not deliver", which is a
279
+ * legitimate answer the caller must handle, not an error.
280
+ */
281
+ async function awaitChange(read, unchanged, timeoutMs, pollMs = 100) {
282
+ const deadline = Date.now() + timeoutMs;
283
+ for (;;) {
284
+ const current = read();
285
+ if (current !== unchanged)
286
+ return current;
287
+ if (Date.now() >= deadline)
288
+ return undefined;
289
+ await new Promise(resolve => setTimeout(resolve, pollMs));
290
+ }
291
+ }
292
+
293
+ /*
294
+ Created by Franz Zemen 2026-08-29
295
+ License Type: UNLICENSED
296
+
297
+ What a window should do once it can act on a session renewal
298
+ (multiple-windows PRD D5).
299
+
300
+ WHY THIS IS A NAMED FUNCTION RATHER THAN THREE `if`s INSIDE THE SERVICE. The
301
+ rule was originally written as "retry with a token different from the one you
302
+ failed with", which is not a well-defined instruction: the in-window
303
+ single-flight already collapses requests that were sent with DIFFERENT tokens,
304
+ so "the one you failed with" is not a single value. Pulling the decision out
305
+ forces the three cases to be named, and lets them be tested without mocking a
306
+ network call — which is what they deserve, because getting this wrong shows up
307
+ hours later as "a window signed me out" with nothing linking cause to effect.
308
+ */
309
+ /**
310
+ * @param before the token this window held immediately BEFORE asking for the lock
311
+ * @param current the token in shared storage now that this window can act
312
+ */
313
+ function decideRenewal(before, current) {
314
+ // Gone. Retrying would present nothing and 401 again; worse, it would look
315
+ // like a transient failure when it is a definite answer.
316
+ if (before && !current)
317
+ return 'sign-out';
318
+ // Someone else's fresh token. Rotating again would invalidate the token every
319
+ // OTHER window just adopted — turning one window's recovery into everyone
320
+ // else's sign-out.
321
+ if (current && current !== before)
322
+ return 'adopt';
323
+ // Unchanged, or nothing stored to begin with.
324
+ return 'renew';
325
+ }
326
+
327
+ /** Name of the cross-window renewal lock (PRD D5). */
328
+ const RENEWAL_LOCK = 'brokenstock.session.renew';
329
+ /** How long a window waits for the renewing window to publish a token (PRD D5). */
330
+ const PEER_RENEWAL_WAIT_MS = 10_000;
331
+ /** How long the renewal request itself may take before it is abandoned (PRD D5). */
332
+ const RENEWAL_TIMEOUT_MS = 15_000;
79
333
  class AuthenticationService {
334
+ #refreshing;
335
+ #auth;
336
+ #httpClient;
337
+ #thinkingService;
338
+ #router;
339
+ #authUrls;
340
+ #store;
341
+ /** Off by default: an app opts in to sharing its session across windows (PRD D7). */
342
+ #sharedSession;
80
343
  constructor() {
81
344
  this.authenticated = signal(false, /* @ts-ignore */
82
345
  ...(ngDevMode ? [{ debugName: "authenticated" }] : /* istanbul ignore next */ []));
@@ -94,16 +357,65 @@ class AuthenticationService {
94
357
  this.#thinkingService = inject(ThinkingService);
95
358
  this.#router = inject(Router);
96
359
  this.#authUrls = inject(SHELL_AUTH_URLS);
360
+ this.#store = inject(SessionTokenStore);
361
+ /** Off by default: an app opts in to sharing its session across windows (PRD D7). */
362
+ this.#sharedSession = inject(SHELL_SHARED_SESSION, { optional: true }) ?? false;
363
+ if (!this.#sharedSession)
364
+ return;
365
+ // Adopt a token this browser already holds. This runs during construction,
366
+ // and `authGuard` injecting this service IS the first read of `authenticated`
367
+ // — a field initializer completes before `inject()` returns — so the guard
368
+ // sees the restored state without becoming asynchronous (PRD D4).
369
+ //
370
+ // The token may well be expired. That is not this constructor's problem: the
371
+ // first request 401s and the interceptor renews and retries, which is the
372
+ // path that already works today.
373
+ const stored = this.#store.read();
374
+ if (stored) {
375
+ this.#auth = stored;
376
+ this.authenticated.set(true);
377
+ }
378
+ // Another window signing out must sign this one out too (PRD D6). A token
379
+ // that merely CHANGED needs no action here — `auth` reads through, so the
380
+ // next caller gets the new value without anyone copying it.
381
+ this.#store.onExternalChange(token => {
382
+ if (!token && this.authenticated())
383
+ this.#clearSession();
384
+ });
97
385
  }
98
- #refreshing;
99
- #auth;
100
- #httpClient;
101
- #thinkingService;
102
- #router;
103
- #authUrls;
386
+ /**
387
+ * The bearer for outbound calls.
388
+ *
389
+ * READ-THROUGH, not a cached copy, whenever the session is shared across
390
+ * windows (PRD D2/D10). A copy is only as fresh as the last `storage` event
391
+ * delivered, and the consumer that matters most is outside the request path:
392
+ * the market-tape socket re-presents this token on a sixty-second heartbeat,
393
+ * and the server closes the socket with a code the client does NOT retry if it
394
+ * is stale. A window that was frozen when the event fired would lose its live
395
+ * prices permanently. Reading through removes the question of which window
396
+ * holds which token.
397
+ */
104
398
  get auth() {
399
+ if (this.#sharedSession) {
400
+ const stored = this.#store.read();
401
+ if (stored)
402
+ return stored;
403
+ }
105
404
  return this.#auth;
106
405
  }
406
+ /**
407
+ * Adopts a freshly issued token: in memory, and in shared storage so every
408
+ * other window of this browser sees it on their next read.
409
+ *
410
+ * A failed write THROWS out of here rather than being absorbed — see
411
+ * `SessionTokenStore`. A session the other windows cannot see is the failure
412
+ * this whole design exists to prevent.
413
+ */
414
+ #adoptToken(token) {
415
+ this.#auth = token;
416
+ if (this.#sharedSession)
417
+ this.#store.write(token);
418
+ }
107
419
  // Legacy hasRole/hasAnyRole removed (legacy-authz-teardown): sessions carry no
108
420
  // roles, and every gate moved to hasFeature (commerce) with the BE enforcing the
109
421
  // security axis.
@@ -129,6 +441,23 @@ class AuthenticationService {
129
441
  this.conflictSessions.set(undefined);
130
442
  this.pendingCredentials.set(undefined);
131
443
  }
444
+ /**
445
+ * The session is gone and cannot be recovered — renewal failed permanently,
446
+ * or another window signed out.
447
+ *
448
+ * ⚠ THIS EXISTS BECAUSE THERE ARE TWO SIGN-OUT PATHS AND ONLY ONE USED TO
449
+ * CLEAR THE TOKEN. `logout()` went through `#clearSession()`; the 401
450
+ * interceptor set the signals and navigated but left `#auth` alone. Harmless
451
+ * while the token lived only in memory and died with the tab — but once it is
452
+ * in storage, a dead token left behind means every later window marks itself
453
+ * signed in, passes the guard, renders, 401s and bounces to the login screen.
454
+ * The reload fix would have become "the app flashes, then throws me out",
455
+ * which is harder to diagnose than the behaviour it replaced.
456
+ */
457
+ sessionLost() {
458
+ this.#thinkingService.reset();
459
+ this.#clearSession();
460
+ }
132
461
  logout() {
133
462
  const subscription = this.#httpClient
134
463
  .get(this.#authUrls.logout, {
@@ -180,7 +509,7 @@ class AuthenticationService {
180
509
  reject('Unable to login');
181
510
  return;
182
511
  }
183
- this.#auth = auth;
512
+ this.#adoptToken(auth);
184
513
  this.authenticated.set(true);
185
514
  this.pendingCredentials.set(undefined);
186
515
  resolve();
@@ -214,15 +543,79 @@ class AuthenticationService {
214
543
  });
215
544
  });
216
545
  }
546
+ /**
547
+ * Renews the session, coordinating with the other windows of this browser.
548
+ *
549
+ * WHY COORDINATION IS REQUIRED, NOT AN OPTIMISATION. Every window shares one
550
+ * session, so they all find the token expired at the same moment and all try
551
+ * to renew. The server serialises rotation on the session row and REFUSES the
552
+ * losers — so uncoordinated, every window but one is signed out, every token
553
+ * lifetime, several times a day.
554
+ *
555
+ * The lock is taken with `ifAvailable`, so a window that does not get it is
556
+ * told so immediately and then waits for the WINNER'S RESULT with a bound of
557
+ * its own, rather than blocking on a peer it cannot supervise. A frozen
558
+ * window holding the lock would otherwise stop every other window forever,
559
+ * with nothing on screen and nothing logged.
560
+ */
217
561
  async #doRefresh() {
562
+ if (!this.#sharedSession)
563
+ return this.#renew();
564
+ // Captured BEFORE asking for the lock: it is what "did someone else renew
565
+ // while I waited?" is measured against.
566
+ const before = this.#store.read();
567
+ const outcome = await runExclusive(RENEWAL_LOCK, () => this.#renewOrAdopt(before));
568
+ if (outcome.ran)
569
+ return outcome.value;
570
+ // Another window is renewing. Wait for its token to appear.
571
+ const published = await awaitChange(() => this.#store.read(), before, PEER_RENEWAL_WAIT_MS);
572
+ if (published) {
573
+ this.#auth = published;
574
+ return true;
575
+ }
576
+ // Either the winner cleared the token (its renewal failed permanently), or
577
+ // it never delivered inside the bound. Re-read to tell those apart: an
578
+ // absent token is a definite answer and must not be retried, whereas a
579
+ // still-unchanged one means the winner is gone and this window should try.
580
+ if (before && !this.#store.read())
581
+ return false;
582
+ return this.#renewOrAdopt(before);
583
+ }
584
+ /**
585
+ * The three-way rule, applied once this window holds the lock (PRD D5).
586
+ *
587
+ * Stated precisely because "retry with a token different from the one you
588
+ * failed with" is not well defined: the in-window single-flight already
589
+ * collapses requests that were sent with different tokens.
590
+ */
591
+ async #renewOrAdopt(before) {
592
+ const current = this.#store.read();
593
+ switch (decideRenewal(before, current)) {
594
+ case 'sign-out':
595
+ return false;
596
+ case 'adopt':
597
+ this.#auth = current;
598
+ return true;
599
+ case 'renew':
600
+ return this.#renew();
601
+ }
602
+ }
603
+ /**
604
+ * The renewal request itself.
605
+ *
606
+ * ⚠ THE TIMEOUT IS LOAD-BEARING. Without it a hung request holds the
607
+ * cross-window lock indefinitely and every other window waits behind it.
608
+ */
609
+ async #renew() {
218
610
  try {
219
611
  const response = await fetch(this.#authUrls.refresh, {
220
612
  method: 'POST',
221
613
  headers: {
222
- [HttpHeader.Authorization]: this.#auth,
614
+ [HttpHeader.Authorization]: this.auth,
223
615
  [HttpHeader.AppContext]: 'brokenstock',
224
616
  },
225
617
  credentials: 'include',
618
+ signal: AbortSignal.timeout(RENEWAL_TIMEOUT_MS),
226
619
  });
227
620
  if (!response.ok)
228
621
  return false;
@@ -230,20 +623,33 @@ class AuthenticationService {
230
623
  if (!auth || !BEARER_TOKEN_REGEX.test(auth))
231
624
  return false;
232
625
  const body = await response.json();
233
- this.#auth = auth;
234
- this.userSession.set(body.session);
626
+ this.#adoptToken(auth);
627
+ // The raw token is stripped exactly as `login()` strips it. `/refresh`
628
+ // returns `{session: {token}}`, and assigning that straight through put
629
+ // the bearer into a PUBLIC signal — a second copy of the credential in a
630
+ // place nothing needs it, which contradicts the care taken everywhere
631
+ // else about where this token may live.
632
+ const session = { ...(body.session ?? {}) };
633
+ delete session.token;
634
+ this.userSession.set(session);
235
635
  return true;
236
636
  }
237
637
  catch {
638
+ // Includes the abort. A timed-out renewal is a failed renewal.
238
639
  return false;
239
640
  }
240
641
  }
642
+ /**
643
+ * THE ONE PLACE a session ends. Both sign-out paths route through here so the
644
+ * stored token cannot outlive the session it belongs to — see `sessionLost`.
645
+ */
241
646
  #clearSession() {
242
647
  this.authenticated.set(false);
243
648
  this.userSession.set(undefined);
244
649
  this.conflictSessions.set(undefined);
245
650
  this.pendingCredentials.set(undefined);
246
651
  this.#auth = 'Bearer ..';
652
+ this.#store.clear();
247
653
  this.#router.navigate(['/login']).catch(console.error);
248
654
  }
249
655
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
@@ -252,7 +658,7 @@ class AuthenticationService {
252
658
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticationService, decorators: [{
253
659
  type: Injectable,
254
660
  args: [{ providedIn: 'root' }]
255
- }] });
661
+ }], ctorParameters: () => [] });
256
662
 
257
663
  const authGuard = (_route, state) => {
258
664
  const authService = inject(AuthenticationService);
@@ -277,9 +683,7 @@ const guestGuard = () => {
277
683
 
278
684
  const authInterceptor = (req, next) => {
279
685
  const authService = inject(AuthenticationService);
280
- const thinkingService = inject(ThinkingService);
281
686
  const authUrls = inject(SHELL_AUTH_URLS);
282
- const router = inject(Router);
283
687
  return next(req).pipe(catchError((error) => {
284
688
  const isRefreshOrLogin = req.url === authUrls.refresh || req.url === authUrls.login;
285
689
  if (error.status === 401 && !isRefreshOrLogin) {
@@ -290,16 +694,13 @@ const authInterceptor = (req, next) => {
290
694
  });
291
695
  return next(retryReq);
292
696
  }
293
- thinkingService.reset();
294
- authService.authenticated.set(false);
295
- authService.userSession.set(undefined);
296
- router.navigate(['/login']);
697
+ // `sessionLost()` and not the three lines that used to be here: it
698
+ // also clears the STORED token. Leaving a dead token behind makes
699
+ // every later window sign itself in, render, 401 and bounce.
700
+ authService.sessionLost();
297
701
  return throwError(() => error);
298
702
  }), catchError(() => {
299
- thinkingService.reset();
300
- authService.authenticated.set(false);
301
- authService.userSession.set(undefined);
302
- router.navigate(['/login']);
703
+ authService.sessionLost();
303
704
  return throwError(() => error);
304
705
  }));
305
706
  }
@@ -2805,5 +3206,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
2805
3206
  * Generated bundle index. Do not edit.
2806
3207
  */
2807
3208
 
2808
- export { AuthenticationService, CLEAR_USAGE, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RegexValidatorDirective, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_POST_LOGIN_ROUTE, Shell, SideMenu, StatusBar, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, audienceAllowed, authGuard, authInterceptor, buildFallbackChain, guestGuard, provideShellConfig, tokenize };
3209
+ export { AuthenticationService, CLEAR_USAGE, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RegexValidatorDirective, SESSION_TOKEN_KEY, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_POST_LOGIN_ROUTE, SHELL_SHARED_SESSION, SessionTokenStore, Shell, SideMenu, StatusBar, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, audienceAllowed, authGuard, authInterceptor, buildFallbackChain, decideRenewal, guestGuard, provideShellConfig, tokenize };
2809
3210
  //# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map