assistant-cloud 0.1.42 → 0.2.0
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 +40 -0
- package/dist/AssistantCloud.d.ts +4 -0
- package/dist/AssistantCloud.d.ts.map +1 -1
- package/dist/AssistantCloud.js +11 -5
- package/dist/AssistantCloud.js.map +1 -1
- package/dist/AssistantCloudAPI.d.ts +17 -1
- package/dist/AssistantCloudAPI.d.ts.map +1 -1
- package/dist/AssistantCloudAPI.js +20 -9
- package/dist/AssistantCloudAPI.js.map +1 -1
- package/dist/AssistantCloudAuthStrategy.d.ts +4 -1
- package/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
- package/dist/AssistantCloudAuthStrategy.js +79 -18
- package/dist/AssistantCloudAuthStrategy.js.map +1 -1
- package/dist/AssistantCloudEvents.d.ts +32 -0
- package/dist/AssistantCloudEvents.d.ts.map +1 -0
- package/dist/AssistantCloudEvents.js +115 -0
- package/dist/AssistantCloudEvents.js.map +1 -0
- package/dist/AssistantCloudFiles.d.ts +12 -1
- package/dist/AssistantCloudFiles.d.ts.map +1 -1
- package/dist/AssistantCloudFiles.js +13 -1
- package/dist/AssistantCloudFiles.js.map +1 -1
- package/dist/AssistantCloudRuns.d.ts +11 -0
- package/dist/AssistantCloudRuns.d.ts.map +1 -1
- package/dist/AssistantCloudRuns.js.map +1 -1
- package/dist/AssistantCloudScores.d.ts +27 -0
- package/dist/AssistantCloudScores.d.ts.map +1 -0
- package/dist/AssistantCloudScores.js +30 -0
- package/dist/AssistantCloudScores.js.map +1 -0
- package/dist/AssistantCloudThreadMessages.d.ts +11 -1
- package/dist/AssistantCloudThreadMessages.d.ts.map +1 -1
- package/dist/AssistantCloudThreadMessages.js +12 -1
- package/dist/AssistantCloudThreadMessages.js.map +1 -1
- package/dist/AssistantCloudThreads.d.ts +8 -0
- package/dist/AssistantCloudThreads.d.ts.map +1 -1
- package/dist/AssistantCloudThreads.js +9 -1
- package/dist/AssistantCloudThreads.js.map +1 -1
- package/dist/CloudMessagePersistence.d.ts +4 -0
- package/dist/CloudMessagePersistence.d.ts.map +1 -1
- package/dist/CloudMessagePersistence.js +53 -18
- package/dist/CloudMessagePersistence.js.map +1 -1
- package/dist/cloudResponse.d.ts +4 -1
- package/dist/cloudResponse.d.ts.map +1 -1
- package/dist/cloudResponse.js +14 -1
- package/dist/cloudResponse.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +5 -2
- package/dist/runTelemetry.d.ts +79 -9
- package/dist/runTelemetry.d.ts.map +1 -1
- package/dist/runTelemetry.js +152 -6
- package/dist/runTelemetry.js.map +1 -1
- package/dist/telemetry/index.d.ts +32 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +62 -0
- package/dist/telemetry/index.js.map +1 -0
- package/package.json +28 -4
- package/src/AssistantCloud.ts +20 -8
- package/src/AssistantCloudAPI.ts +42 -10
- package/src/AssistantCloudAuthStrategy.ts +153 -46
- package/src/AssistantCloudEvents.test.ts +124 -0
- package/src/AssistantCloudEvents.ts +185 -0
- package/src/AssistantCloudFiles.test.ts +59 -0
- package/src/AssistantCloudFiles.ts +39 -0
- package/src/AssistantCloudRuns.ts +12 -1
- package/src/AssistantCloudScores.test.ts +113 -0
- package/src/AssistantCloudScores.ts +61 -0
- package/src/AssistantCloudThreadMessages.test.ts +51 -0
- package/src/AssistantCloudThreadMessages.ts +33 -0
- package/src/AssistantCloudThreads.test.ts +21 -0
- package/src/AssistantCloudThreads.ts +21 -0
- package/src/CloudMessagePersistence.ts +65 -29
- package/src/cloudResponse.test.ts +34 -1
- package/src/cloudResponse.ts +30 -0
- package/src/index.ts +24 -1
- package/src/runTelemetry.test.ts +366 -0
- package/src/runTelemetry.ts +271 -16
- package/src/telemetry/index.test.ts +252 -0
- package/src/telemetry/index.ts +98 -0
- package/src/tests/AssistantCloud.test.ts +22 -0
- package/src/tests/AssistantCloudAPI.test.ts +44 -4
- package/src/tests/AssistantCloudAuthStrategy.test.ts +354 -11
- package/src/tests/CloudMessagePersistence.test.ts +208 -0
|
@@ -23,6 +23,13 @@ describe("AssistantCloud telemetry config", () => {
|
|
|
23
23
|
expect(createCloud({ enabled: false }).telemetry.enabled).toBe(false);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
it("can disable engagement events without disabling run reports", () => {
|
|
27
|
+
expect(createCloud({ events: false }).telemetry).toEqual({
|
|
28
|
+
enabled: true,
|
|
29
|
+
events: false,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
26
33
|
it("stays enabled when the config object carries an undefined enabled", () => {
|
|
27
34
|
const beforeReport: NonNullable<
|
|
28
35
|
AssistantCloudTelemetryConfig["beforeReport"]
|
|
@@ -36,4 +43,19 @@ describe("AssistantCloud telemetry config", () => {
|
|
|
36
43
|
expect(telemetry.enabled).toBe(true);
|
|
37
44
|
expect(telemetry.beforeReport).toBe(beforeReport);
|
|
38
45
|
});
|
|
46
|
+
|
|
47
|
+
it("preserves configured run report dimensions", () => {
|
|
48
|
+
expect(
|
|
49
|
+
createCloud({
|
|
50
|
+
release: "web-2026.09.08",
|
|
51
|
+
environment: "production",
|
|
52
|
+
tags: ["region:sg", "tier:paid"],
|
|
53
|
+
}).telemetry,
|
|
54
|
+
).toEqual({
|
|
55
|
+
enabled: true,
|
|
56
|
+
release: "web-2026.09.08",
|
|
57
|
+
environment: "production",
|
|
58
|
+
tags: ["region:sg", "tier:paid"],
|
|
59
|
+
});
|
|
60
|
+
});
|
|
39
61
|
});
|
|
@@ -143,7 +143,7 @@ describe("AssistantCloudAPI", () => {
|
|
|
143
143
|
);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
it("
|
|
146
|
+
it("preserves the current error shape for JSON error responses without a code", async () => {
|
|
147
147
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
148
148
|
ok: false,
|
|
149
149
|
status: 400,
|
|
@@ -167,16 +167,54 @@ describe("AssistantCloudAPI", () => {
|
|
|
167
167
|
expect(error.name).toBe("CloudAPIError");
|
|
168
168
|
expect(error.message).toBe("invalid request payload");
|
|
169
169
|
expect(error.status).toBe(400);
|
|
170
|
+
expect(error.code).toBeUndefined();
|
|
171
|
+
expect(error.details).toBeUndefined();
|
|
170
172
|
});
|
|
171
173
|
|
|
172
|
-
it("
|
|
174
|
+
it("exposes the plan-limit error code and details", async () => {
|
|
175
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
176
|
+
ok: false,
|
|
177
|
+
status: 402,
|
|
178
|
+
headers: new Headers(),
|
|
179
|
+
text: vi.fn().mockResolvedValue(
|
|
180
|
+
JSON.stringify({
|
|
181
|
+
error: "plan_limit_reached",
|
|
182
|
+
plan: "free",
|
|
183
|
+
period_end: "2026-10-01T00:00:00.000Z",
|
|
184
|
+
cap: 100,
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
});
|
|
188
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
189
|
+
|
|
190
|
+
const api = new AssistantCloudAPI({
|
|
191
|
+
apiKey: "test-key",
|
|
192
|
+
userId: "u-1",
|
|
193
|
+
workspaceId: "w-1",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const error = await api.makeRawRequest("/threads").catch((e) => e);
|
|
197
|
+
expect(error).toBeInstanceOf(CloudAPIError);
|
|
198
|
+
expect(error.message).toBe(
|
|
199
|
+
'Request failed with status 402, {"error":"plan_limit_reached","plan":"free","period_end":"2026-10-01T00:00:00.000Z","cap":100}',
|
|
200
|
+
);
|
|
201
|
+
expect(error.status).toBe(402);
|
|
202
|
+
expect(error.code).toBe("plan_limit_reached");
|
|
203
|
+
expect(error.details).toEqual({
|
|
204
|
+
plan: "free",
|
|
205
|
+
period_end: "2026-10-01T00:00:00.000Z",
|
|
206
|
+
cap: 100,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("exposes rate-limit error codes with empty details", async () => {
|
|
173
211
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
174
212
|
ok: false,
|
|
175
213
|
status: 429,
|
|
176
214
|
headers: new Headers(),
|
|
177
215
|
text: vi
|
|
178
216
|
.fn()
|
|
179
|
-
.mockResolvedValue(JSON.stringify({ error: "
|
|
217
|
+
.mockResolvedValue(JSON.stringify({ error: "rate_limited" })),
|
|
180
218
|
});
|
|
181
219
|
vi.stubGlobal("fetch", fetchMock);
|
|
182
220
|
|
|
@@ -189,9 +227,11 @@ describe("AssistantCloudAPI", () => {
|
|
|
189
227
|
const error = await api.makeRawRequest("/threads").catch((e) => e);
|
|
190
228
|
expect(error).toBeInstanceOf(CloudAPIError);
|
|
191
229
|
expect(error.message).toBe(
|
|
192
|
-
'Request failed with status 429, {"error":"
|
|
230
|
+
'Request failed with status 429, {"error":"rate_limited"}',
|
|
193
231
|
);
|
|
194
232
|
expect(error.status).toBe(429);
|
|
233
|
+
expect(error.code).toBe("rate_limited");
|
|
234
|
+
expect(error.details).toEqual({});
|
|
195
235
|
});
|
|
196
236
|
|
|
197
237
|
it("throws generic error with status for non-JSON error responses", async () => {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
1
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
3
|
import {
|
|
3
4
|
AssistantCloudAnonymousAuthStrategy,
|
|
4
5
|
AssistantCloudJWTAuthStrategy,
|
|
6
|
+
readAnonymousRefreshToken,
|
|
5
7
|
} from "../AssistantCloudAuthStrategy";
|
|
6
8
|
import { CloudResponseError } from "../cloudResponse";
|
|
7
9
|
|
|
@@ -43,6 +45,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
43
45
|
});
|
|
44
46
|
|
|
45
47
|
afterEach(() => {
|
|
48
|
+
vi.useRealTimers();
|
|
46
49
|
vi.unstubAllGlobals();
|
|
47
50
|
if (originalLocalStorageDescriptor) {
|
|
48
51
|
Object.defineProperty(
|
|
@@ -55,6 +58,73 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
55
58
|
}
|
|
56
59
|
});
|
|
57
60
|
|
|
61
|
+
it("reads the stored anonymous refresh token", () => {
|
|
62
|
+
const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
|
|
63
|
+
installLocalStorage({
|
|
64
|
+
getItem: (key) => values.get(key) ?? null,
|
|
65
|
+
setItem: (key, value) => {
|
|
66
|
+
values.set(key, value);
|
|
67
|
+
},
|
|
68
|
+
removeItem: (key) => {
|
|
69
|
+
values.delete(key);
|
|
70
|
+
},
|
|
71
|
+
} as Storage);
|
|
72
|
+
|
|
73
|
+
expect(readAnonymousRefreshToken(baseUrl)).toBe(refreshToken.token);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("reads the token stored for a base url given with a trailing slash", () => {
|
|
77
|
+
// The two-step cast below erases the contextual Storage typing the plain
|
|
78
|
+
// `as Storage` sites get, so this parameter needs its own annotation.
|
|
79
|
+
installLocalStorage({
|
|
80
|
+
getItem: (key: string) =>
|
|
81
|
+
key === refreshTokenKey ? JSON.stringify(refreshToken) : null,
|
|
82
|
+
setItem: vi.fn(),
|
|
83
|
+
removeItem: vi.fn(),
|
|
84
|
+
} as unknown as Storage);
|
|
85
|
+
|
|
86
|
+
expect(readAnonymousRefreshToken(`${baseUrl}/`)).toBe(refreshToken.token);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns null when no anonymous refresh token is stored", () => {
|
|
90
|
+
installLocalStorage({
|
|
91
|
+
getItem: () => null,
|
|
92
|
+
setItem: vi.fn(),
|
|
93
|
+
removeItem: vi.fn(),
|
|
94
|
+
} as unknown as Storage);
|
|
95
|
+
|
|
96
|
+
expect(readAnonymousRefreshToken(baseUrl)).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it.each([30_000, 0, -1])(
|
|
100
|
+
"returns null when the stored anonymous refresh token expires in %i ms",
|
|
101
|
+
(expiresIn) => {
|
|
102
|
+
vi.useFakeTimers();
|
|
103
|
+
const now = Date.UTC(2026, 8, 4, 12, 0, 0);
|
|
104
|
+
vi.setSystemTime(now);
|
|
105
|
+
const values = new Map([
|
|
106
|
+
[
|
|
107
|
+
refreshTokenKey,
|
|
108
|
+
JSON.stringify({
|
|
109
|
+
token: refreshToken.token,
|
|
110
|
+
expires_at: new Date(now + expiresIn).toISOString(),
|
|
111
|
+
}),
|
|
112
|
+
],
|
|
113
|
+
]);
|
|
114
|
+
installLocalStorage({
|
|
115
|
+
getItem: (key) => values.get(key) ?? null,
|
|
116
|
+
setItem: (key, value) => {
|
|
117
|
+
values.set(key, value);
|
|
118
|
+
},
|
|
119
|
+
removeItem: (key) => {
|
|
120
|
+
values.delete(key);
|
|
121
|
+
},
|
|
122
|
+
} as Storage);
|
|
123
|
+
|
|
124
|
+
expect(readAnonymousRefreshToken(baseUrl)).toBeNull();
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
58
128
|
it.each([
|
|
59
129
|
"2099-01-01",
|
|
60
130
|
"2099-01-01T00:00:00Z",
|
|
@@ -83,7 +153,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
83
153
|
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(nextRefreshToken));
|
|
84
154
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
85
155
|
`${baseUrl}/v1/auth/tokens/anonymous`,
|
|
86
|
-
{ method: "POST" },
|
|
156
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
87
157
|
);
|
|
88
158
|
});
|
|
89
159
|
|
|
@@ -106,6 +176,276 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
106
176
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
107
177
|
});
|
|
108
178
|
|
|
179
|
+
it("deduplicates anonymous token requests across strategy instances", async () => {
|
|
180
|
+
const values = new Map<string, string>();
|
|
181
|
+
installLocalStorage({
|
|
182
|
+
getItem: (key) => values.get(key) ?? null,
|
|
183
|
+
setItem: (key, value) => {
|
|
184
|
+
values.set(key, value);
|
|
185
|
+
},
|
|
186
|
+
removeItem: (key) => {
|
|
187
|
+
values.delete(key);
|
|
188
|
+
},
|
|
189
|
+
} as Storage);
|
|
190
|
+
const fetchMock = mockAnonymousTokenFetch();
|
|
191
|
+
const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
192
|
+
const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
193
|
+
|
|
194
|
+
await expect(
|
|
195
|
+
Promise.all([first.getAuthHeaders(), second.getAuthHeaders()]),
|
|
196
|
+
).resolves.toEqual([
|
|
197
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
198
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
199
|
+
]);
|
|
200
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
201
|
+
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("coordinates anonymous token requests across realms", async () => {
|
|
205
|
+
const values = new Map<string, string>();
|
|
206
|
+
installLocalStorage({
|
|
207
|
+
getItem: (key) => values.get(key) ?? null,
|
|
208
|
+
setItem: (key, value) => {
|
|
209
|
+
values.set(key, value);
|
|
210
|
+
},
|
|
211
|
+
removeItem: (key) => {
|
|
212
|
+
values.delete(key);
|
|
213
|
+
},
|
|
214
|
+
} as Storage);
|
|
215
|
+
let lockTail: Promise<unknown> = Promise.resolve();
|
|
216
|
+
const lockRequest = vi.fn(
|
|
217
|
+
(_name: string, callback: () => Promise<string | null>) => {
|
|
218
|
+
const request = lockTail.then(callback);
|
|
219
|
+
lockTail = request.then(
|
|
220
|
+
() => undefined,
|
|
221
|
+
() => undefined,
|
|
222
|
+
);
|
|
223
|
+
return request;
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
vi.stubGlobal("navigator", { locks: { request: lockRequest } });
|
|
227
|
+
const rotatedRefreshToken = { token: "r2", expires_at: "2099-01-01" };
|
|
228
|
+
const response = (refreshTokenValue: typeof refreshToken) => ({
|
|
229
|
+
ok: true,
|
|
230
|
+
json: vi.fn().mockResolvedValue({
|
|
231
|
+
access_token: accessToken,
|
|
232
|
+
refresh_token: refreshTokenValue,
|
|
233
|
+
}),
|
|
234
|
+
});
|
|
235
|
+
const fetchMock = vi
|
|
236
|
+
.fn()
|
|
237
|
+
.mockResolvedValueOnce(response(refreshToken))
|
|
238
|
+
.mockResolvedValueOnce(response(rotatedRefreshToken));
|
|
239
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
240
|
+
|
|
241
|
+
const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
242
|
+
vi.resetModules();
|
|
243
|
+
const { AssistantCloudAnonymousAuthStrategy: Second } =
|
|
244
|
+
await import("../AssistantCloudAuthStrategy");
|
|
245
|
+
|
|
246
|
+
await expect(
|
|
247
|
+
Promise.all([
|
|
248
|
+
first.getAuthHeaders(),
|
|
249
|
+
new Second(baseUrl).getAuthHeaders(),
|
|
250
|
+
]),
|
|
251
|
+
).resolves.toEqual([
|
|
252
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
253
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
254
|
+
]);
|
|
255
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
256
|
+
1,
|
|
257
|
+
`${baseUrl}/v1/auth/tokens/anonymous`,
|
|
258
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
259
|
+
);
|
|
260
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
261
|
+
2,
|
|
262
|
+
`${baseUrl}/v1/auth/tokens/refresh`,
|
|
263
|
+
{
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: { "Content-Type": "application/json" },
|
|
266
|
+
body: JSON.stringify({ refresh_token: refreshToken.token }),
|
|
267
|
+
signal: expect.any(AbortSignal),
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
expect(lockRequest).toHaveBeenCalledTimes(2);
|
|
271
|
+
expect(values.get(refreshTokenKey)).toBe(
|
|
272
|
+
JSON.stringify(rotatedRefreshToken),
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("retries shared anonymous token requests after a failure", async () => {
|
|
277
|
+
const values = new Map<string, string>();
|
|
278
|
+
installLocalStorage({
|
|
279
|
+
getItem: (key) => values.get(key) ?? null,
|
|
280
|
+
setItem: (key, value) => {
|
|
281
|
+
values.set(key, value);
|
|
282
|
+
},
|
|
283
|
+
removeItem: (key) => {
|
|
284
|
+
values.delete(key);
|
|
285
|
+
},
|
|
286
|
+
} as Storage);
|
|
287
|
+
const failure = new Error("authentication unavailable");
|
|
288
|
+
const fetchMock = vi
|
|
289
|
+
.fn()
|
|
290
|
+
.mockRejectedValueOnce(failure)
|
|
291
|
+
.mockResolvedValueOnce({
|
|
292
|
+
ok: true,
|
|
293
|
+
json: vi.fn().mockResolvedValue({
|
|
294
|
+
access_token: accessToken,
|
|
295
|
+
refresh_token: refreshToken,
|
|
296
|
+
}),
|
|
297
|
+
});
|
|
298
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
299
|
+
const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
300
|
+
const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
301
|
+
|
|
302
|
+
await Promise.all([
|
|
303
|
+
expect(first.getAuthHeaders()).rejects.toBe(failure),
|
|
304
|
+
expect(second.getAuthHeaders()).rejects.toBe(failure),
|
|
305
|
+
]);
|
|
306
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
307
|
+
|
|
308
|
+
await expect(
|
|
309
|
+
new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
|
|
310
|
+
).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
|
|
311
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("aborts timed out shared anonymous token requests before retrying", async () => {
|
|
315
|
+
vi.useFakeTimers();
|
|
316
|
+
const values = new Map<string, string>();
|
|
317
|
+
installLocalStorage({
|
|
318
|
+
getItem: (key) => values.get(key) ?? null,
|
|
319
|
+
setItem: (key, value) => {
|
|
320
|
+
values.set(key, value);
|
|
321
|
+
},
|
|
322
|
+
removeItem: (key) => {
|
|
323
|
+
values.delete(key);
|
|
324
|
+
},
|
|
325
|
+
} as Storage);
|
|
326
|
+
let requestSignal: AbortSignal | null | undefined;
|
|
327
|
+
const fetchMock = vi
|
|
328
|
+
.fn()
|
|
329
|
+
.mockImplementationOnce((_input: RequestInfo | URL, init?: RequestInit) =>
|
|
330
|
+
Promise.resolve({
|
|
331
|
+
ok: true,
|
|
332
|
+
json: () =>
|
|
333
|
+
new Promise<never>((_resolve, reject) => {
|
|
334
|
+
requestSignal = init?.signal;
|
|
335
|
+
requestSignal?.addEventListener(
|
|
336
|
+
"abort",
|
|
337
|
+
() => reject(requestSignal?.reason),
|
|
338
|
+
{ once: true },
|
|
339
|
+
);
|
|
340
|
+
}),
|
|
341
|
+
} as unknown as Response),
|
|
342
|
+
)
|
|
343
|
+
.mockResolvedValueOnce({
|
|
344
|
+
ok: true,
|
|
345
|
+
json: vi.fn().mockResolvedValue({
|
|
346
|
+
access_token: accessToken,
|
|
347
|
+
refresh_token: refreshToken,
|
|
348
|
+
}),
|
|
349
|
+
});
|
|
350
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
351
|
+
const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
352
|
+
const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
353
|
+
|
|
354
|
+
const firstRequest = expect(first.getAuthHeaders()).rejects.toThrow(
|
|
355
|
+
"Assistant Cloud anonymous token request timed out after 30000ms",
|
|
356
|
+
);
|
|
357
|
+
const secondRequest = expect(second.getAuthHeaders()).rejects.toThrow(
|
|
358
|
+
"Assistant Cloud anonymous token request timed out after 30000ms",
|
|
359
|
+
);
|
|
360
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
361
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
362
|
+
expect(requestSignal).toBeInstanceOf(AbortSignal);
|
|
363
|
+
|
|
364
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
365
|
+
await Promise.all([firstRequest, secondRequest]);
|
|
366
|
+
expect(requestSignal?.aborted).toBe(true);
|
|
367
|
+
expect(values.has(refreshTokenKey)).toBe(false);
|
|
368
|
+
|
|
369
|
+
await expect(
|
|
370
|
+
new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
|
|
371
|
+
).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
|
|
372
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("aborts timed out refresh requests without replacing the identity", async () => {
|
|
376
|
+
vi.useFakeTimers();
|
|
377
|
+
const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
|
|
378
|
+
installLocalStorage({
|
|
379
|
+
getItem: (key) => values.get(key) ?? null,
|
|
380
|
+
setItem: (key, value) => {
|
|
381
|
+
values.set(key, value);
|
|
382
|
+
},
|
|
383
|
+
removeItem: (key) => {
|
|
384
|
+
values.delete(key);
|
|
385
|
+
},
|
|
386
|
+
} as Storage);
|
|
387
|
+
let requestSignal: AbortSignal | null | undefined;
|
|
388
|
+
const rotatedRefreshToken = { token: "r2", expires_at: "2099-02-01" };
|
|
389
|
+
const fetchMock = vi
|
|
390
|
+
.fn()
|
|
391
|
+
.mockImplementationOnce(
|
|
392
|
+
(_input: RequestInfo | URL, init?: RequestInit) =>
|
|
393
|
+
new Promise<Response>((_resolve, reject) => {
|
|
394
|
+
requestSignal = init?.signal;
|
|
395
|
+
requestSignal?.addEventListener(
|
|
396
|
+
"abort",
|
|
397
|
+
() => reject(requestSignal?.reason),
|
|
398
|
+
{ once: true },
|
|
399
|
+
);
|
|
400
|
+
}),
|
|
401
|
+
)
|
|
402
|
+
.mockResolvedValueOnce({
|
|
403
|
+
ok: true,
|
|
404
|
+
json: vi.fn().mockResolvedValue({
|
|
405
|
+
access_token: accessToken,
|
|
406
|
+
refresh_token: rotatedRefreshToken,
|
|
407
|
+
}),
|
|
408
|
+
});
|
|
409
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
410
|
+
const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
411
|
+
|
|
412
|
+
const request = expect(strategy.getAuthHeaders()).rejects.toThrow(
|
|
413
|
+
"Assistant Cloud refresh token request timed out after 30000ms",
|
|
414
|
+
);
|
|
415
|
+
expect(requestSignal).toBeInstanceOf(AbortSignal);
|
|
416
|
+
|
|
417
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
418
|
+
await request;
|
|
419
|
+
expect(requestSignal?.aborted).toBe(true);
|
|
420
|
+
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
|
|
421
|
+
|
|
422
|
+
await expect(strategy.getAuthHeaders()).resolves.toEqual({
|
|
423
|
+
Authorization: `Bearer ${accessToken}`,
|
|
424
|
+
});
|
|
425
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
426
|
+
expect(values.get(refreshTokenKey)).toBe(
|
|
427
|
+
JSON.stringify(rotatedRefreshToken),
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("keeps anonymous token requests independent without localStorage", async () => {
|
|
432
|
+
delete (globalThis as { localStorage?: Storage }).localStorage;
|
|
433
|
+
const lockRequest = vi.fn();
|
|
434
|
+
vi.stubGlobal("navigator", { locks: { request: lockRequest } });
|
|
435
|
+
const fetchMock = mockAnonymousTokenFetch();
|
|
436
|
+
const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
437
|
+
const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
438
|
+
|
|
439
|
+
await expect(
|
|
440
|
+
Promise.all([first.getAuthHeaders(), second.getAuthHeaders()]),
|
|
441
|
+
).resolves.toEqual([
|
|
442
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
443
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
444
|
+
]);
|
|
445
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
446
|
+
expect(lockRequest).not.toHaveBeenCalled();
|
|
447
|
+
});
|
|
448
|
+
|
|
109
449
|
it("scopes anonymous refresh tokens by backend", async () => {
|
|
110
450
|
const secondBaseUrl = "https://other.example.com";
|
|
111
451
|
const values = new Map<string, string>();
|
|
@@ -140,20 +480,20 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
140
480
|
});
|
|
141
481
|
vi.stubGlobal("fetch", fetchMock);
|
|
142
482
|
|
|
143
|
-
await
|
|
144
|
-
|
|
145
|
-
secondBaseUrl,
|
|
146
|
-
)
|
|
483
|
+
await Promise.all([
|
|
484
|
+
new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
|
|
485
|
+
new AssistantCloudAnonymousAuthStrategy(secondBaseUrl).getAuthHeaders(),
|
|
486
|
+
]);
|
|
147
487
|
|
|
148
488
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
149
489
|
1,
|
|
150
490
|
`${baseUrl}/v1/auth/tokens/anonymous`,
|
|
151
|
-
{ method: "POST" },
|
|
491
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
152
492
|
);
|
|
153
493
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
154
494
|
2,
|
|
155
495
|
`${secondBaseUrl}/v1/auth/tokens/anonymous`,
|
|
156
|
-
{ method: "POST" },
|
|
496
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
157
497
|
);
|
|
158
498
|
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
|
|
159
499
|
expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
|
|
@@ -206,12 +546,13 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
206
546
|
method: "POST",
|
|
207
547
|
headers: { "Content-Type": "application/json" },
|
|
208
548
|
body: JSON.stringify({ refresh_token: refreshToken.token }),
|
|
549
|
+
signal: expect.any(AbortSignal),
|
|
209
550
|
},
|
|
210
551
|
);
|
|
211
552
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
212
553
|
2,
|
|
213
554
|
`${secondBaseUrl}/v1/auth/tokens/anonymous`,
|
|
214
|
-
{ method: "POST" },
|
|
555
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
215
556
|
);
|
|
216
557
|
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
|
|
217
558
|
expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
|
|
@@ -270,12 +611,13 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
270
611
|
method: "POST",
|
|
271
612
|
headers: { "Content-Type": "application/json" },
|
|
272
613
|
body: JSON.stringify({ refresh_token: scopedRefreshToken.token }),
|
|
614
|
+
signal: expect.any(AbortSignal),
|
|
273
615
|
},
|
|
274
616
|
);
|
|
275
617
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
276
618
|
2,
|
|
277
619
|
`${secondBaseUrl}/v1/auth/tokens/anonymous`,
|
|
278
|
-
{ method: "POST" },
|
|
620
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
279
621
|
);
|
|
280
622
|
expect(values.has("aui:refresh_token")).toBe(false);
|
|
281
623
|
expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
|
|
@@ -325,7 +667,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
325
667
|
const removeItem = vi.fn(() => {
|
|
326
668
|
throw new DOMException("blocked", "SecurityError");
|
|
327
669
|
});
|
|
328
|
-
installLocalStorage({ getItem, setItem, removeItem } as Storage);
|
|
670
|
+
installLocalStorage({ getItem, setItem, removeItem } as unknown as Storage);
|
|
329
671
|
mockAnonymousTokenFetch();
|
|
330
672
|
|
|
331
673
|
await expect(
|
|
@@ -470,6 +812,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
470
812
|
method: "POST",
|
|
471
813
|
headers: { "Content-Type": "application/json" },
|
|
472
814
|
body: JSON.stringify({ refresh_token: refreshToken.token }),
|
|
815
|
+
signal: expect.any(AbortSignal),
|
|
473
816
|
},
|
|
474
817
|
);
|
|
475
818
|
expect(setItem).not.toHaveBeenCalled();
|
|
@@ -538,7 +881,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
538
881
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
539
882
|
2,
|
|
540
883
|
`${baseUrl}/v1/auth/tokens/anonymous`,
|
|
541
|
-
{ method: "POST" },
|
|
884
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
542
885
|
);
|
|
543
886
|
expect(values.get(refreshTokenKey)).toBe(
|
|
544
887
|
JSON.stringify(replacementRefreshToken),
|