gloomberb 0.12.0 → 0.13.2

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.
Files changed (219) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -2
  3. package/src/api-client/auth.ts +2 -1
  4. package/src/api-client/index.test.ts +34 -0
  5. package/src/api-client/index.ts +10 -2
  6. package/src/api-client/request.ts +4 -1
  7. package/src/api-client/research-activity.ts +61 -0
  8. package/src/api-client/types.ts +2 -1
  9. package/src/cli/commands/plugins.ts +4 -3
  10. package/src/cli/desktop-pane-shot.ts +79 -10
  11. package/src/cli/pane-functions/capabilities.ts +76 -23
  12. package/src/cli/pane-functions/cloud-session.test.ts +54 -0
  13. package/src/cli/pane-functions/cloud-session.ts +38 -0
  14. package/src/cli/pane-functions/dom.test.ts +41 -0
  15. package/src/cli/pane-functions/dom.ts +199 -0
  16. package/src/cli/pane-functions/index.test.ts +13 -2
  17. package/src/cli/pane-functions/index.ts +9 -5
  18. package/src/cli/pane-functions/intraday-shot.test.ts +307 -0
  19. package/src/cli/pane-functions/intraday-shot.ts +334 -0
  20. package/src/cli/pane-functions/report.test.ts +29 -1
  21. package/src/cli/pane-functions/report.ts +68 -7
  22. package/src/cli/pane-functions/screenshot.ts +195 -42
  23. package/src/cli/restore-plugins.ts +10 -3
  24. package/src/components/chart/composite/composite-chart.test.tsx +68 -1
  25. package/src/components/chart/composite/composite-chart.tsx +87 -21
  26. package/src/components/chart/composite/rasterizer.ts +33 -0
  27. package/src/components/chart/composite/renderers.test.ts +51 -4
  28. package/src/components/chart/composite/scene.test.ts +128 -2
  29. package/src/components/chart/composite/scene.ts +92 -16
  30. package/src/components/chart/composite/text-renderer.ts +13 -0
  31. package/src/components/chart/composite/time-scale.ts +30 -10
  32. package/src/components/chart/composite/types.ts +26 -0
  33. package/src/components/command-bar/assist/runtime.ts +38 -30
  34. package/src/components/command-bar/surface/assist.test.tsx +63 -9
  35. package/src/components/command-bar/surface/test-harness.tsx +23 -4
  36. package/src/components/onboarding/account-step/chooser-panel.tsx +2 -2
  37. package/src/components/onboarding/account-step/qr-panel.tsx +2 -10
  38. package/src/components/onboarding/onboarding-wizard.test.tsx +38 -12
  39. package/src/components/onboarding/onboarding-wizard.tsx +49 -14
  40. package/src/data/config/store/index.test.ts +23 -0
  41. package/src/data/config/store/node.ts +7 -1
  42. package/src/data/config/store/normalize.ts +4 -0
  43. package/src/news/types.ts +1 -0
  44. package/src/plugins/builtin/cds/client.test.ts +20 -0
  45. package/src/plugins/builtin/cds/client.ts +13 -3
  46. package/src/plugins/builtin/cds/headless.test.ts +30 -0
  47. package/src/plugins/builtin/cds/headless.ts +113 -0
  48. package/src/plugins/builtin/cds/index.tsx +4 -0
  49. package/src/plugins/builtin/chart-composer/cli-options.ts +4 -1
  50. package/src/plugins/builtin/chart-composer/index.tsx +1 -1
  51. package/src/plugins/builtin/chat/controller/channels.ts +2 -1
  52. package/src/plugins/builtin/chat/controller/index.ts +13 -5
  53. package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
  54. package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
  55. package/src/plugins/builtin/chat/controller/state.ts +1 -0
  56. package/src/plugins/builtin/chat/controller.test.ts +260 -4
  57. package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
  58. package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
  59. package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
  60. package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
  61. package/src/plugins/builtin/cloud/askg/protocol.test.ts +190 -0
  62. package/src/plugins/builtin/cloud/askg/protocol.ts +286 -0
  63. package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
  64. package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
  65. package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
  66. package/src/plugins/builtin/cloud/auth-dialog.tsx +7 -1
  67. package/src/plugins/builtin/cloud/device-signin-dialog.tsx +21 -8
  68. package/src/plugins/builtin/cloud/plugin.tsx +2 -0
  69. package/src/plugins/builtin/cloud/verification-panel.tsx +56 -0
  70. package/src/plugins/builtin/congress-trades/client.ts +11 -0
  71. package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
  72. package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
  73. package/src/plugins/builtin/congress-trades/headless.ts +145 -0
  74. package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
  75. package/src/plugins/builtin/credit-conditions/client.ts +1 -1
  76. package/src/plugins/builtin/credit-conditions/headless.test.ts +31 -0
  77. package/src/plugins/builtin/credit-conditions/headless.ts +55 -0
  78. package/src/plugins/builtin/credit-conditions/index.tsx +4 -0
  79. package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
  80. package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
  81. package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
  82. package/src/plugins/builtin/dividend-yield/model.ts +4 -22
  83. package/src/plugins/builtin/dividend-yield/view.ts +17 -0
  84. package/src/plugins/builtin/earnings/headless.test.ts +82 -0
  85. package/src/plugins/builtin/earnings/headless.ts +162 -0
  86. package/src/plugins/builtin/earnings/index.tsx +2 -0
  87. package/src/plugins/builtin/earnings-calls/data.ts +29 -8
  88. package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
  89. package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
  90. package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
  91. package/src/plugins/builtin/earnings-calls/model.ts +238 -0
  92. package/src/plugins/builtin/earnings-calls/pane.tsx +4 -0
  93. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +13 -11
  94. package/src/plugins/builtin/fear-greed/headless.test.ts +53 -0
  95. package/src/plugins/builtin/fear-greed/headless.ts +84 -0
  96. package/src/plugins/builtin/fear-greed/index.tsx +4 -0
  97. package/src/plugins/builtin/holders/client.ts +29 -0
  98. package/src/plugins/builtin/holders/headless.test.ts +58 -0
  99. package/src/plugins/builtin/holders/headless.ts +162 -0
  100. package/src/plugins/builtin/holders/index.tsx +14 -8
  101. package/src/plugins/builtin/holders/pane.tsx +7 -1
  102. package/src/plugins/builtin/insider/client.ts +50 -0
  103. package/src/plugins/builtin/insider/headless.test.ts +122 -0
  104. package/src/plugins/builtin/insider/headless.ts +117 -0
  105. package/src/plugins/builtin/insider/index.tsx +21 -50
  106. package/src/plugins/builtin/insider/insider-data.ts +1 -1
  107. package/src/plugins/builtin/insider/model.ts +72 -0
  108. package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
  109. package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
  110. package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
  111. package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
  112. package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
  113. package/src/plugins/builtin/market-movers/client.ts +76 -0
  114. package/src/plugins/builtin/market-movers/headless.test.ts +34 -0
  115. package/src/plugins/builtin/market-movers/headless.ts +73 -0
  116. package/src/plugins/builtin/market-movers/index.tsx +11 -51
  117. package/src/plugins/builtin/news/headless.test.ts +138 -0
  118. package/src/plugins/builtin/news/headless.ts +190 -0
  119. package/src/plugins/builtin/news/index.tsx +13 -9
  120. package/src/plugins/builtin/news/wire/index.tsx +2 -1
  121. package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
  122. package/src/plugins/builtin/research/analyst-headless.ts +172 -0
  123. package/src/plugins/builtin/research/analyst-model.ts +219 -0
  124. package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
  125. package/src/plugins/builtin/research/client.ts +12 -0
  126. package/src/plugins/builtin/research/corporate-actions-pane.tsx +3 -237
  127. package/src/plugins/builtin/research/event-model.ts +245 -0
  128. package/src/plugins/builtin/research/events-headless.test.ts +32 -0
  129. package/src/plugins/builtin/research/events-headless.ts +87 -0
  130. package/src/plugins/builtin/research/headless.test.ts +93 -0
  131. package/src/plugins/builtin/research/headless.ts +161 -0
  132. package/src/plugins/builtin/research/index.tsx +32 -18
  133. package/src/plugins/builtin/research-search/pane.tsx +4 -0
  134. package/src/plugins/builtin/scanner/headless.test.ts +59 -0
  135. package/src/plugins/builtin/scanner/headless.ts +125 -0
  136. package/src/plugins/builtin/scanner/index.ts +4 -0
  137. package/src/plugins/builtin/sec/client.ts +16 -0
  138. package/src/plugins/builtin/sec/headless.test.ts +72 -0
  139. package/src/plugins/builtin/sec/headless.ts +69 -0
  140. package/src/plugins/builtin/sec/index.tsx +20 -75
  141. package/src/plugins/builtin/sec/model.ts +89 -0
  142. package/src/plugins/builtin/sectors/client.ts +54 -0
  143. package/src/plugins/builtin/sectors/headless.test.ts +28 -0
  144. package/src/plugins/builtin/sectors/headless.ts +80 -0
  145. package/src/plugins/builtin/sectors/index.tsx +6 -51
  146. package/src/plugins/builtin/shared/cloud-upgrade.ts +8 -4
  147. package/src/plugins/builtin/short-interest/client.ts +5 -2
  148. package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
  149. package/src/plugins/builtin/short-interest/headless.ts +111 -0
  150. package/src/plugins/builtin/short-interest/index.tsx +15 -9
  151. package/src/plugins/builtin/thirteenf/data.ts +13 -11
  152. package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
  153. package/src/plugins/builtin/thirteenf/headless.ts +286 -0
  154. package/src/plugins/builtin/thirteenf/index.tsx +4 -0
  155. package/src/plugins/builtin/thirteenf/model.ts +13 -0
  156. package/src/plugins/builtin/thirteenf/table.ts +3 -15
  157. package/src/plugins/builtin/ticker-detail/pane.tsx +14 -0
  158. package/src/plugins/builtin/treasury-auctions/headless.test.ts +34 -0
  159. package/src/plugins/builtin/treasury-auctions/headless.ts +95 -0
  160. package/src/plugins/builtin/treasury-auctions/index.tsx +4 -0
  161. package/src/plugins/builtin/volatility/client.ts +16 -3
  162. package/src/plugins/builtin/volatility/headless.test.ts +35 -0
  163. package/src/plugins/builtin/volatility/headless.ts +75 -0
  164. package/src/plugins/builtin/volatility/index.tsx +4 -0
  165. package/src/plugins/builtin/world-indices/client.ts +43 -0
  166. package/src/plugins/builtin/world-indices/headless.test.ts +32 -0
  167. package/src/plugins/builtin/world-indices/headless.ts +109 -0
  168. package/src/plugins/builtin/world-indices/index.tsx +4 -0
  169. package/src/plugins/builtin/yield-curve/headless.test.ts +24 -0
  170. package/src/plugins/builtin/yield-curve/headless.ts +67 -0
  171. package/src/plugins/builtin/yield-curve/index.tsx +6 -8
  172. package/src/plugins/builtin/yield-curve/treasury-data.ts +16 -6
  173. package/src/plugins/bundle.test.ts +58 -1
  174. package/src/plugins/bundle.ts +4 -2
  175. package/src/plugins/catalog-backend.test.ts +26 -0
  176. package/src/plugins/catalog-backend.ts +9 -2
  177. package/src/plugins/catalog-browser.test.ts +0 -13
  178. package/src/plugins/catalog-browser.ts +4 -0
  179. package/src/plugins/catalog-manifest.test.ts +15 -0
  180. package/src/plugins/catalog-manifest.ts +12 -5
  181. package/src/plugins/catalog-ui.ts +0 -6
  182. package/src/plugins/host-contract.ts +9 -0
  183. package/src/plugins/host-modules.ts +12 -0
  184. package/src/plugins/loader.ts +63 -15
  185. package/src/plugins/prediction-markets/headless.test.ts +119 -0
  186. package/src/plugins/prediction-markets/headless.ts +255 -0
  187. package/src/plugins/prediction-markets/index.tsx +2 -0
  188. package/src/plugins/seed.ts +3 -0
  189. package/src/public/broker.ts +6 -0
  190. package/src/remote/schema.test.ts +65 -0
  191. package/src/remote/schema.ts +322 -39
  192. package/src/remote/types.ts +49 -2
  193. package/src/renderers/browser/app-services.ts +3 -0
  194. package/src/renderers/browser/config-host.ts +24 -8
  195. package/src/renderers/browser/deeplink-bridge.test.ts +11 -0
  196. package/src/renderers/browser/deeplink-bridge.ts +15 -3
  197. package/src/renderers/browser/main.tsx +3 -1
  198. package/src/renderers/browser/research-entry.test.ts +8 -0
  199. package/src/renderers/browser/research-entry.ts +7 -0
  200. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +2 -2
  201. package/src/renderers/electrobun/bun/desktop/initialization.ts +10 -3
  202. package/src/renderers/electrobun/bun/external-plugins.ts +20 -4
  203. package/src/renderers/electrobun/view/build-assets.ts +0 -2
  204. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +63 -10
  205. package/src/sources/gloomberb-cloud/news.ts +1 -0
  206. package/src/sync/core-contributors.test.ts +111 -0
  207. package/src/sync/core-contributors.ts +155 -1
  208. package/src/time-series/resolve.test.ts +118 -1
  209. package/src/time-series/resolve.ts +14 -16
  210. package/src/types/config.ts +2 -0
  211. package/src/plugins/broker-sync/broker-sync.test.ts +0 -188
  212. package/src/plugins/broker-sync/native-loader.ts +0 -10
  213. package/src/plugins/broker-sync/normalize.ts +0 -308
  214. package/src/plugins/broker-sync/public.ts +0 -122
  215. package/src/plugins/broker-sync/robinhood-native.ts +0 -367
  216. package/src/plugins/broker-sync/robinhood.ts +0 -114
  217. package/src/plugins/broker-sync/simplefin-native.ts +0 -201
  218. package/src/plugins/broker-sync/simplefin.ts +0 -70
  219. package/src/renderers/electrobun/view/native-stubs/broker-sync-native-loader.ts +0 -7
@@ -104,12 +104,13 @@ export class ChatControllerChannels {
104
104
  return request;
105
105
  }
106
106
 
107
- async refreshChatState(): Promise<void> {
107
+ async refreshChatState(isCurrent: () => boolean = () => true): Promise<void> {
108
108
  if (!this.options.canLoadPrivateState()) {
109
109
  await this.refreshPresence();
110
110
  return;
111
111
  }
112
112
  const state = await apiClient.getChatState();
113
+ if (!isCurrent()) return;
113
114
  this.channels = normalizeChannels(state.channels);
114
115
  this.onlineCount = state.onlineCount;
115
116
  for (const entry of state.channelStates) {
@@ -197,9 +197,15 @@ export class ChatController {
197
197
  /** Single-flight: every open chat pane calls this on mount. */
198
198
  async refreshSession(): Promise<void> {
199
199
  if (this.sessionRefreshPromise) return this.sessionRefreshPromise;
200
- const request = this.runSessionRefresh().finally(() => {
201
- this.sessionRefreshPromise = null;
202
- });
200
+ this.realtime.stopSessionRetry();
201
+ const request = this.runSessionRefresh()
202
+ .catch((error) => {
203
+ this.realtime.scheduleSessionRetry();
204
+ throw error;
205
+ })
206
+ .finally(() => {
207
+ this.sessionRefreshPromise = null;
208
+ });
203
209
  this.sessionRefreshPromise = request;
204
210
  return request;
205
211
  }
@@ -215,7 +221,8 @@ export class ChatController {
215
221
  ensureRealtimeSubscriptions: () => this.realtime.ensureRealtimeSubscriptions(),
216
222
  persistChannelState: (channelId) => this.storage.persistChannelState(channelId),
217
223
  persistSession: (sessionToken, user) => this.storage.persistSession(sessionToken, user),
218
- refreshChatState: () => this.refreshChatState(),
224
+ refreshChatState: (isCurrent) => this.channelCatalog.refreshChatState(isCurrent),
225
+ scheduleSessionRetry: () => this.realtime.scheduleSessionRetry(),
219
226
  session: this.session,
220
227
  stopRealtimeSubscriptions: () => {
221
228
  this.realtime.stopRealtimeSubscriptions();
@@ -337,11 +344,12 @@ export class ChatController {
337
344
  this.appActive = appActive;
338
345
  if (!appActive) {
339
346
  this.realtime.stopVerificationPolling();
347
+ this.realtime.stopSessionRetry();
340
348
  return;
341
349
  }
342
350
  this.markFocusedViewsViewed();
343
351
  this.realtime.syncVerificationPolling();
344
- void this.refreshChatState()
352
+ void this.refreshSession()
345
353
  .then(() => this.markFocusedViewsViewed())
346
354
  .catch(() => {});
347
355
  }
@@ -1,6 +1,7 @@
1
1
  import { apiClient, type ChatNotification } from "../../../../api-client";
2
2
  import {
3
3
  SAFETY_REFRESH_MS,
4
+ SESSION_RETRY_MS,
4
5
  VERIFICATION_POLL_MS,
5
6
  } from "./state";
6
7
 
@@ -18,6 +19,7 @@ interface ChatControllerRealtimeOptions {
18
19
 
19
20
  export class ChatControllerRealtime {
20
21
  private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
22
+ private sessionRetryTimer: ReturnType<typeof setTimeout> | null = null;
21
23
  private safetyRefreshTimer: ReturnType<typeof setInterval> | null = null;
22
24
  private chatNotificationUnsubscribe: (() => void) | null = null;
23
25
  private chatPresenceUnsubscribe: (() => void) | null = null;
@@ -36,6 +38,27 @@ export class ChatControllerRealtime {
36
38
  }, VERIFICATION_POLL_MS);
37
39
  }
38
40
 
41
+ scheduleSessionRetry(): void {
42
+ if (
43
+ this.sessionRetryTimer
44
+ || !this.options.getAppActive()
45
+ || !this.options.hasSession()
46
+ ) {
47
+ return;
48
+ }
49
+ this.sessionRetryTimer = setTimeout(() => {
50
+ this.sessionRetryTimer = null;
51
+ void this.options.refreshSession().catch(() => {});
52
+ }, SESSION_RETRY_MS);
53
+ this.sessionRetryTimer.unref?.();
54
+ }
55
+
56
+ stopSessionRetry(): void {
57
+ if (!this.sessionRetryTimer) return;
58
+ clearTimeout(this.sessionRetryTimer);
59
+ this.sessionRetryTimer = null;
60
+ }
61
+
39
62
  ensureRealtimeSubscriptions(): void {
40
63
  if (!this.chatNotificationUnsubscribe) {
41
64
  this.chatNotificationUnsubscribe = apiClient.subscribeChatNotifications((notification) => {
@@ -85,6 +108,7 @@ export class ChatControllerRealtime {
85
108
 
86
109
  stopAll(): void {
87
110
  this.stopVerificationPolling();
111
+ this.stopSessionRetry();
88
112
  this.stopSafetyRefresh();
89
113
  this.stopRealtimeSubscriptions();
90
114
  }
@@ -1,4 +1,5 @@
1
1
  import { apiClient } from "../../../../api-client";
2
+ import { ApiRequestError } from "../../../../api-client/errors";
2
3
  import {
3
4
  DEFAULT_CHAT_CHANNEL_ID,
4
5
  type ChannelRuntimeState,
@@ -93,7 +94,8 @@ interface RefreshChatControllerSessionOptions {
93
94
  ensureRealtimeSubscriptions: () => void;
94
95
  persistChannelState: (channelId: string) => void;
95
96
  persistSession: (sessionToken: string | null, user: ChatSessionUser | null) => void;
96
- refreshChatState: () => Promise<void>;
97
+ refreshChatState: (isCurrent?: () => boolean) => Promise<void>;
98
+ scheduleSessionRetry: () => void;
97
99
  session: ChatControllerSessionState;
98
100
  stopRealtimeSubscriptions: () => void;
99
101
  stopSafetyRefresh: () => void;
@@ -110,6 +112,7 @@ export async function refreshChatControllerSession({
110
112
  persistChannelState,
111
113
  persistSession,
112
114
  refreshChatState,
115
+ scheduleSessionRetry,
113
116
  session,
114
117
  stopRealtimeSubscriptions,
115
118
  stopSafetyRefresh,
@@ -119,20 +122,85 @@ export async function refreshChatControllerSession({
119
122
  session.sessionToken = apiClient.getSessionToken();
120
123
  const apiSession = await apiClient.getSession();
121
124
  if (!apiSession) {
122
- const persistedToken = apiClient.getSessionToken() || session.sessionToken;
123
- // A 200 with no user is what /auth/get-session returns when no cookie
124
- // reached the server. Desktop's backend process used to treat that as a
125
- // real sign-out and persist a wiped token, so the next launch was logged
126
- // out. Keep a captured native session; hard account-missing responses
127
- // already clear the token inside getSession().
125
+ const persistedToken = apiClient.getSessionToken();
126
+ // A 200 with no user used to mean the desktop backend had not received the
127
+ // restored cookie yet, so do not discard a captured native session on that
128
+ // response alone. Probe another authenticated endpoint: success validates
129
+ // the token, a 401 proves it expired, and a transient failure keeps offline
130
+ // startup from signing the user out.
128
131
  if (persistedToken) {
129
132
  session.sessionToken = persistedToken;
130
133
  session.user = session.user ?? normalizeSessionUser(apiClient.getCurrentUser());
134
+ // getSession() clears the API client's cached user when the endpoint
135
+ // answers null. Keep both session views aligned while the protected
136
+ // request validates the preserved native credential.
137
+ apiClient.restoreCachedUser(session.user);
131
138
  session.sessionChecked = true;
132
139
  persistSession(session.sessionToken, session.user);
133
140
  emit();
141
+
142
+ const credentialIsCurrent = () =>
143
+ apiClient.getSessionToken() === persistedToken && session.sessionToken === persistedToken;
144
+ const handleValidationError = (error: unknown) => {
145
+ if (!credentialIsCurrent()) return;
146
+ if (error instanceof ApiRequestError && error.status === 401) {
147
+ apiClient.setSessionToken(null);
148
+ session.sessionToken = null;
149
+ applySignedOut();
150
+ return;
151
+ }
152
+ if (error instanceof ApiRequestError && error.status === 403 && session.user) {
153
+ session.user = { ...session.user, emailVerified: false };
154
+ apiClient.restoreCachedUser(session.user);
155
+ persistSession(session.sessionToken, session.user);
156
+ emit();
157
+ syncVerificationPolling();
158
+ stopSafetyRefresh();
159
+ stopRealtimeSubscriptions();
160
+ return;
161
+ }
162
+ scheduleSessionRetry();
163
+ };
164
+
165
+ if (!session.user?.emailVerified) {
166
+ try {
167
+ const profile = await apiClient.getAccountProfile();
168
+ if (!credentialIsCurrent()) return;
169
+ const persistedProfile = {
170
+ ...profile,
171
+ updatedAt: profile.updatedAt ?? undefined,
172
+ };
173
+ session.user = normalizeSessionUser(persistedProfile);
174
+ apiClient.restoreCachedUser(persistedProfile);
175
+ persistSession(session.sessionToken, session.user);
176
+ emit();
177
+ } catch (error) {
178
+ handleValidationError(error);
179
+ return;
180
+ }
181
+
182
+ if (!session.user?.emailVerified) {
183
+ syncVerificationPolling();
184
+ stopSafetyRefresh();
185
+ stopRealtimeSubscriptions();
186
+ return;
187
+ }
188
+ }
189
+
190
+ try {
191
+ await refreshChatState(credentialIsCurrent);
192
+ } catch (error) {
193
+ handleValidationError(error);
194
+ return;
195
+ }
196
+ if (!credentialIsCurrent()) return;
197
+
198
+ stopVerificationPolling();
199
+ ensureRealtimeSubscriptions();
200
+ ensureOpenChannelConnections();
134
201
  return;
135
202
  }
203
+ session.sessionToken = null;
136
204
  applySignedOut();
137
205
  return;
138
206
  }
@@ -152,7 +220,7 @@ export async function refreshChatControllerSession({
152
220
  if (nextUser?.emailVerified) {
153
221
  stopVerificationPolling();
154
222
  ensureRealtimeSubscriptions();
155
- await refreshChatState().catch(() => {});
223
+ await refreshChatState().catch(() => scheduleSessionRetry());
156
224
  ensureOpenChannelConnections();
157
225
  return;
158
226
  }
@@ -21,6 +21,7 @@ export const TRANSCRIPT_CACHE_POLICY = {
21
21
  };
22
22
  export const DRAFT_SYNC_DEBOUNCE_MS = 250;
23
23
  export const VERIFICATION_POLL_MS = 5_000;
24
+ export const SESSION_RETRY_MS = 30_000;
24
25
  export const SAFETY_REFRESH_MS = 30_000;
25
26
  export const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
26
27
 
@@ -1,8 +1,16 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test";
2
2
  import type { AppNotificationRequest } from "../../../types/plugin";
3
3
  import { MemoryPluginPersistence as MemoryPersistence } from "../../../test-support/plugin-persistence";
4
- import { apiClient, type ChatChannel, type ChatMessage, type ChatNotification } from "../../../api-client";
4
+ import {
5
+ apiClient,
6
+ type ChatChannel,
7
+ type ChatMessage,
8
+ type ChatNotification,
9
+ type ChatStateResponse,
10
+ } from "../../../api-client";
11
+ import { ApiRequestError } from "../../../api-client/errors";
5
12
  import { ChatController } from "./controller";
13
+ import { SESSION_RETRY_MS } from "./controller/state";
6
14
 
7
15
  const TRANSCRIPT_KIND = "channel-transcript";
8
16
  const TRANSCRIPT_KEY = "everyone";
@@ -14,6 +22,7 @@ const originalGetMessages = apiClient.getMessages.bind(apiClient);
14
22
  const originalGetChannels = apiClient.getChannels.bind(apiClient);
15
23
  const originalGetChatPresence = apiClient.getChatPresence.bind(apiClient);
16
24
  const originalGetChatState = apiClient.getChatState.bind(apiClient);
25
+ const originalGetAccountProfile = apiClient.getAccountProfile.bind(apiClient);
17
26
  const originalUpdateChatChannelState = apiClient.updateChatChannelState.bind(apiClient);
18
27
  const originalMarkChatNotificationsDelivered = apiClient.markChatNotificationsDelivered.bind(apiClient);
19
28
  const originalSubscribeChatNotifications = apiClient.subscribeChatNotifications.bind(apiClient);
@@ -91,6 +100,7 @@ beforeEach(() => {
91
100
  });
92
101
 
93
102
  afterEach(() => {
103
+ jest.useRealTimers();
94
104
  apiClient.dispose();
95
105
  apiClient.setSessionToken(null);
96
106
  apiClient.setCookieSessionMode(false);
@@ -100,6 +110,7 @@ afterEach(() => {
100
110
  apiClient.getChannels = originalGetChannels;
101
111
  apiClient.getChatPresence = originalGetChatPresence;
102
112
  apiClient.getChatState = originalGetChatState;
113
+ apiClient.getAccountProfile = originalGetAccountProfile;
103
114
  apiClient.updateChatChannelState = originalUpdateChatChannelState;
104
115
  apiClient.markChatNotificationsDelivered = originalMarkChatNotificationsDelivered;
105
116
  apiClient.subscribeChatNotifications = originalSubscribeChatNotifications;
@@ -453,9 +464,15 @@ describe("ChatController", () => {
453
464
  }
454
465
  });
455
466
 
456
- test("keeps a persisted native session when get-session returns no user", async () => {
467
+ test("validates a persisted native session through protected chat state", async () => {
457
468
  const persistence = new MemoryPersistence();
458
469
  const controller = new ChatController();
470
+ const directChannel: ChatChannel = {
471
+ id: "dm:u2",
472
+ name: "u2",
473
+ kind: "direct",
474
+ created_at: "2026-03-28T00:00:00.000Z",
475
+ };
459
476
 
460
477
  persistence.setState("session", {
461
478
  sessionToken: "token-123",
@@ -463,11 +480,22 @@ describe("ChatController", () => {
463
480
  }, { schemaVersion: 1 });
464
481
 
465
482
  controller.attachPersistence(persistence);
466
- apiClient.getSession = async () => null;
483
+ apiClient.getSession = async () => {
484
+ apiClient.restoreCachedUser(null);
485
+ return null;
486
+ };
487
+ apiClient.getChatState = async () => ({
488
+ channels: [...SERVER_CHAT_CHANNELS, directChannel],
489
+ onlineCount: 0,
490
+ channelStates: [],
491
+ notifications: [],
492
+ });
467
493
 
468
494
  await controller.refreshSession();
469
495
 
470
496
  expect(apiClient.getSessionToken()).toBe("token-123");
497
+ expect(apiClient.getCurrentUser()?.id).toBe("u1");
498
+ expect(controller.getChannels()).toContainEqual(directChannel);
471
499
  expect(persistence.getState<{
472
500
  sessionToken: string;
473
501
  user: { id: string; username: string; emailVerified: boolean };
@@ -483,6 +511,232 @@ describe("ChatController", () => {
483
511
  controller.dispose();
484
512
  });
485
513
 
514
+ test("clears an expired persisted session when protected chat rejects it", async () => {
515
+ const persistence = new MemoryPersistence();
516
+ const controller = new ChatController();
517
+
518
+ persistence.setState("session", {
519
+ sessionToken: "expired-token",
520
+ user: { id: "u1", username: "vince", emailVerified: true },
521
+ }, { schemaVersion: 1 });
522
+
523
+ controller.attachPersistence(persistence);
524
+ apiClient.getSession = async () => {
525
+ apiClient.restoreCachedUser(null);
526
+ return null;
527
+ };
528
+ apiClient.getChatState = async () => {
529
+ throw new ApiRequestError("Unauthorized", 401);
530
+ };
531
+
532
+ await controller.refreshSession();
533
+
534
+ expect(apiClient.getSessionToken()).toBeNull();
535
+ expect(apiClient.getCurrentUser()).toBeNull();
536
+ expect(controller.getSnapshot().user).toBeNull();
537
+ expect(persistence.getState("session", { schemaVersion: 1 })).toEqual({
538
+ sessionToken: null,
539
+ user: null,
540
+ });
541
+ controller.dispose();
542
+ });
543
+
544
+ test("clears an expired persisted session for an unverified user", async () => {
545
+ const persistence = new MemoryPersistence();
546
+ const controller = new ChatController();
547
+ let profileRequests = 0;
548
+
549
+ persistence.setState("session", {
550
+ sessionToken: "expired-token",
551
+ user: { id: "u1", username: "vince", emailVerified: false },
552
+ }, { schemaVersion: 1 });
553
+
554
+ controller.attachPersistence(persistence);
555
+ apiClient.getSession = async () => {
556
+ apiClient.restoreCachedUser(null);
557
+ return null;
558
+ };
559
+ apiClient.getAccountProfile = async () => {
560
+ profileRequests += 1;
561
+ throw new ApiRequestError("Unauthorized", 401);
562
+ };
563
+
564
+ await controller.refreshSession();
565
+
566
+ expect(profileRequests).toBe(1);
567
+ expect(apiClient.getSessionToken()).toBeNull();
568
+ expect(apiClient.getCurrentUser()).toBeNull();
569
+ expect(controller.getSnapshot().user).toBeNull();
570
+ controller.dispose();
571
+ });
572
+
573
+ test("downgrades stale verification state when protected chat rejects it", async () => {
574
+ const persistence = new MemoryPersistence();
575
+ const controller = new ChatController();
576
+
577
+ persistence.setState("session", {
578
+ sessionToken: "token-123",
579
+ user: { id: "u1", username: "vince", emailVerified: true },
580
+ }, { schemaVersion: 1 });
581
+
582
+ controller.attachPersistence(persistence);
583
+ apiClient.getSession = async () => {
584
+ apiClient.restoreCachedUser(null);
585
+ return null;
586
+ };
587
+ apiClient.getChatState = async () => {
588
+ throw new ApiRequestError("Email verification required", 403);
589
+ };
590
+
591
+ await controller.refreshSession();
592
+
593
+ expect(apiClient.getSessionToken()).toBe("token-123");
594
+ expect(apiClient.getCurrentUser()?.emailVerified).toBe(false);
595
+ expect(controller.getSnapshot().user?.emailVerified).toBe(false);
596
+ expect(persistence.getState<{
597
+ sessionToken: string;
598
+ user: { id: string; username: string; emailVerified: boolean };
599
+ }>("session", { schemaVersion: 1 })?.user.emailVerified).toBe(false);
600
+ controller.dispose();
601
+ });
602
+
603
+ test("does not let a stale validation failure clear a replacement session", async () => {
604
+ const persistence = new MemoryPersistence();
605
+ const controller = new ChatController();
606
+ let rejectProbe: ((error: Error) => void) | null = null;
607
+ let markProbeStarted: (() => void) | null = null;
608
+ const probeStarted = new Promise<void>((resolve) => {
609
+ markProbeStarted = resolve;
610
+ });
611
+
612
+ persistence.setState("session", {
613
+ sessionToken: "old-token",
614
+ user: { id: "u1", username: "vince", emailVerified: true },
615
+ }, { schemaVersion: 1 });
616
+
617
+ controller.attachPersistence(persistence);
618
+ apiClient.getSession = async () => {
619
+ apiClient.restoreCachedUser(null);
620
+ return null;
621
+ };
622
+ apiClient.getChatState = () => new Promise((_, reject) => {
623
+ rejectProbe = reject;
624
+ markProbeStarted?.();
625
+ });
626
+
627
+ const refresh = controller.refreshSession();
628
+ await probeStarted;
629
+ controller.adoptSession("new-token", {
630
+ id: "u2",
631
+ username: "mara",
632
+ emailVerified: true,
633
+ });
634
+ rejectProbe?.(new ApiRequestError("Unauthorized", 401));
635
+ await refresh;
636
+
637
+ expect(apiClient.getSessionToken()).toBe("new-token");
638
+ expect(apiClient.getCurrentUser()?.id).toBe("u2");
639
+ expect(controller.getSnapshot().user?.id).toBe("u2");
640
+ expect(persistence.getState<{
641
+ sessionToken: string;
642
+ user: { id: string };
643
+ }>("session", { schemaVersion: 1 })).toMatchObject({
644
+ sessionToken: "new-token",
645
+ user: { id: "u2" },
646
+ });
647
+ controller.dispose();
648
+ });
649
+
650
+ test("does not apply stale chat state after a replacement session", async () => {
651
+ const persistence = new MemoryPersistence();
652
+ const controller = new ChatController();
653
+ let resolveProbe: ((state: ChatStateResponse) => void) | null = null;
654
+ let markProbeStarted: (() => void) | null = null;
655
+ const probeStarted = new Promise<void>((resolve) => {
656
+ markProbeStarted = resolve;
657
+ });
658
+ const oldDirectChannel: ChatChannel = {
659
+ id: "dm:old-account",
660
+ name: "old-account",
661
+ kind: "direct",
662
+ created_at: "2026-03-28T00:00:00.000Z",
663
+ };
664
+
665
+ persistence.setState("session", {
666
+ sessionToken: "old-token",
667
+ user: { id: "u1", username: "vince", emailVerified: true },
668
+ }, { schemaVersion: 1 });
669
+
670
+ controller.attachPersistence(persistence);
671
+ apiClient.getSession = async () => {
672
+ apiClient.restoreCachedUser(null);
673
+ return null;
674
+ };
675
+ apiClient.getChatState = () => new Promise<ChatStateResponse>((resolve) => {
676
+ resolveProbe = resolve;
677
+ markProbeStarted?.();
678
+ });
679
+
680
+ const refresh = controller.refreshSession();
681
+ await probeStarted;
682
+ controller.adoptSession("new-token", {
683
+ id: "u2",
684
+ username: "mara",
685
+ emailVerified: true,
686
+ });
687
+ resolveProbe?.({
688
+ channels: [...SERVER_CHAT_CHANNELS, oldDirectChannel],
689
+ onlineCount: 0,
690
+ channelStates: [],
691
+ notifications: [],
692
+ });
693
+ await refresh;
694
+
695
+ expect(apiClient.getSessionToken()).toBe("new-token");
696
+ expect(controller.getSnapshot().user?.id).toBe("u2");
697
+ expect(controller.getChannels()).not.toContainEqual(oldDirectChannel);
698
+ controller.dispose();
699
+ });
700
+
701
+ test("revalidates a persisted session after an offline validation probe", async () => {
702
+ jest.useFakeTimers();
703
+ const persistence = new MemoryPersistence();
704
+ const controller = new ChatController();
705
+
706
+ persistence.setState("session", {
707
+ sessionToken: "token-123",
708
+ user: { id: "u1", username: "vince", emailVerified: true },
709
+ }, { schemaVersion: 1 });
710
+
711
+ controller.attachPersistence(persistence);
712
+ apiClient.getSession = async () => {
713
+ apiClient.restoreCachedUser(null);
714
+ return null;
715
+ };
716
+ apiClient.getChatState = async () => {
717
+ throw new Error("network down");
718
+ };
719
+
720
+ await controller.refreshSession();
721
+
722
+ expect(apiClient.getSessionToken()).toBe("token-123");
723
+ expect(apiClient.getCurrentUser()?.id).toBe("u1");
724
+ expect(controller.getSnapshot().user?.id).toBe("u1");
725
+ expect((controller as any).realtime.sessionRetryTimer).not.toBeNull();
726
+
727
+ apiClient.getChatState = async () => {
728
+ throw new ApiRequestError("Unauthorized", 401);
729
+ };
730
+ jest.advanceTimersByTime(SESSION_RETRY_MS);
731
+ await controller.refreshSession();
732
+
733
+ expect(apiClient.getSessionToken()).toBeNull();
734
+ expect(controller.getSnapshot().user).toBeNull();
735
+ expect((controller as any).realtime.sessionRetryTimer).toBeNull();
736
+ controller.dispose();
737
+ jest.useRealTimers();
738
+ });
739
+
486
740
  test("signs out when get-session returns no user and no token is stored", async () => {
487
741
  const persistence = new MemoryPersistence();
488
742
  const controller = new ChatController();
@@ -530,6 +784,8 @@ describe("ChatController", () => {
530
784
  username: "vince",
531
785
  emailVerified: true,
532
786
  });
787
+ expect((controller as any).realtime.sessionRetryTimer).not.toBeNull();
788
+ controller.dispose();
533
789
  });
534
790
 
535
791
  test("refreshes the public transcript without requiring a session", async () => {