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 +1 -1
- package/src/utils/api-client.test.ts +86 -0
- package/src/utils/api-client.ts +30 -5
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/utils/api-client.ts
CHANGED
|
@@ -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
|
|
200
|
-
|
|
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
|
-
|
|
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.
|
|
1
|
+
export const VERSION = "0.3.3";
|