gloomberb 0.3.2 → 0.3.3

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.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Bloomberg-style stock portfolio tracker for the terminal",
5
5
  "module": "src/index.tsx",
6
6
  "type": "module",
@@ -0,0 +1,86 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { AuthUser } from "./api-client";
3
+ import { apiClient } from "./api-client";
4
+
5
+ const originalFetch = globalThis.fetch;
6
+
7
+ const verifiedUser: AuthUser = {
8
+ id: "user-1",
9
+ name: "Test User",
10
+ email: "test@example.com",
11
+ username: "test",
12
+ emailVerified: true,
13
+ image: null,
14
+ createdAt: "2026-03-30T00:00:00.000Z",
15
+ updatedAt: "2026-03-30T00:00:00.000Z",
16
+ };
17
+
18
+ function createResponse(body: unknown, options: { status?: number; cookies?: string[] } = {}): Response {
19
+ const headers = {
20
+ getSetCookie: () => options.cookies ?? [],
21
+ get: (name: string) => {
22
+ if (name.toLowerCase() !== "set-cookie") return null;
23
+ return options.cookies?.[0] ?? null;
24
+ },
25
+ } as Headers;
26
+
27
+ return {
28
+ ok: (options.status ?? 200) >= 200 && (options.status ?? 200) < 300,
29
+ status: options.status ?? 200,
30
+ headers,
31
+ text: async () => JSON.stringify(body),
32
+ } as Response;
33
+ }
34
+
35
+ afterEach(() => {
36
+ globalThis.fetch = originalFetch;
37
+ apiClient.setSessionToken(null);
38
+ apiClient.setWebSocketToken(null);
39
+ });
40
+
41
+ describe("apiClient auth cookies", () => {
42
+ test("captures secure session cookies after login and reuses them on session refresh", async () => {
43
+ const seenCookies: Array<string | null> = [];
44
+
45
+ globalThis.fetch = (async (_input: Request | string | URL, init?: RequestInit) => {
46
+ const headers = new Headers(init?.headers);
47
+ seenCookies.push(headers.get("Cookie"));
48
+
49
+ if (seenCookies.length === 1) {
50
+ return createResponse(
51
+ { token: "ws-token", user: verifiedUser },
52
+ { cookies: ["__Secure-gloomberb.session_token=signed-token.value; Path=/; HttpOnly; Secure; SameSite=Lax"] },
53
+ );
54
+ }
55
+
56
+ return createResponse({ user: verifiedUser });
57
+ }) as typeof fetch;
58
+
59
+ await apiClient.signIn("test@example.com", "password");
60
+ await apiClient.getSession();
61
+
62
+ expect(apiClient.getSessionToken()).toBe("signed-token.value");
63
+ expect(apiClient.getWebSocketToken()).toBe("ws-token");
64
+ expect(seenCookies).toEqual([
65
+ null,
66
+ "__Secure-gloomberb.session_token=signed-token.value",
67
+ ]);
68
+ });
69
+
70
+ test("replays both supported cookie names when restoring a saved session token", async () => {
71
+ const seenCookies: Array<string | null> = [];
72
+ apiClient.setSessionToken("persisted-token.value");
73
+
74
+ globalThis.fetch = (async (_input: Request | string | URL, init?: RequestInit) => {
75
+ const headers = new Headers(init?.headers);
76
+ seenCookies.push(headers.get("Cookie"));
77
+ return createResponse({ user: verifiedUser });
78
+ }) as typeof fetch;
79
+
80
+ await apiClient.getSession();
81
+
82
+ expect(seenCookies).toEqual([
83
+ "__Secure-gloomberb.session_token=persisted-token.value; gloomberb.session_token=persisted-token.value",
84
+ ]);
85
+ });
86
+ });
@@ -3,6 +3,7 @@ import type { InstrumentSearchResult } from "../types/instrument";
3
3
  import { debugLog } from "./debug-log";
4
4
 
5
5
  const DEFAULT_API_URL = "https://api.gloom.sh";
6
+ const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
6
7
  const cloudApiLog = debugLog.createLogger("cloud-api");
7
8
 
8
9
  export interface ChatMessage {
@@ -107,6 +108,7 @@ function marketKey(symbol: string, exchange?: string): string {
107
108
 
108
109
  type ChannelListener = (message: ChatMessage) => void;
109
110
  type QuoteListener = (target: QuoteStreamTarget, quote: CloudQuotePayload) => void;
111
+ type SessionCookieName = (typeof SESSION_COOKIE_NAMES)[number];
110
112
 
111
113
  class ApiRequestError extends Error {
112
114
  constructor(message: string, readonly status?: number) {
@@ -117,6 +119,7 @@ class ApiRequestError extends Error {
117
119
 
118
120
  class GloomApiClient {
119
121
  private sessionToken: string | null = null;
122
+ private sessionCookieName: SessionCookieName | null = null;
120
123
  private websocketToken: string | null = null;
121
124
  private currentUser: AuthUser | null = null;
122
125
  private readonly baseUrl: string;
@@ -142,6 +145,9 @@ class GloomApiClient {
142
145
  }
143
146
 
144
147
  setSessionToken(token: string | null): void {
148
+ if (this.sessionToken !== token) {
149
+ this.sessionCookieName = null;
150
+ }
145
151
  this.sessionToken = token;
146
152
  if (!token) {
147
153
  this.websocketToken = null;
@@ -195,22 +201,41 @@ class GloomApiClient {
195
201
 
196
202
  private extractSessionCookie(res: Response): void {
197
203
  const setCookie = res.headers.getSetCookie?.() ?? [];
204
+ const fallbackHeader = res.headers.get("set-cookie");
205
+ if (fallbackHeader) {
206
+ setCookie.push(fallbackHeader);
207
+ }
198
208
  for (const cookie of setCookie) {
199
- const match = cookie.match(/gloomberb\.session_token=([^;]+)/);
200
- if (match) {
209
+ for (const cookieName of SESSION_COOKIE_NAMES) {
210
+ const escapedCookieName = cookieName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
211
+ const match = cookie.match(new RegExp(`${escapedCookieName}=([^;]+)`));
212
+ if (!match) continue;
201
213
  this.sessionToken = match[1] ?? null;
214
+ this.sessionCookieName = cookieName;
215
+ return;
202
216
  }
203
217
  }
204
218
  }
205
219
 
220
+ private buildSessionCookieHeader(): string | null {
221
+ if (!this.sessionToken) return null;
222
+ const cookieNames = this.sessionCookieName ? [this.sessionCookieName] : SESSION_COOKIE_NAMES;
223
+ return cookieNames.map((cookieName) => `${cookieName}=${this.sessionToken}`).join("; ");
224
+ }
225
+
226
+ private setSessionCookieHeader(headers: Headers): void {
227
+ const cookieHeader = this.buildSessionCookieHeader();
228
+ if (cookieHeader) {
229
+ headers.set("Cookie", cookieHeader);
230
+ }
231
+ }
232
+
206
233
  private async request<T>(path: string, options?: RequestInit): Promise<T> {
207
234
  const headers = new Headers(options?.headers);
208
235
  if (!headers.has("Content-Type") && options?.method && options.method !== "GET") {
209
236
  headers.set("Content-Type", "application/json");
210
237
  }
211
- if (this.sessionToken) {
212
- headers.set("Cookie", `gloomberb.session_token=${this.sessionToken}`);
213
- }
238
+ this.setSessionCookieHeader(headers);
214
239
  headers.set("Origin", this.baseUrl);
215
240
 
216
241
  const res = await fetch(`${this.baseUrl}${path}`, {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.3.2";
1
+ export const VERSION = "0.3.3";