@franzzemen/brokenstock-shell 0.4.2 → 0.4.4

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,214 @@ 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
+ const LOCKS_AVAILABLE = typeof navigator !== 'undefined'
237
+ && typeof navigator.locks?.request === 'function';
238
+ /**
239
+ * Runs `work` if this window can take the lock right now.
240
+ *
241
+ * Returns `{ran: false}` when another window holds it — the caller decides what
242
+ * to do instead, which is the point: only the caller knows whether waiting for
243
+ * the peer's result or proceeding alone is correct.
244
+ *
245
+ * When `navigator.locks` is unavailable the work RUNS, uncoordinated. That is a
246
+ * deliberate degradation to today's behaviour rather than a refusal.
247
+ */
248
+ async function runExclusive(name, work) {
249
+ if (!LOCKS_AVAILABLE) {
250
+ console.warn(`[shell] navigator.locks unavailable; "${name}" runs without cross-window coordination`);
251
+ return { ran: true, value: await work() };
252
+ }
253
+ let outcome = { ran: false };
254
+ await navigator.locks.request(name, { ifAvailable: true }, async (lock) => {
255
+ if (!lock)
256
+ return;
257
+ outcome = { ran: true, value: await work() };
258
+ });
259
+ return outcome;
260
+ }
261
+ /**
262
+ * Polls `read` until it returns something other than `unchanged`, or the bound
263
+ * elapses.
264
+ *
265
+ * Polling rather than listening for `storage` on purpose: the event does not
266
+ * fire in a window that is frozen at the moment it is dispatched, and this is
267
+ * the path that recovers such a window. A poll asks the question again.
268
+ *
269
+ * Resolves `undefined` on timeout — "the peer did not deliver", which is a
270
+ * legitimate answer the caller must handle, not an error.
271
+ */
272
+ async function awaitChange(read, unchanged, timeoutMs, pollMs = 100) {
273
+ const deadline = Date.now() + timeoutMs;
274
+ for (;;) {
275
+ const current = read();
276
+ if (current !== unchanged)
277
+ return current;
278
+ if (Date.now() >= deadline)
279
+ return undefined;
280
+ await new Promise(resolve => setTimeout(resolve, pollMs));
281
+ }
282
+ }
283
+
284
+ /** Name of the cross-window renewal lock (PRD D5). */
285
+ const RENEWAL_LOCK = 'brokenstock.session.renew';
286
+ /** How long a window waits for the renewing window to publish a token (PRD D5). */
287
+ const PEER_RENEWAL_WAIT_MS = 10_000;
288
+ /** How long the renewal request itself may take before it is abandoned (PRD D5). */
289
+ const RENEWAL_TIMEOUT_MS = 15_000;
79
290
  class AuthenticationService {
291
+ #refreshing;
292
+ #auth;
293
+ #httpClient;
294
+ #thinkingService;
295
+ #router;
296
+ #authUrls;
297
+ #store;
298
+ /** Off by default: an app opts in to sharing its session across windows (PRD D7). */
299
+ #sharedSession;
80
300
  constructor() {
81
301
  this.authenticated = signal(false, /* @ts-ignore */
82
302
  ...(ngDevMode ? [{ debugName: "authenticated" }] : /* istanbul ignore next */ []));
@@ -94,16 +314,65 @@ class AuthenticationService {
94
314
  this.#thinkingService = inject(ThinkingService);
95
315
  this.#router = inject(Router);
96
316
  this.#authUrls = inject(SHELL_AUTH_URLS);
317
+ this.#store = inject(SessionTokenStore);
318
+ /** Off by default: an app opts in to sharing its session across windows (PRD D7). */
319
+ this.#sharedSession = inject(SHELL_SHARED_SESSION, { optional: true }) ?? false;
320
+ if (!this.#sharedSession)
321
+ return;
322
+ // Adopt a token this browser already holds. This runs during construction,
323
+ // and `authGuard` injecting this service IS the first read of `authenticated`
324
+ // — a field initializer completes before `inject()` returns — so the guard
325
+ // sees the restored state without becoming asynchronous (PRD D4).
326
+ //
327
+ // The token may well be expired. That is not this constructor's problem: the
328
+ // first request 401s and the interceptor renews and retries, which is the
329
+ // path that already works today.
330
+ const stored = this.#store.read();
331
+ if (stored) {
332
+ this.#auth = stored;
333
+ this.authenticated.set(true);
334
+ }
335
+ // Another window signing out must sign this one out too (PRD D6). A token
336
+ // that merely CHANGED needs no action here — `auth` reads through, so the
337
+ // next caller gets the new value without anyone copying it.
338
+ this.#store.onExternalChange(token => {
339
+ if (!token && this.authenticated())
340
+ this.#clearSession();
341
+ });
97
342
  }
98
- #refreshing;
99
- #auth;
100
- #httpClient;
101
- #thinkingService;
102
- #router;
103
- #authUrls;
343
+ /**
344
+ * The bearer for outbound calls.
345
+ *
346
+ * READ-THROUGH, not a cached copy, whenever the session is shared across
347
+ * windows (PRD D2/D10). A copy is only as fresh as the last `storage` event
348
+ * delivered, and the consumer that matters most is outside the request path:
349
+ * the market-tape socket re-presents this token on a sixty-second heartbeat,
350
+ * and the server closes the socket with a code the client does NOT retry if it
351
+ * is stale. A window that was frozen when the event fired would lose its live
352
+ * prices permanently. Reading through removes the question of which window
353
+ * holds which token.
354
+ */
104
355
  get auth() {
356
+ if (this.#sharedSession) {
357
+ const stored = this.#store.read();
358
+ if (stored)
359
+ return stored;
360
+ }
105
361
  return this.#auth;
106
362
  }
363
+ /**
364
+ * Adopts a freshly issued token: in memory, and in shared storage so every
365
+ * other window of this browser sees it on their next read.
366
+ *
367
+ * A failed write THROWS out of here rather than being absorbed — see
368
+ * `SessionTokenStore`. A session the other windows cannot see is the failure
369
+ * this whole design exists to prevent.
370
+ */
371
+ #adoptToken(token) {
372
+ this.#auth = token;
373
+ if (this.#sharedSession)
374
+ this.#store.write(token);
375
+ }
107
376
  // Legacy hasRole/hasAnyRole removed (legacy-authz-teardown): sessions carry no
108
377
  // roles, and every gate moved to hasFeature (commerce) with the BE enforcing the
109
378
  // security axis.
@@ -129,6 +398,23 @@ class AuthenticationService {
129
398
  this.conflictSessions.set(undefined);
130
399
  this.pendingCredentials.set(undefined);
131
400
  }
401
+ /**
402
+ * The session is gone and cannot be recovered — renewal failed permanently,
403
+ * or another window signed out.
404
+ *
405
+ * ⚠ THIS EXISTS BECAUSE THERE ARE TWO SIGN-OUT PATHS AND ONLY ONE USED TO
406
+ * CLEAR THE TOKEN. `logout()` went through `#clearSession()`; the 401
407
+ * interceptor set the signals and navigated but left `#auth` alone. Harmless
408
+ * while the token lived only in memory and died with the tab — but once it is
409
+ * in storage, a dead token left behind means every later window marks itself
410
+ * signed in, passes the guard, renders, 401s and bounces to the login screen.
411
+ * The reload fix would have become "the app flashes, then throws me out",
412
+ * which is harder to diagnose than the behaviour it replaced.
413
+ */
414
+ sessionLost() {
415
+ this.#thinkingService.reset();
416
+ this.#clearSession();
417
+ }
132
418
  logout() {
133
419
  const subscription = this.#httpClient
134
420
  .get(this.#authUrls.logout, {
@@ -180,7 +466,7 @@ class AuthenticationService {
180
466
  reject('Unable to login');
181
467
  return;
182
468
  }
183
- this.#auth = auth;
469
+ this.#adoptToken(auth);
184
470
  this.authenticated.set(true);
185
471
  this.pendingCredentials.set(undefined);
186
472
  resolve();
@@ -214,15 +500,81 @@ class AuthenticationService {
214
500
  });
215
501
  });
216
502
  }
503
+ /**
504
+ * Renews the session, coordinating with the other windows of this browser.
505
+ *
506
+ * WHY COORDINATION IS REQUIRED, NOT AN OPTIMISATION. Every window shares one
507
+ * session, so they all find the token expired at the same moment and all try
508
+ * to renew. The server serialises rotation on the session row and REFUSES the
509
+ * losers — so uncoordinated, every window but one is signed out, every token
510
+ * lifetime, several times a day.
511
+ *
512
+ * The lock is taken with `ifAvailable`, so a window that does not get it is
513
+ * told so immediately and then waits for the WINNER'S RESULT with a bound of
514
+ * its own, rather than blocking on a peer it cannot supervise. A frozen
515
+ * window holding the lock would otherwise stop every other window forever,
516
+ * with nothing on screen and nothing logged.
517
+ */
217
518
  async #doRefresh() {
519
+ if (!this.#sharedSession)
520
+ return this.#renew();
521
+ // Captured BEFORE asking for the lock: it is what "did someone else renew
522
+ // while I waited?" is measured against.
523
+ const before = this.#store.read();
524
+ const outcome = await runExclusive(RENEWAL_LOCK, () => this.#renewOrAdopt(before));
525
+ if (outcome.ran)
526
+ return outcome.value;
527
+ // Another window is renewing. Wait for its token to appear.
528
+ const published = await awaitChange(() => this.#store.read(), before, PEER_RENEWAL_WAIT_MS);
529
+ if (published) {
530
+ this.#auth = published;
531
+ return true;
532
+ }
533
+ // Either the winner cleared the token (its renewal failed permanently), or
534
+ // it never delivered inside the bound. Re-read to tell those apart: an
535
+ // absent token is a definite answer and must not be retried, whereas a
536
+ // still-unchanged one means the winner is gone and this window should try.
537
+ if (before && !this.#store.read())
538
+ return false;
539
+ return this.#renewOrAdopt(before);
540
+ }
541
+ /**
542
+ * The three-way rule, applied once this window holds the lock (PRD D5).
543
+ *
544
+ * Stated precisely because "retry with a token different from the one you
545
+ * failed with" is not well defined: the in-window single-flight already
546
+ * collapses requests that were sent with different tokens.
547
+ */
548
+ async #renewOrAdopt(before) {
549
+ const current = this.#store.read();
550
+ // Gone: whoever held the lock before us renewed and then failed permanently,
551
+ // or signed out. Retrying would present nothing and 401 again.
552
+ if (before && !current)
553
+ return false;
554
+ // Someone renewed while we queued. Adopt their work; do not rotate again —
555
+ // a second rotation would invalidate the token every other window just took.
556
+ if (current && current !== before) {
557
+ this.#auth = current;
558
+ return true;
559
+ }
560
+ return this.#renew();
561
+ }
562
+ /**
563
+ * The renewal request itself.
564
+ *
565
+ * ⚠ THE TIMEOUT IS LOAD-BEARING. Without it a hung request holds the
566
+ * cross-window lock indefinitely and every other window waits behind it.
567
+ */
568
+ async #renew() {
218
569
  try {
219
570
  const response = await fetch(this.#authUrls.refresh, {
220
571
  method: 'POST',
221
572
  headers: {
222
- [HttpHeader.Authorization]: this.#auth,
573
+ [HttpHeader.Authorization]: this.auth,
223
574
  [HttpHeader.AppContext]: 'brokenstock',
224
575
  },
225
576
  credentials: 'include',
577
+ signal: AbortSignal.timeout(RENEWAL_TIMEOUT_MS),
226
578
  });
227
579
  if (!response.ok)
228
580
  return false;
@@ -230,20 +582,33 @@ class AuthenticationService {
230
582
  if (!auth || !BEARER_TOKEN_REGEX.test(auth))
231
583
  return false;
232
584
  const body = await response.json();
233
- this.#auth = auth;
234
- this.userSession.set(body.session);
585
+ this.#adoptToken(auth);
586
+ // The raw token is stripped exactly as `login()` strips it. `/refresh`
587
+ // returns `{session: {token}}`, and assigning that straight through put
588
+ // the bearer into a PUBLIC signal — a second copy of the credential in a
589
+ // place nothing needs it, which contradicts the care taken everywhere
590
+ // else about where this token may live.
591
+ const session = { ...(body.session ?? {}) };
592
+ delete session.token;
593
+ this.userSession.set(session);
235
594
  return true;
236
595
  }
237
596
  catch {
597
+ // Includes the abort. A timed-out renewal is a failed renewal.
238
598
  return false;
239
599
  }
240
600
  }
601
+ /**
602
+ * THE ONE PLACE a session ends. Both sign-out paths route through here so the
603
+ * stored token cannot outlive the session it belongs to — see `sessionLost`.
604
+ */
241
605
  #clearSession() {
242
606
  this.authenticated.set(false);
243
607
  this.userSession.set(undefined);
244
608
  this.conflictSessions.set(undefined);
245
609
  this.pendingCredentials.set(undefined);
246
610
  this.#auth = 'Bearer ..';
611
+ this.#store.clear();
247
612
  this.#router.navigate(['/login']).catch(console.error);
248
613
  }
249
614
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
@@ -252,7 +617,7 @@ class AuthenticationService {
252
617
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticationService, decorators: [{
253
618
  type: Injectable,
254
619
  args: [{ providedIn: 'root' }]
255
- }] });
620
+ }], ctorParameters: () => [] });
256
621
 
257
622
  const authGuard = (_route, state) => {
258
623
  const authService = inject(AuthenticationService);
@@ -277,9 +642,7 @@ const guestGuard = () => {
277
642
 
278
643
  const authInterceptor = (req, next) => {
279
644
  const authService = inject(AuthenticationService);
280
- const thinkingService = inject(ThinkingService);
281
645
  const authUrls = inject(SHELL_AUTH_URLS);
282
- const router = inject(Router);
283
646
  return next(req).pipe(catchError((error) => {
284
647
  const isRefreshOrLogin = req.url === authUrls.refresh || req.url === authUrls.login;
285
648
  if (error.status === 401 && !isRefreshOrLogin) {
@@ -290,16 +653,13 @@ const authInterceptor = (req, next) => {
290
653
  });
291
654
  return next(retryReq);
292
655
  }
293
- thinkingService.reset();
294
- authService.authenticated.set(false);
295
- authService.userSession.set(undefined);
296
- router.navigate(['/login']);
656
+ // `sessionLost()` and not the three lines that used to be here: it
657
+ // also clears the STORED token. Leaving a dead token behind makes
658
+ // every later window sign itself in, render, 401 and bounce.
659
+ authService.sessionLost();
297
660
  return throwError(() => error);
298
661
  }), catchError(() => {
299
- thinkingService.reset();
300
- authService.authenticated.set(false);
301
- authService.userSession.set(undefined);
302
- router.navigate(['/login']);
662
+ authService.sessionLost();
303
663
  return throwError(() => error);
304
664
  }));
305
665
  }
@@ -1913,11 +2273,30 @@ class KeyboardShortcutService {
1913
2273
  this.#document.addEventListener('keydown', (event) => {
1914
2274
  this.#handleKeydown(event);
1915
2275
  });
1916
- // Focus moves are what change `suppressed`, and they are frequent — kept
1917
- // out of the zone, with the signal written directly. A signal write marks
1918
- // its consumers dirty on its own; it does not need a change-detection pass
1919
- // of its own to be scheduled here.
1920
- const refresh = () => this.suppressed.set(this.#isSuppressed());
2276
+ // ⚠ THE WRITE IS DEFERRED, AND THAT IS NOT AN OPTIMISATION.
2277
+ //
2278
+ // `element.focus()` dispatches `focusin` SYNCHRONOUSLY. Angular calls
2279
+ // `.focus()` from template-driven code — an autofocused input, the shell's
2280
+ // terminal reclaiming focus — so this listener can run in the middle of a
2281
+ // render, and a signal write there throws NG0600, "writing to signals is
2282
+ // not allowed while Angular renders the template".
2283
+ //
2284
+ // That throw shipped: it aborted the login transition, so the sign-in POST
2285
+ // returned 200 and the app still showed "not logged in", with every
2286
+ // authenticated request that followed answering 401. Nothing in the failure
2287
+ // pointed at the keyboard.
2288
+ //
2289
+ // A microtask runs after the render completes, which is soon enough for an
2290
+ // indicator a human reads and late enough to be outside the reactive
2291
+ // context. The value is compared first so an unchanged focus move writes
2292
+ // nothing at all.
2293
+ const refresh = () => {
2294
+ queueMicrotask(() => {
2295
+ const next = this.#isSuppressed();
2296
+ if (next !== this.suppressed())
2297
+ this.suppressed.set(next);
2298
+ });
2299
+ };
1921
2300
  this.#document.addEventListener('focusin', refresh, true);
1922
2301
  this.#document.addEventListener('focusout', refresh, true);
1923
2302
  });
@@ -1925,7 +2304,13 @@ class KeyboardShortcutService {
1925
2304
  registerGroup(groupId, shortcuts) {
1926
2305
  this.#groups.set(groupId, shortcuts);
1927
2306
  this.#activeGroupId.set(groupId);
1928
- this.suppressed.set(this.#isSuppressed());
2307
+ // Same hazard as the focus listener above: a screen registers from
2308
+ // `ngOnInit`, which Angular may run inside a render.
2309
+ queueMicrotask(() => {
2310
+ const next = this.#isSuppressed();
2311
+ if (next !== this.suppressed())
2312
+ this.suppressed.set(next);
2313
+ });
1929
2314
  }
1930
2315
  unregisterGroup(groupId) {
1931
2316
  this.#groups.delete(groupId);
@@ -2780,5 +3165,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
2780
3165
  * Generated bundle index. Do not edit.
2781
3166
  */
2782
3167
 
2783
- 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 };
3168
+ 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, guestGuard, provideShellConfig, tokenize };
2784
3169
  //# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map