@franzzemen/brokenstock-shell 0.4.4 → 0.4.6

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.
@@ -233,8 +233,17 @@ path here is bounded regardless.
233
233
  fall back to running uncoordinated — today's behaviour — never throw. A missing
234
234
  API must not be the reason nobody can sign in.
235
235
  */
236
- const LOCKS_AVAILABLE = typeof navigator !== 'undefined'
237
- && typeof navigator.locks?.request === 'function';
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
+ }
238
247
  /**
239
248
  * Runs `work` if this window can take the lock right now.
240
249
  *
@@ -246,7 +255,7 @@ const LOCKS_AVAILABLE = typeof navigator !== 'undefined'
246
255
  * deliberate degradation to today's behaviour rather than a refusal.
247
256
  */
248
257
  async function runExclusive(name, work) {
249
- if (!LOCKS_AVAILABLE) {
258
+ if (!locksAvailable()) {
250
259
  console.warn(`[shell] navigator.locks unavailable; "${name}" runs without cross-window coordination`);
251
260
  return { ran: true, value: await work() };
252
261
  }
@@ -281,6 +290,40 @@ async function awaitChange(read, unchanged, timeoutMs, pollMs = 100) {
281
290
  }
282
291
  }
283
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
+
284
327
  /** Name of the cross-window renewal lock (PRD D5). */
285
328
  const RENEWAL_LOCK = 'brokenstock.session.renew';
286
329
  /** How long a window waits for the renewing window to publish a token (PRD D5). */
@@ -547,17 +590,15 @@ class AuthenticationService {
547
590
  */
548
591
  async #renewOrAdopt(before) {
549
592
  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;
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();
559
601
  }
560
- return this.#renew();
561
602
  }
562
603
  /**
563
604
  * The renewal request itself.
@@ -2736,12 +2777,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
2736
2777
  args: [{ providedIn: 'root' }]
2737
2778
  }], ctorParameters: () => [] });
2738
2779
 
2780
+ /** Roomy enough for the scanner beside a chart, small enough for a laptop. */
2781
+ const POP_OUT_WIDTH = 1280;
2782
+ const POP_OUT_HEIGHT = 900;
2739
2783
  class StatusBar {
2740
2784
  constructor() {
2741
2785
  this.#authService = inject(AuthenticationService);
2742
2786
  this.#focusService = inject(TerminalFocusService);
2743
2787
  this.#commandService = inject(CommandService);
2788
+ this.#sharedSession = inject(SHELL_SHARED_SESSION, { optional: true }) ?? false;
2744
2789
  this.isAuthenticated = this.#authService.authenticated;
2790
+ /**
2791
+ * Whether a second window is offered at all (multiple-windows PRD D14).
2792
+ *
2793
+ * ⚠ GATED ON SHARED SESSIONS, and this is a correctness gate rather than a
2794
+ * tidiness one. Without a session shared across windows, the new window starts
2795
+ * with no token, goes to the login screen, and signing in there ends the
2796
+ * session THIS window is using. A pop-out button in an app without sharing is
2797
+ * a button that signs you out — the exact failure the multiple-windows work
2798
+ * exists to remove.
2799
+ */
2800
+ this.canPopOut = computed(() => this.#sharedSession && this.isAuthenticated(), /* @ts-ignore */
2801
+ ...(ngDevMode ? [{ debugName: "canPopOut" }] : /* istanbul ignore next */ []));
2745
2802
  this.loginStatus = computed(() => this.#authService.authenticating()
2746
2803
  ? 'Authenticating...'
2747
2804
  : this.#authService.authenticated()
@@ -2752,21 +2809,41 @@ class StatusBar {
2752
2809
  #authService;
2753
2810
  #focusService;
2754
2811
  #commandService;
2812
+ #sharedSession;
2755
2813
  logout() {
2756
2814
  this.#authService.logout();
2757
2815
  this.#commandService.echoCommand('logout');
2758
2816
  this.#focusService.requestFocus();
2759
2817
  }
2818
+ /**
2819
+ * Open the screen you are on in a second, sized window.
2820
+ *
2821
+ * There is no mechanism by which this differs from the browser's own
2822
+ * "Duplicate tab": every window is a separate copy of the app started at some
2823
+ * address. It exists for the sizing and because it is discoverable — Ctrl+T,
2824
+ * a bookmark and dragging a tab out all work equally well once the session is
2825
+ * shared.
2826
+ *
2827
+ * `noopener` is deliberate. The new window needs nothing from this one — it
2828
+ * reads the session from storage — and leaving the opener reachable would give
2829
+ * two windows script access to each other for no purpose.
2830
+ */
2831
+ popOut() {
2832
+ if (typeof window === 'undefined')
2833
+ return;
2834
+ window.open(window.location.href, '_blank', `noopener,noreferrer,width=${POP_OUT_WIDTH},height=${POP_OUT_HEIGHT}`);
2835
+ this.#focusService.requestFocus();
2836
+ }
2760
2837
  openFaq() {
2761
2838
  this.#commandService.execute('faq');
2762
2839
  this.#focusService.requestFocus();
2763
2840
  }
2764
2841
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: StatusBar, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
2765
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: StatusBar, isStandalone: true, selector: "shell-status-bar", ngImport: i0, template: "<div class=\"status-bar\">\n <div class=\"status-item\">\n <span class=\"status-label text-secondary-info\">Status:</span>\n @if (isAuthenticated()) {\n <a class=\"status-value text-interactive logout-link\" (click)=\"logout()\">Log Out</a>\n } @else {\n <span class=\"status-value text-loss\">{{ loginStatus() }}</span>\n }\n </div>\n @if (isAuthenticated()) {\n <ng-content />\n }\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"openFaq()\"\n title=\"Open FAQ (or type `faq`)\"\n aria-label=\"Open FAQ\"\n >?</button>\n</div>\n", styles: [".status-bar{display:flex;align-items:center;gap:24px;padding:0 16px;height:100%;background-color:var(--color-surface);border-bottom:1px solid var(--color-surface-border);font-size:.8rem}.status-item{display:flex;align-items:baseline;gap:6px}.status-label{font-weight:500;text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.logout-link{cursor:pointer;text-decoration:underline}.logout-link:hover{opacity:.8}.faq-button{margin-left:auto;background:none;border:1px solid var(--color-surface-border);border-radius:50%;width:22px;height:22px;font-size:.85rem;font-weight:600;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}.faq-button:hover{opacity:.8}\n"], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
2842
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: StatusBar, isStandalone: true, selector: "shell-status-bar", ngImport: i0, template: "<div class=\"status-bar\">\n <div class=\"status-item\">\n <span class=\"status-label text-secondary-info\">Status:</span>\n @if (isAuthenticated()) {\n <a class=\"status-value text-interactive logout-link\" (click)=\"logout()\">Log Out</a>\n } @else {\n <span class=\"status-value text-loss\">{{ loginStatus() }}</span>\n }\n </div>\n @if (isAuthenticated()) {\n <ng-content />\n }\n @if (canPopOut()) {\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"popOut()\"\n title=\"Open this screen in a second window\"\n aria-label=\"Open this screen in a second window\"\n >\u29C9</button>\n }\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"openFaq()\"\n title=\"Open FAQ (or type `faq`)\"\n aria-label=\"Open FAQ\"\n >?</button>\n</div>\n", styles: [".status-bar{display:flex;align-items:center;gap:24px;padding:0 16px;height:100%;background-color:var(--color-surface);border-bottom:1px solid var(--color-surface-border);font-size:.8rem}.status-item{display:flex;align-items:baseline;gap:6px}.status-label{font-weight:500;text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.logout-link{cursor:pointer;text-decoration:underline}.logout-link:hover{opacity:.8}.faq-button{margin-left:auto;background:none;border:1px solid var(--color-surface-border);border-radius:50%;width:22px;height:22px;font-size:.85rem;font-weight:600;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}.faq-button:hover{opacity:.8}\n"], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
2766
2843
  }
2767
2844
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: StatusBar, decorators: [{
2768
2845
  type: Component,
2769
- args: [{ selector: 'shell-status-bar', changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"status-bar\">\n <div class=\"status-item\">\n <span class=\"status-label text-secondary-info\">Status:</span>\n @if (isAuthenticated()) {\n <a class=\"status-value text-interactive logout-link\" (click)=\"logout()\">Log Out</a>\n } @else {\n <span class=\"status-value text-loss\">{{ loginStatus() }}</span>\n }\n </div>\n @if (isAuthenticated()) {\n <ng-content />\n }\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"openFaq()\"\n title=\"Open FAQ (or type `faq`)\"\n aria-label=\"Open FAQ\"\n >?</button>\n</div>\n", styles: [".status-bar{display:flex;align-items:center;gap:24px;padding:0 16px;height:100%;background-color:var(--color-surface);border-bottom:1px solid var(--color-surface-border);font-size:.8rem}.status-item{display:flex;align-items:baseline;gap:6px}.status-label{font-weight:500;text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.logout-link{cursor:pointer;text-decoration:underline}.logout-link:hover{opacity:.8}.faq-button{margin-left:auto;background:none;border:1px solid var(--color-surface-border);border-radius:50%;width:22px;height:22px;font-size:.85rem;font-weight:600;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}.faq-button:hover{opacity:.8}\n"] }]
2846
+ args: [{ selector: 'shell-status-bar', changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"status-bar\">\n <div class=\"status-item\">\n <span class=\"status-label text-secondary-info\">Status:</span>\n @if (isAuthenticated()) {\n <a class=\"status-value text-interactive logout-link\" (click)=\"logout()\">Log Out</a>\n } @else {\n <span class=\"status-value text-loss\">{{ loginStatus() }}</span>\n }\n </div>\n @if (isAuthenticated()) {\n <ng-content />\n }\n @if (canPopOut()) {\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"popOut()\"\n title=\"Open this screen in a second window\"\n aria-label=\"Open this screen in a second window\"\n >\u29C9</button>\n }\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"openFaq()\"\n title=\"Open FAQ (or type `faq`)\"\n aria-label=\"Open FAQ\"\n >?</button>\n</div>\n", styles: [".status-bar{display:flex;align-items:center;gap:24px;padding:0 16px;height:100%;background-color:var(--color-surface);border-bottom:1px solid var(--color-surface-border);font-size:.8rem}.status-item{display:flex;align-items:baseline;gap:6px}.status-label{font-weight:500;text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.logout-link{cursor:pointer;text-decoration:underline}.logout-link:hover{opacity:.8}.faq-button{margin-left:auto;background:none;border:1px solid var(--color-surface-border);border-radius:50%;width:22px;height:22px;font-size:.85rem;font-weight:600;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}.faq-button:hover{opacity:.8}\n"] }]
2770
2847
  }] });
2771
2848
 
2772
2849
  class SideMenu {
@@ -3165,5 +3242,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
3165
3242
  * Generated bundle index. Do not edit.
3166
3243
  */
3167
3244
 
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 };
3245
+ 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 };
3169
3246
  //# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map