gloomberb 0.5.0 → 0.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Bloomberg-style stock portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { colors, floatingPaneBg } from "../../theme/colors";
3
3
  import { PaneHeader } from "./pane-header";
4
- import { getPaneBodyHeight } from "./pane-sizing";
4
+ import { getPaneBodyHeight, getPaneBodyHorizontalInset } from "./pane-sizing";
5
5
 
6
6
  interface FloatingPaneWrapperProps {
7
7
  title: string;
@@ -22,6 +22,7 @@ export function FloatingPaneWrapper({
22
22
  }: FloatingPaneWrapperProps) {
23
23
  const bg = floatingPaneBg(focused);
24
24
  const bodyHeight = getPaneBodyHeight(height);
25
+ const bodyInset = getPaneBodyHorizontalInset(focused);
25
26
 
26
27
  return (
27
28
  <box
@@ -38,7 +39,7 @@ export function FloatingPaneWrapper({
38
39
  <PaneHeader title={title} width={width} focused={focused} floating showActions={showActions} />
39
40
 
40
41
  {/* Content */}
41
- <box height={bodyHeight} overflow="hidden">
42
+ <box height={bodyHeight} overflow="hidden" paddingLeft={bodyInset} paddingRight={bodyInset}>
42
43
  {children}
43
44
  </box>
44
45
 
@@ -1,6 +1,16 @@
1
1
  const RESERVED_PANE_CHROME_ROWS = 2;
2
+ const FOCUSED_PANE_BODY_INSET = 1;
3
+ const RESERVED_FOCUSED_PANE_CHROME_COLUMNS = FOCUSED_PANE_BODY_INSET * 2;
2
4
 
3
5
  export function getPaneBodyHeight(height: number): number {
4
6
  // Reserve the header row plus the bottom border/resize row.
5
7
  return Math.max(1, height - RESERVED_PANE_CHROME_ROWS);
6
8
  }
9
+
10
+ export function getPaneBodyHorizontalInset(focused: boolean): number {
11
+ return focused ? FOCUSED_PANE_BODY_INSET : 0;
12
+ }
13
+
14
+ export function getPaneBodyWidth(width: number, focused: boolean): number {
15
+ return Math.max(1, width - (focused ? RESERVED_FOCUSED_PANE_CHROME_COLUMNS : 0));
16
+ }
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { paneBg } from "../../theme/colors";
3
3
  import { PaneHeader } from "./pane-header";
4
- import { getPaneBodyHeight } from "./pane-sizing";
4
+ import { getPaneBodyHeight, getPaneBodyHorizontalInset } from "./pane-sizing";
5
5
 
6
6
  interface PaneWrapperProps {
7
7
  title?: string;
@@ -30,6 +30,7 @@ export function PaneWrapper({
30
30
  const bodyHeight = typeof height === "number"
31
31
  ? title ? getPaneBodyHeight(height) : height
32
32
  : undefined;
33
+ const bodyInset = getPaneBodyHorizontalInset(focused);
33
34
 
34
35
  return (
35
36
  <box
@@ -45,7 +46,13 @@ export function PaneWrapper({
45
46
  {title && (
46
47
  <PaneHeader title={title} width={width} focused={focused} showActions={showActions} />
47
48
  )}
48
- <box height={bodyHeight} flexGrow={bodyHeight == null ? 1 : 0} overflow="hidden">
49
+ <box
50
+ height={bodyHeight}
51
+ flexGrow={bodyHeight == null ? 1 : 0}
52
+ overflow="hidden"
53
+ paddingLeft={bodyInset}
54
+ paddingRight={bodyInset}
55
+ >
49
56
  {children}
50
57
  </box>
51
58
  </box>
@@ -35,6 +35,7 @@ afterEach(() => {
35
35
  });
36
36
 
37
37
  function createShellPluginRegistry(options?: {
38
+ portfolioListComponent?: (props: PaneProps) => ReactNode;
38
39
  tickerDetailComponent?: (props: PaneProps) => ReactNode;
39
40
  }): PluginRegistry {
40
41
  return {
@@ -42,7 +43,7 @@ function createShellPluginRegistry(options?: {
42
43
  ["portfolio-list", {
43
44
  id: "portfolio-list",
44
45
  name: "Portfolio List",
45
- component: () => <text>Portfolio Body</text>,
46
+ component: options?.portfolioListComponent ?? (() => <text>Portfolio Body</text>),
46
47
  defaultPosition: "left",
47
48
  }],
48
49
  ["ticker-detail", {
@@ -281,6 +282,20 @@ function BrokerShellHarness({ pluginRegistry }: { pluginRegistry: PluginRegistry
281
282
  );
282
283
  }
283
284
 
285
+ function FocusBorderProbe({ width, height }: Pick<PaneProps, "width" | "height">) {
286
+ return (
287
+ <box flexDirection="column" width={width} height={height}>
288
+ <box height={1} width={width}>
289
+ <text>Read Probe</text>
290
+ </box>
291
+ <box height={1} width={width} flexDirection="row">
292
+ <box flexGrow={1} />
293
+ <text>Right</text>
294
+ </box>
295
+ </box>
296
+ );
297
+ }
298
+
284
299
  function ChartShellHarness({
285
300
  pluginRegistry,
286
301
  config,
@@ -651,9 +666,11 @@ describe("Shell", () => {
651
666
  harnessState?.config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main")?.settings?.chartResolution,
652
667
  ).toBe("1wk");
653
668
  const resolutionSpans = testSetup.captureSpans().lines[resolutionRow]?.spans ?? [];
654
- expect(
655
- resolutionSpans.some((span) => span.text === targetResolution && (span.attributes & TextAttributes.BOLD) !== 0),
656
- ).toBe(true);
669
+ const boldResolutionText = resolutionSpans
670
+ .filter((span) => (span.attributes & TextAttributes.BOLD) !== 0)
671
+ .map((span) => span.text)
672
+ .join("");
673
+ expect(boldResolutionText).toContain(targetResolution);
657
674
  });
658
675
 
659
676
  test("keeps the cash drawer and gridlock tip on distinct click rows in the full app layout", async () => {
@@ -700,6 +717,84 @@ describe("Shell", () => {
700
717
  expect(toasts).toEqual(["Retiled all panes"]);
701
718
  });
702
719
 
720
+ test("keeps focused docked pane body text inside the focus border", async () => {
721
+ const config = createDefaultConfig("/tmp/gloomberb-shell-focus-border-test");
722
+ const mainPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
723
+ if (!mainPane) throw new Error("missing default portfolio pane");
724
+
725
+ const singlePaneLayout = {
726
+ dockRoot: { kind: "pane" as const, instanceId: "portfolio-list:main" },
727
+ instances: [{ ...mainPane }],
728
+ floating: [],
729
+ };
730
+ const state = {
731
+ ...createInitialState({
732
+ ...config,
733
+ layout: cloneLayout(singlePaneLayout),
734
+ layouts: [{ name: "Default", layout: cloneLayout(singlePaneLayout) }],
735
+ }),
736
+ focusedPaneId: "portfolio-list:main",
737
+ };
738
+
739
+ testSetup = await testRender(
740
+ <AppContext value={{ state, dispatch: () => {} }}>
741
+ <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
742
+ <Shell pluginRegistry={createShellPluginRegistry({
743
+ portfolioListComponent: FocusBorderProbe,
744
+ })} />
745
+ </DialogProvider>
746
+ </AppContext>,
747
+ { width: 40, height: 10 },
748
+ );
749
+
750
+ await testSetup.renderOnce();
751
+
752
+ const frame = testSetup.captureCharFrame();
753
+ expect(frame).toContain("│Read Probe");
754
+ expect(frame).toContain("Right│");
755
+ expect(frame).not.toContain("│ead Probe");
756
+ expect(frame).not.toContain("Righ│");
757
+ });
758
+
759
+ test("keeps focused floating pane body text inside the focus border", async () => {
760
+ const config = createDefaultConfig("/tmp/gloomberb-shell-floating-focus-border-test");
761
+ const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
762
+ if (!detailPane) throw new Error("missing detail pane");
763
+
764
+ const floatingOnlyLayout = {
765
+ dockRoot: null,
766
+ instances: [{ ...detailPane }],
767
+ floating: [{ instanceId: "ticker-detail:main", x: 4, y: 2, width: 30, height: 8, zIndex: 75 }],
768
+ };
769
+ const state = {
770
+ ...createInitialState({
771
+ ...config,
772
+ layout: cloneLayout(floatingOnlyLayout),
773
+ layouts: [{ name: "Default", layout: cloneLayout(floatingOnlyLayout) }],
774
+ }),
775
+ focusedPaneId: "ticker-detail:main",
776
+ };
777
+
778
+ testSetup = await testRender(
779
+ <AppContext value={{ state, dispatch: () => {} }}>
780
+ <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
781
+ <Shell pluginRegistry={createShellPluginRegistry({
782
+ tickerDetailComponent: FocusBorderProbe,
783
+ })} />
784
+ </DialogProvider>
785
+ </AppContext>,
786
+ { width: 40, height: 12 },
787
+ );
788
+
789
+ await testSetup.renderOnce();
790
+
791
+ const frame = testSetup.captureCharFrame();
792
+ expect(frame).toContain("│Read Probe");
793
+ expect(frame).toContain("Right│");
794
+ expect(frame).not.toContain("│ead Probe");
795
+ expect(frame).not.toContain("Righ│");
796
+ });
797
+
703
798
  test("shows the gridlock tip after snapping a pane to a half screen", () => {
704
799
  const config = createDefaultConfig("/tmp/gloomberb-shell-snap-test");
705
800
  const snapLayout = cloneLayout(config.layout);
@@ -39,7 +39,7 @@ import { PANE_HEADER_ACTION, PANE_HEADER_CLOSE } from "./pane-header";
39
39
  import { getNativeSurfaceManager, type NativeOccluder, type NativePaneLayer } from "../chart/native/surface-manager";
40
40
  import { FloatingPaneWrapper } from "./floating-pane";
41
41
  import { PaneWrapper } from "./pane";
42
- import { getPaneBodyHeight } from "./pane-sizing";
42
+ import { getPaneBodyHeight, getPaneBodyWidth } from "./pane-sizing";
43
43
 
44
44
  interface ShellProps {
45
45
  pluginRegistry: PluginRegistry;
@@ -987,6 +987,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
987
987
  const focused = state.focusedPaneId === leaf.instanceId && (!overlayOpen || menuState?.paneId === leaf.instanceId);
988
988
  const showActions = focused || hoveredPaneId === leaf.instanceId || menuState?.paneId === leaf.instanceId;
989
989
  const bodyHeight = getPaneBodyHeight(leaf.rect.height);
990
+ const bodyWidth = getPaneBodyWidth(leaf.rect.width, focused);
990
991
  return (
991
992
  <box
992
993
  key={`dock:${leaf.instanceId}`}
@@ -1009,7 +1010,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1009
1010
  paneId={pane.instance.instanceId}
1010
1011
  paneType={pane.instance.paneId}
1011
1012
  focused={focused}
1012
- width={leaf.rect.width}
1013
+ width={bodyWidth}
1013
1014
  height={bodyHeight}
1014
1015
  />
1015
1016
  </PaneInstanceProvider>
@@ -1023,6 +1024,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1023
1024
  const focused = state.focusedPaneId === pane.instance.instanceId && (!overlayOpen || menuState?.paneId === pane.instance.instanceId);
1024
1025
  const showActions = focused || hoveredPaneId === pane.instance.instanceId || menuState?.paneId === pane.instance.instanceId;
1025
1026
  const bodyHeight = getPaneBodyHeight(preview.height);
1027
+ const bodyWidth = getPaneBodyWidth(preview.width, focused);
1026
1028
  return (
1027
1029
  <FloatingPaneWrapper
1028
1030
  key={`float:${pane.instance.instanceId}`}
@@ -1041,7 +1043,7 @@ export function Shell({ pluginRegistry }: ShellProps) {
1041
1043
  paneId={pane.instance.instanceId}
1042
1044
  paneType={pane.instance.paneId}
1043
1045
  focused={focused}
1044
- width={preview.width}
1046
+ width={bodyWidth}
1045
1047
  height={bodyHeight}
1046
1048
  close={() => handleFloatingClose(pane.instance.instanceId)}
1047
1049
  />
@@ -155,6 +155,27 @@ describe("ChatController", () => {
155
155
  });
156
156
  });
157
157
 
158
+ test("does not restore persisted websocket tokens", () => {
159
+ const persistence = new MemoryPersistence();
160
+ const controller = new ChatController();
161
+
162
+ persistence.setState("session", {
163
+ sessionToken: "token-123",
164
+ websocketToken: "stale-ws-token",
165
+ user: { id: "u1", username: "vince", emailVerified: true },
166
+ }, { schemaVersion: 1 });
167
+
168
+ controller.attachPersistence(persistence);
169
+
170
+ expect(apiClient.getSessionToken()).toBe("token-123");
171
+ expect(apiClient.getWebSocketToken()).toBeNull();
172
+ expect(apiClient.getCurrentUser()).toMatchObject({
173
+ id: "u1",
174
+ username: "vince",
175
+ emailVerified: true,
176
+ });
177
+ });
178
+
158
179
  test("reset clears persisted chat state and session token", () => {
159
180
  const persistence = new MemoryPersistence();
160
181
  const controller = new ChatController();
@@ -129,7 +129,9 @@ export class ChatController {
129
129
  schemaVersion: SESSION_SCHEMA_VERSION,
130
130
  });
131
131
  apiClient.setSessionToken(session?.sessionToken ?? null);
132
- apiClient.setWebSocketToken(session?.websocketToken ?? null);
132
+ // WebSocket tokens are short-lived connection credentials. Reusing a persisted
133
+ // one can trap reconnects on an expired token even while the session cookie is valid.
134
+ apiClient.setWebSocketToken(null);
133
135
  apiClient.restoreCachedUser(session?.user ?? null);
134
136
  this.user = session?.user
135
137
  ? {
@@ -483,7 +485,6 @@ export class ChatController {
483
485
  private persistSession(): void {
484
486
  const value = {
485
487
  sessionToken: apiClient.getSessionToken(),
486
- websocketToken: apiClient.getWebSocketToken(),
487
488
  user: this.user,
488
489
  } satisfies PersistedSessionState;
489
490
  this.resume?.setState(SESSION_STATE_KEY, value, {
@@ -962,10 +962,18 @@ export const gloomberbCloudPlugin: GloomPlugin = {
962
962
  ctx.notify({ body: "Not logged in.", type: "error" });
963
963
  return;
964
964
  }
965
- await apiClient.signOut();
965
+ let signOutError: unknown = null;
966
+ try {
967
+ await apiClient.signOut();
968
+ } catch (error) {
969
+ signOutError = error;
970
+ }
966
971
  await chatController.refreshSession();
967
972
  await chatController.refreshMessages();
968
- ctx.notify({ body: "Logged out.", type: "info" });
973
+ ctx.notify({
974
+ body: signOutError ? "Logged out locally. Cloud sign-out did not complete." : "Logged out.",
975
+ type: "info",
976
+ });
969
977
  },
970
978
  hidden: () => !apiClient.getSessionToken(),
971
979
  });
@@ -3,6 +3,7 @@ import type { AuthUser } from "./api-client";
3
3
  import { apiClient } from "./api-client";
4
4
 
5
5
  const originalFetch = globalThis.fetch;
6
+ const originalWebSocket = globalThis.WebSocket;
6
7
 
7
8
  const verifiedUser: AuthUser = {
8
9
  id: "user-1",
@@ -33,7 +34,9 @@ function createResponse(body: unknown, options: { status?: number; cookies?: str
33
34
  }
34
35
 
35
36
  afterEach(() => {
37
+ apiClient.dispose();
36
38
  globalThis.fetch = originalFetch;
39
+ globalThis.WebSocket = originalWebSocket;
37
40
  apiClient.setSessionToken(null);
38
41
  apiClient.setWebSocketToken(null);
39
42
  });
@@ -83,6 +86,93 @@ describe("apiClient auth cookies", () => {
83
86
  "__Secure-gloomberb.session_token=persisted-token.value; gloomberb.session_token=persisted-token.value",
84
87
  ]);
85
88
  });
89
+
90
+ test("keeps cached identity when session refresh is rejected without a hard account-missing response", async () => {
91
+ apiClient.setSessionToken("persisted-token.value");
92
+ apiClient.restoreCachedUser(verifiedUser);
93
+
94
+ globalThis.fetch = (async () => createResponse({ message: "Unauthorized" }, { status: 401 })) as typeof fetch;
95
+
96
+ await expect(apiClient.getSession()).rejects.toThrow("Unauthorized");
97
+ expect(apiClient.getSessionToken()).toBe("persisted-token.value");
98
+ expect(apiClient.getCurrentUser()).toMatchObject({
99
+ id: verifiedUser.id,
100
+ username: verifiedUser.username,
101
+ emailVerified: true,
102
+ });
103
+ });
104
+
105
+ test("clears cached identity when session refresh says the account no longer exists", async () => {
106
+ apiClient.setSessionToken("persisted-token.value");
107
+ apiClient.setWebSocketToken("ws-token");
108
+ apiClient.restoreCachedUser(verifiedUser);
109
+
110
+ globalThis.fetch = (async () => createResponse({ code: "USER_NOT_FOUND" }, { status: 403 })) as typeof fetch;
111
+
112
+ await expect(apiClient.getSession()).resolves.toBeNull();
113
+ expect(apiClient.getSessionToken()).toBeNull();
114
+ expect(apiClient.getWebSocketToken()).toBeNull();
115
+ expect(apiClient.getCurrentUser()).toBeNull();
116
+ });
117
+
118
+ test("clears local session on explicit sign out even if the server request fails", async () => {
119
+ apiClient.setSessionToken("persisted-token.value");
120
+ apiClient.setWebSocketToken("ws-token");
121
+ apiClient.restoreCachedUser(verifiedUser);
122
+
123
+ globalThis.fetch = (async () => createResponse({ message: "server unavailable" }, { status: 503 })) as typeof fetch;
124
+
125
+ await expect(apiClient.signOut()).rejects.toThrow("server unavailable");
126
+ expect(apiClient.getSessionToken()).toBeNull();
127
+ expect(apiClient.getWebSocketToken()).toBeNull();
128
+ expect(apiClient.getCurrentUser()).toBeNull();
129
+ });
130
+
131
+ test("drops a stale websocket token after socket close so reconnect can use the session token", () => {
132
+ const sockets: Array<{
133
+ url: string;
134
+ readyState: number;
135
+ onclose: ((event: unknown) => void) | null;
136
+ close: () => void;
137
+ }> = [];
138
+
139
+ class FakeWebSocket {
140
+ static OPEN = 1;
141
+ readyState = 0;
142
+ onopen: ((event: unknown) => void) | null = null;
143
+ onmessage: ((event: { data: string }) => void) | null = null;
144
+ onclose: ((event: unknown) => void) | null = null;
145
+ onerror: ((event: unknown) => void) | null = null;
146
+
147
+ constructor(readonly url: string) {
148
+ sockets.push(this);
149
+ }
150
+
151
+ send(): void {}
152
+
153
+ close(): void {
154
+ this.readyState = 3;
155
+ this.onclose?.({ code: 1000, reason: "closed" });
156
+ }
157
+ }
158
+
159
+ globalThis.WebSocket = FakeWebSocket as any;
160
+ apiClient.setSessionToken("session-token");
161
+ apiClient.setWebSocketToken("stale-ws-token");
162
+ apiClient.restoreCachedUser(verifiedUser);
163
+
164
+ const unsubscribe = apiClient.subscribeQuotes([{ symbol: "AAPL" }], () => {});
165
+
166
+ expect(sockets).toHaveLength(1);
167
+ expect(sockets[0]!.url).toContain("token=stale-ws-token");
168
+
169
+ sockets[0]!.onclose?.({ code: 1008, reason: "Unauthorized" });
170
+
171
+ expect(apiClient.getWebSocketToken()).toBeNull();
172
+ expect(apiClient.getSessionToken()).toBe("session-token");
173
+
174
+ unsubscribe();
175
+ });
86
176
  });
87
177
 
88
178
  describe("apiClient chat timestamps", () => {
@@ -6,6 +6,11 @@ import { normalizeTimestamp } from "./timestamp";
6
6
  const DEFAULT_API_URL = "https://api.gloom.sh";
7
7
  const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
8
8
  const cloudApiLog = debugLog.createLogger("cloud-api");
9
+ const HARD_SESSION_INVALID_PATTERNS = [
10
+ /\b(user|account)\b.*\b(not found|deleted|removed|disabled|deactivated|suspended)\b/i,
11
+ /\b(user|account)\b.*\bdoes(?:\s+not|n't)\s+exist\b/i,
12
+ /\b(no|unknown|missing)\s+(user|account)\b/i,
13
+ ];
9
14
 
10
15
  export interface ChatMessage {
11
16
  id: string;
@@ -131,6 +136,22 @@ class ApiRequestError extends Error {
131
136
  }
132
137
  }
133
138
 
139
+ function parseApiErrorMessage(body: string): string {
140
+ try {
141
+ const parsed = JSON.parse(body) as Record<string, unknown>;
142
+ const parts = [parsed.message, parsed.error, parsed.code, parsed.reason]
143
+ .filter((value): value is string => typeof value === "string" && value.trim().length > 0);
144
+ return parts.join(" ") || body;
145
+ } catch {
146
+ return body;
147
+ }
148
+ }
149
+
150
+ function isHardSessionInvalidMessage(message: string): boolean {
151
+ const normalized = message.replace(/[_-]+/g, " ");
152
+ return HARD_SESSION_INVALID_PATTERNS.some((pattern) => pattern.test(normalized));
153
+ }
154
+
134
155
  class GloomApiClient {
135
156
  private sessionToken: string | null = null;
136
157
  private sessionCookieName: SessionCookieName | null = null;
@@ -262,12 +283,7 @@ class GloomApiClient {
262
283
 
263
284
  if (!res.ok) {
264
285
  const body = await res.text();
265
- let msg: string;
266
- try {
267
- msg = JSON.parse(body).message ?? body;
268
- } catch {
269
- msg = body;
270
- }
286
+ const msg = parseApiErrorMessage(body);
271
287
  throw new ApiRequestError(msg, res.status);
272
288
  }
273
289
 
@@ -300,10 +316,11 @@ class GloomApiClient {
300
316
 
301
317
  const socketToken = this.getSocketAuthToken();
302
318
  if (!socketToken) return;
319
+ const usingWebSocketToken = !!this.websocketToken;
303
320
  const url = `${this.getWebSocketBaseUrl()}/cloud/ws?token=${encodeURIComponent(socketToken)}`;
304
321
  cloudApiLog.info("open websocket", {
305
322
  hasToken: !!socketToken,
306
- tokenSource: this.websocketToken ? "websocket" : "session",
323
+ tokenSource: usingWebSocketToken ? "websocket" : "session",
307
324
  quoteTargets: this.quoteTargets.size,
308
325
  channelTargets: this.channelListeners.size,
309
326
  });
@@ -321,11 +338,24 @@ class GloomApiClient {
321
338
  void this.handleSocketMessage(String(event.data));
322
339
  };
323
340
 
324
- ws.onclose = () => {
341
+ ws.onclose = (event) => {
342
+ const activeSocket = this.ws === ws;
325
343
  if (this.ws === ws) {
326
344
  this.ws = null;
327
345
  }
328
- cloudApiLog.warn("websocket closed", { quoteTargets: this.quoteTargets.size, channelTargets: this.channelListeners.size });
346
+ const closeEvent = event as CloseEvent | undefined;
347
+ cloudApiLog.warn("websocket closed", {
348
+ quoteTargets: this.quoteTargets.size,
349
+ channelTargets: this.channelListeners.size,
350
+ code: closeEvent?.code,
351
+ reason: closeEvent?.reason,
352
+ tokenSource: usingWebSocketToken ? "websocket" : "session",
353
+ });
354
+ if (activeSocket && usingWebSocketToken && this.sessionToken) {
355
+ this.websocketToken = null;
356
+ this.reconnectDelayMs = 1000;
357
+ cloudApiLog.warn("cleared websocket token after socket close; falling back to session token");
358
+ }
329
359
  if (!this.shouldKeepSocketOpen()) return;
330
360
  this.scheduleReconnect();
331
361
  };
@@ -410,6 +440,14 @@ class GloomApiClient {
410
440
 
411
441
  if (parsed?.type === "auth.unverified") {
412
442
  cloudApiLog.warn("websocket marked unverified");
443
+ if (this.websocketToken && this.sessionToken) {
444
+ this.websocketToken = null;
445
+ this.reconnectDelayMs = 1000;
446
+ cloudApiLog.warn("cleared websocket token after auth rejection; falling back to session token");
447
+ this.teardownSocket();
448
+ this.scheduleReconnect();
449
+ return;
450
+ }
413
451
  if (this.currentUser) {
414
452
  this.currentUser = { ...this.currentUser, emailVerified: false };
415
453
  }
@@ -464,9 +502,11 @@ class GloomApiClient {
464
502
  }
465
503
 
466
504
  async signOut(): Promise<void> {
467
- await this.request("/api/auth/api/auth/sign-out", { method: "POST" });
468
- this.sessionToken = null;
469
- this.setCurrentUser(null);
505
+ try {
506
+ await this.request("/api/auth/api/auth/sign-out", { method: "POST" });
507
+ } finally {
508
+ this.setSessionToken(null);
509
+ }
470
510
  }
471
511
 
472
512
  async getSession(): Promise<AuthUser | null> {
@@ -478,11 +518,11 @@ class GloomApiClient {
478
518
  this.setCurrentUser(user);
479
519
  return user;
480
520
  } catch (error) {
481
- if (!(error instanceof ApiRequestError) || (error.status !== 401 && error.status !== 403)) {
482
- throw error;
521
+ if (error instanceof ApiRequestError && isHardSessionInvalidMessage(error.message)) {
522
+ this.setSessionToken(null);
523
+ return null;
483
524
  }
484
- this.setCurrentUser(null);
485
- return null;
525
+ throw error;
486
526
  }
487
527
  }
488
528
 
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.5.0";
1
+ export const VERSION = "0.5.1";