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.
- package/README.md +2 -0
- package/package.json +1 -2
- package/src/api-client/auth.ts +2 -1
- package/src/api-client/index.test.ts +34 -0
- package/src/api-client/index.ts +10 -2
- package/src/api-client/request.ts +4 -1
- package/src/api-client/research-activity.ts +61 -0
- package/src/api-client/types.ts +2 -1
- package/src/cli/commands/plugins.ts +4 -3
- package/src/cli/desktop-pane-shot.ts +79 -10
- package/src/cli/pane-functions/capabilities.ts +76 -23
- package/src/cli/pane-functions/cloud-session.test.ts +54 -0
- package/src/cli/pane-functions/cloud-session.ts +38 -0
- package/src/cli/pane-functions/dom.test.ts +41 -0
- package/src/cli/pane-functions/dom.ts +199 -0
- package/src/cli/pane-functions/index.test.ts +13 -2
- package/src/cli/pane-functions/index.ts +9 -5
- package/src/cli/pane-functions/intraday-shot.test.ts +307 -0
- package/src/cli/pane-functions/intraday-shot.ts +334 -0
- package/src/cli/pane-functions/report.test.ts +29 -1
- package/src/cli/pane-functions/report.ts +68 -7
- package/src/cli/pane-functions/screenshot.ts +195 -42
- package/src/cli/restore-plugins.ts +10 -3
- package/src/components/chart/composite/composite-chart.test.tsx +68 -1
- package/src/components/chart/composite/composite-chart.tsx +87 -21
- package/src/components/chart/composite/rasterizer.ts +33 -0
- package/src/components/chart/composite/renderers.test.ts +51 -4
- package/src/components/chart/composite/scene.test.ts +128 -2
- package/src/components/chart/composite/scene.ts +92 -16
- package/src/components/chart/composite/text-renderer.ts +13 -0
- package/src/components/chart/composite/time-scale.ts +30 -10
- package/src/components/chart/composite/types.ts +26 -0
- package/src/components/command-bar/assist/runtime.ts +38 -30
- package/src/components/command-bar/surface/assist.test.tsx +63 -9
- package/src/components/command-bar/surface/test-harness.tsx +23 -4
- package/src/components/onboarding/account-step/chooser-panel.tsx +2 -2
- package/src/components/onboarding/account-step/qr-panel.tsx +2 -10
- package/src/components/onboarding/onboarding-wizard.test.tsx +38 -12
- package/src/components/onboarding/onboarding-wizard.tsx +49 -14
- package/src/data/config/store/index.test.ts +23 -0
- package/src/data/config/store/node.ts +7 -1
- package/src/data/config/store/normalize.ts +4 -0
- package/src/news/types.ts +1 -0
- package/src/plugins/builtin/cds/client.test.ts +20 -0
- package/src/plugins/builtin/cds/client.ts +13 -3
- package/src/plugins/builtin/cds/headless.test.ts +30 -0
- package/src/plugins/builtin/cds/headless.ts +113 -0
- package/src/plugins/builtin/cds/index.tsx +4 -0
- package/src/plugins/builtin/chart-composer/cli-options.ts +4 -1
- package/src/plugins/builtin/chart-composer/index.tsx +1 -1
- package/src/plugins/builtin/chat/controller/channels.ts +2 -1
- package/src/plugins/builtin/chat/controller/index.ts +13 -5
- package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
- package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
- package/src/plugins/builtin/chat/controller/state.ts +1 -0
- package/src/plugins/builtin/chat/controller.test.ts +260 -4
- package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
- package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
- package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
- package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
- package/src/plugins/builtin/cloud/askg/protocol.test.ts +190 -0
- package/src/plugins/builtin/cloud/askg/protocol.ts +286 -0
- package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
- package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
- package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
- package/src/plugins/builtin/cloud/auth-dialog.tsx +7 -1
- package/src/plugins/builtin/cloud/device-signin-dialog.tsx +21 -8
- package/src/plugins/builtin/cloud/plugin.tsx +2 -0
- package/src/plugins/builtin/cloud/verification-panel.tsx +56 -0
- package/src/plugins/builtin/congress-trades/client.ts +11 -0
- package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
- package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
- package/src/plugins/builtin/congress-trades/headless.ts +145 -0
- package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
- package/src/plugins/builtin/credit-conditions/client.ts +1 -1
- package/src/plugins/builtin/credit-conditions/headless.test.ts +31 -0
- package/src/plugins/builtin/credit-conditions/headless.ts +55 -0
- package/src/plugins/builtin/credit-conditions/index.tsx +4 -0
- package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
- package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
- package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
- package/src/plugins/builtin/dividend-yield/model.ts +4 -22
- package/src/plugins/builtin/dividend-yield/view.ts +17 -0
- package/src/plugins/builtin/earnings/headless.test.ts +82 -0
- package/src/plugins/builtin/earnings/headless.ts +162 -0
- package/src/plugins/builtin/earnings/index.tsx +2 -0
- package/src/plugins/builtin/earnings-calls/data.ts +29 -8
- package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
- package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
- package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
- package/src/plugins/builtin/earnings-calls/model.ts +238 -0
- package/src/plugins/builtin/earnings-calls/pane.tsx +4 -0
- package/src/plugins/builtin/earnings-calls/transcript-view.tsx +13 -11
- package/src/plugins/builtin/fear-greed/headless.test.ts +53 -0
- package/src/plugins/builtin/fear-greed/headless.ts +84 -0
- package/src/plugins/builtin/fear-greed/index.tsx +4 -0
- package/src/plugins/builtin/holders/client.ts +29 -0
- package/src/plugins/builtin/holders/headless.test.ts +58 -0
- package/src/plugins/builtin/holders/headless.ts +162 -0
- package/src/plugins/builtin/holders/index.tsx +14 -8
- package/src/plugins/builtin/holders/pane.tsx +7 -1
- package/src/plugins/builtin/insider/client.ts +50 -0
- package/src/plugins/builtin/insider/headless.test.ts +122 -0
- package/src/plugins/builtin/insider/headless.ts +117 -0
- package/src/plugins/builtin/insider/index.tsx +21 -50
- package/src/plugins/builtin/insider/insider-data.ts +1 -1
- package/src/plugins/builtin/insider/model.ts +72 -0
- package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
- package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
- package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
- package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
- package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
- package/src/plugins/builtin/market-movers/client.ts +76 -0
- package/src/plugins/builtin/market-movers/headless.test.ts +34 -0
- package/src/plugins/builtin/market-movers/headless.ts +73 -0
- package/src/plugins/builtin/market-movers/index.tsx +11 -51
- package/src/plugins/builtin/news/headless.test.ts +138 -0
- package/src/plugins/builtin/news/headless.ts +190 -0
- package/src/plugins/builtin/news/index.tsx +13 -9
- package/src/plugins/builtin/news/wire/index.tsx +2 -1
- package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
- package/src/plugins/builtin/research/analyst-headless.ts +172 -0
- package/src/plugins/builtin/research/analyst-model.ts +219 -0
- package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
- package/src/plugins/builtin/research/client.ts +12 -0
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +3 -237
- package/src/plugins/builtin/research/event-model.ts +245 -0
- package/src/plugins/builtin/research/events-headless.test.ts +32 -0
- package/src/plugins/builtin/research/events-headless.ts +87 -0
- package/src/plugins/builtin/research/headless.test.ts +93 -0
- package/src/plugins/builtin/research/headless.ts +161 -0
- package/src/plugins/builtin/research/index.tsx +32 -18
- package/src/plugins/builtin/research-search/pane.tsx +4 -0
- package/src/plugins/builtin/scanner/headless.test.ts +59 -0
- package/src/plugins/builtin/scanner/headless.ts +125 -0
- package/src/plugins/builtin/scanner/index.ts +4 -0
- package/src/plugins/builtin/sec/client.ts +16 -0
- package/src/plugins/builtin/sec/headless.test.ts +72 -0
- package/src/plugins/builtin/sec/headless.ts +69 -0
- package/src/plugins/builtin/sec/index.tsx +20 -75
- package/src/plugins/builtin/sec/model.ts +89 -0
- package/src/plugins/builtin/sectors/client.ts +54 -0
- package/src/plugins/builtin/sectors/headless.test.ts +28 -0
- package/src/plugins/builtin/sectors/headless.ts +80 -0
- package/src/plugins/builtin/sectors/index.tsx +6 -51
- package/src/plugins/builtin/shared/cloud-upgrade.ts +8 -4
- package/src/plugins/builtin/short-interest/client.ts +5 -2
- package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
- package/src/plugins/builtin/short-interest/headless.ts +111 -0
- package/src/plugins/builtin/short-interest/index.tsx +15 -9
- package/src/plugins/builtin/thirteenf/data.ts +13 -11
- package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
- package/src/plugins/builtin/thirteenf/headless.ts +286 -0
- package/src/plugins/builtin/thirteenf/index.tsx +4 -0
- package/src/plugins/builtin/thirteenf/model.ts +13 -0
- package/src/plugins/builtin/thirteenf/table.ts +3 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +14 -0
- package/src/plugins/builtin/treasury-auctions/headless.test.ts +34 -0
- package/src/plugins/builtin/treasury-auctions/headless.ts +95 -0
- package/src/plugins/builtin/treasury-auctions/index.tsx +4 -0
- package/src/plugins/builtin/volatility/client.ts +16 -3
- package/src/plugins/builtin/volatility/headless.test.ts +35 -0
- package/src/plugins/builtin/volatility/headless.ts +75 -0
- package/src/plugins/builtin/volatility/index.tsx +4 -0
- package/src/plugins/builtin/world-indices/client.ts +43 -0
- package/src/plugins/builtin/world-indices/headless.test.ts +32 -0
- package/src/plugins/builtin/world-indices/headless.ts +109 -0
- package/src/plugins/builtin/world-indices/index.tsx +4 -0
- package/src/plugins/builtin/yield-curve/headless.test.ts +24 -0
- package/src/plugins/builtin/yield-curve/headless.ts +67 -0
- package/src/plugins/builtin/yield-curve/index.tsx +6 -8
- package/src/plugins/builtin/yield-curve/treasury-data.ts +16 -6
- package/src/plugins/bundle.test.ts +58 -1
- package/src/plugins/bundle.ts +4 -2
- package/src/plugins/catalog-backend.test.ts +26 -0
- package/src/plugins/catalog-backend.ts +9 -2
- package/src/plugins/catalog-browser.test.ts +0 -13
- package/src/plugins/catalog-browser.ts +4 -0
- package/src/plugins/catalog-manifest.test.ts +15 -0
- package/src/plugins/catalog-manifest.ts +12 -5
- package/src/plugins/catalog-ui.ts +0 -6
- package/src/plugins/host-contract.ts +9 -0
- package/src/plugins/host-modules.ts +12 -0
- package/src/plugins/loader.ts +63 -15
- package/src/plugins/prediction-markets/headless.test.ts +119 -0
- package/src/plugins/prediction-markets/headless.ts +255 -0
- package/src/plugins/prediction-markets/index.tsx +2 -0
- package/src/plugins/seed.ts +3 -0
- package/src/public/broker.ts +6 -0
- package/src/remote/schema.test.ts +65 -0
- package/src/remote/schema.ts +322 -39
- package/src/remote/types.ts +49 -2
- package/src/renderers/browser/app-services.ts +3 -0
- package/src/renderers/browser/config-host.ts +24 -8
- package/src/renderers/browser/deeplink-bridge.test.ts +11 -0
- package/src/renderers/browser/deeplink-bridge.ts +15 -3
- package/src/renderers/browser/main.tsx +3 -1
- package/src/renderers/browser/research-entry.test.ts +8 -0
- package/src/renderers/browser/research-entry.ts +7 -0
- package/src/renderers/electrobun/bun/desktop/backend-requests.ts +2 -2
- package/src/renderers/electrobun/bun/desktop/initialization.ts +10 -3
- package/src/renderers/electrobun/bun/external-plugins.ts +20 -4
- package/src/renderers/electrobun/view/build-assets.ts +0 -2
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +63 -10
- package/src/sources/gloomberb-cloud/news.ts +1 -0
- package/src/sync/core-contributors.test.ts +111 -0
- package/src/sync/core-contributors.ts +155 -1
- package/src/time-series/resolve.test.ts +118 -1
- package/src/time-series/resolve.ts +14 -16
- package/src/types/config.ts +2 -0
- package/src/plugins/broker-sync/broker-sync.test.ts +0 -188
- package/src/plugins/broker-sync/native-loader.ts +0 -10
- package/src/plugins/broker-sync/normalize.ts +0 -308
- package/src/plugins/broker-sync/public.ts +0 -122
- package/src/plugins/broker-sync/robinhood-native.ts +0 -367
- package/src/plugins/broker-sync/robinhood.ts +0 -114
- package/src/plugins/broker-sync/simplefin-native.ts +0 -201
- package/src/plugins/broker-sync/simplefin.ts +0 -70
- 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
|
-
|
|
201
|
-
|
|
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.
|
|
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()
|
|
123
|
-
// A 200 with no user
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
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 {
|
|
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("
|
|
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 () =>
|
|
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 () => {
|