doover-js 0.6.8 → 0.6.9

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.
@@ -41,6 +41,12 @@ export declare abstract class DooverAuth {
41
41
  * Called before every HTTP request and before opening a websocket.
42
42
  */
43
43
  abstract ensureReady(): Promise<void>;
44
+ /**
45
+ * Called after an authenticated HTTP request receives a 401. Return true when
46
+ * the auth layer recovered (for example by forcing a token refresh) and the
47
+ * request should be retried once.
48
+ */
49
+ handleUnauthorized(): Promise<boolean>;
44
50
  /** Attach a profile and optional config manager for refresh persistence. */
45
51
  attachProfile(profile: AuthProfile, configManager?: AuthProfileStore): void;
46
52
  }
@@ -13,6 +13,14 @@ class DooverAuth {
13
13
  this.profile = null;
14
14
  this.configManager = null;
15
15
  }
16
+ /**
17
+ * Called after an authenticated HTTP request receives a 401. Return true when
18
+ * the auth layer recovered (for example by forcing a token refresh) and the
19
+ * request should be retried once.
20
+ */
21
+ async handleUnauthorized() {
22
+ return false;
23
+ }
16
24
  /** Attach a profile and optional config manager for refresh persistence. */
17
25
  attachProfile(profile, configManager) {
18
26
  this.profile = profile;
@@ -10,6 +10,7 @@ export declare class DooverTokenAuth extends DooverAuth {
10
10
  private authServerClientId;
11
11
  private fetchImpl;
12
12
  private refreshInFlight;
13
+ private authInvalid;
13
14
  constructor(options: {
14
15
  token?: string | null;
15
16
  tokenExpires?: Date | number | null;
@@ -28,9 +29,11 @@ export declare class DooverTokenAuth extends DooverAuth {
28
29
  setToken(token: string | null, tokenExpires?: Date | number | null): void;
29
30
  setRefreshToken(refreshToken: string | null): void;
30
31
  ensureReady(): Promise<void>;
32
+ handleUnauthorized(): Promise<boolean>;
31
33
  attachProfile(profile: AuthProfile, configManager?: AuthProfileStore): void;
32
34
  private needsRefresh;
33
35
  private refresh;
34
36
  private persistToProfile;
37
+ private markAuthInvalid;
35
38
  private resolveExpiry;
36
39
  }
@@ -10,6 +10,7 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
10
10
  constructor(options) {
11
11
  super();
12
12
  this.refreshInFlight = null;
13
+ this.authInvalid = false;
13
14
  this.token = options.token ?? null;
14
15
  this.tokenExpires = this.resolveExpiry(options.tokenExpires, options.token ?? null);
15
16
  this.refreshToken = options.refreshToken ?? null;
@@ -48,13 +49,22 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
48
49
  setToken(token, tokenExpires) {
49
50
  this.token = token;
50
51
  this.tokenExpires = this.resolveExpiry(tokenExpires, token);
52
+ if (token) {
53
+ this.authInvalid = false;
54
+ }
51
55
  this.persistToProfile();
52
56
  }
53
57
  setRefreshToken(refreshToken) {
54
58
  this.refreshToken = refreshToken;
59
+ if (refreshToken) {
60
+ this.authInvalid = false;
61
+ }
55
62
  this.persistToProfile();
56
63
  }
57
64
  async ensureReady() {
65
+ if (this.authInvalid) {
66
+ throw new errors_1.DooverAuthError("Authentication is invalid; sign in again");
67
+ }
58
68
  if (!this.needsRefresh()) {
59
69
  return;
60
70
  }
@@ -66,6 +76,23 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
66
76
  }
67
77
  return this.refreshInFlight;
68
78
  }
79
+ async handleUnauthorized() {
80
+ if (this.authInvalid) {
81
+ return false;
82
+ }
83
+ if (!this.refreshInFlight) {
84
+ this.refreshInFlight = this.refresh().finally(() => {
85
+ this.refreshInFlight = null;
86
+ });
87
+ }
88
+ try {
89
+ await this.refreshInFlight;
90
+ return true;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
69
96
  // ------------------------------------------------------------------
70
97
  // Profile attachment override — load refresh metadata from profile
71
98
  // ------------------------------------------------------------------
@@ -110,6 +137,7 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
110
137
  if (!this.authServerUrl ||
111
138
  !this.refreshToken ||
112
139
  !this.authServerClientId) {
140
+ this.markAuthInvalid();
113
141
  throw new errors_1.DooverAuthError("Cannot refresh token: missing authServerUrl, refreshToken, or authServerClientId");
114
142
  }
115
143
  const url = new URL(`${this.authServerUrl}/oauth2/token`);
@@ -129,12 +157,17 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
129
157
  throw new errors_1.DooverAuthError(`Token refresh request failed: ${err instanceof Error ? err.message : String(err)}`);
130
158
  }
131
159
  if (!response.ok) {
160
+ if (response.status >= 400 && response.status < 500) {
161
+ this.markAuthInvalid();
162
+ }
132
163
  throw new errors_1.DooverAuthError(`Token refresh failed with status ${response.status}`);
133
164
  }
134
165
  const body = (await response.json());
135
166
  if (!body.access_token) {
167
+ this.markAuthInvalid();
136
168
  throw new errors_1.DooverAuthError("Token refresh response did not contain an access_token");
137
169
  }
170
+ this.authInvalid = false;
138
171
  this.token = body.access_token;
139
172
  this.tokenExpires =
140
173
  typeof body.expires_in === "number"
@@ -154,9 +187,7 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
154
187
  this.profile.tokenExpires = this.tokenExpires
155
188
  ? this.tokenExpires.toISOString()
156
189
  : null;
157
- if (this.refreshToken) {
158
- this.profile.refreshToken = this.refreshToken;
159
- }
190
+ this.profile.refreshToken = this.refreshToken;
160
191
  if (this.configManager) {
161
192
  try {
162
193
  this.configManager.create(this.profile);
@@ -167,6 +198,13 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
167
198
  }
168
199
  }
169
200
  }
201
+ markAuthInvalid() {
202
+ this.authInvalid = true;
203
+ this.token = null;
204
+ this.tokenExpires = null;
205
+ this.refreshToken = null;
206
+ this.persistToProfile();
207
+ }
170
208
  resolveExpiry(explicit, token) {
171
209
  if (explicit instanceof Date) {
172
210
  return explicit;
@@ -554,7 +554,23 @@ class MultiplexClient {
554
554
  }
555
555
  const responses = await Promise.all(members.map((m) => m.client.agents.getMultiAgentMessages(...args)));
556
556
  const merged = (0, multiplex_merge_1.mergeMessages)(responses.map((r) => r.results), { order: "desc" });
557
- return { results: merged, count: merged.length };
557
+ // Union each member's per-agent resume cursors so a paginating caller
558
+ // keeps walking. If one agent lives in two members with different
559
+ // cursors, keep the lexically-highest (newest) — resuming from the
560
+ // older one would skip the newer member's tail; resuming from the
561
+ // newer just re-fetches a few messages that dedupe by id downstream.
562
+ const nextCursors = {};
563
+ for (const r of responses) {
564
+ for (const [id, cursor] of Object.entries(r.next_cursors ?? {})) {
565
+ if (!(id in nextCursors) || cursor > nextCursors[id])
566
+ nextCursors[id] = cursor;
567
+ }
568
+ }
569
+ return {
570
+ results: merged,
571
+ count: merged.length,
572
+ ...(Object.keys(nextCursors).length > 0 ? { next_cursors: nextCursors } : {}),
573
+ };
558
574
  },
559
575
  async getMultiAgentAggregates(channelName, ...rest) {
560
576
  const { args, sources } = self.splitSourcesOption([channelName, ...rest]);
@@ -71,6 +71,7 @@ export declare class GatewayClient {
71
71
  private send;
72
72
  private emit;
73
73
  private scheduleReconnect;
74
+ private connectInBackground;
74
75
  /** Exponential backoff with full-jitter, capped at RECONNECT_CAP_MS. */
75
76
  private computeReconnectDelay;
76
77
  private installLifecycleListeners;
@@ -142,7 +142,7 @@ class GatewayClient {
142
142
  diff_only: options?.diff_only ?? false,
143
143
  });
144
144
  if (!this.isConnected()) {
145
- void this.connect();
145
+ this.connectInBackground();
146
146
  return;
147
147
  }
148
148
  this.send({
@@ -222,7 +222,7 @@ class GatewayClient {
222
222
  }
223
223
  syncChannel(channel) {
224
224
  if (!this.isConnected()) {
225
- void this.connect();
225
+ this.connectInBackground();
226
226
  return;
227
227
  }
228
228
  this.send({
@@ -235,7 +235,7 @@ class GatewayClient {
235
235
  }
236
236
  sendOneShotMessage(channel, data) {
237
237
  if (!this.isConnected()) {
238
- void this.connect();
238
+ this.connectInBackground();
239
239
  return;
240
240
  }
241
241
  this.send({
@@ -382,9 +382,16 @@ class GatewayClient {
382
382
  this.reconnectAttempts += 1;
383
383
  this.reconnectTimer = setTimeout(() => {
384
384
  this.reconnectTimer = null;
385
- void this.connect();
385
+ this.connectInBackground();
386
386
  }, delay);
387
387
  }
388
+ connectInBackground() {
389
+ void this.connect().catch((err) => {
390
+ this.emit("wssError", {
391
+ message: err instanceof Error ? err.message : String(err),
392
+ });
393
+ });
394
+ }
388
395
  /** Exponential backoff with full-jitter, capped at RECONNECT_CAP_MS. */
389
396
  computeReconnectDelay() {
390
397
  const exp = Math.min(RECONNECT_CAP_MS, RECONNECT_BASE_MS * 2 ** this.reconnectAttempts);
@@ -413,7 +420,7 @@ class GatewayClient {
413
420
  clearTimeout(this.reconnectTimer);
414
421
  this.reconnectTimer = null;
415
422
  }
416
- void this.connect();
423
+ this.connectInBackground();
417
424
  }
418
425
  channelKey(channel) {
419
426
  return `${channel.agent_id}/${channel.name}`;
@@ -46,6 +46,9 @@ export declare class RestClient {
46
46
  setStats(stats: DooverStatsCollector | null): void;
47
47
  request<T>(options: RequestOptions): Promise<T>;
48
48
  private doRequest;
49
+ private prepareAuth;
50
+ private prepareRequest;
51
+ private sendRequest;
49
52
  get<T>(path: string, query?: RequestOptions["query"], baseUrl?: string): Promise<T>;
50
53
  post<T>(path: string, body?: RequestOptions["body"], query?: RequestOptions["query"], baseUrl?: string): Promise<T>;
51
54
  put<T>(path: string, body?: RequestOptions["body"], query?: RequestOptions["query"], baseUrl?: string): Promise<T>;
@@ -30,20 +30,45 @@ class RestClient {
30
30
  }
31
31
  }
32
32
  async doRequest(options) {
33
+ await this.prepareAuth();
34
+ const request = this.prepareRequest(options);
35
+ let response = await this.sendRequest(request);
36
+ let payload = await this.parseResponse(response);
37
+ if (response.status === 401 && this.auth) {
38
+ const recovered = await this.auth.handleUnauthorized();
39
+ if (recovered) {
40
+ await this.prepareAuth();
41
+ const retryRequest = this.prepareRequest(options);
42
+ response = await this.sendRequest(retryRequest);
43
+ payload = await this.parseResponse(response);
44
+ }
45
+ }
46
+ if (!response.ok) {
47
+ throw new errors_1.DooverApiError({
48
+ status: response.status,
49
+ body: payload,
50
+ url: request.url,
51
+ method: request.method,
52
+ message: typeof payload === "object" &&
53
+ payload !== null &&
54
+ "message" in payload &&
55
+ typeof payload.message === "string"
56
+ ? payload.message
57
+ : undefined,
58
+ });
59
+ }
60
+ return payload;
61
+ }
62
+ async prepareAuth() {
33
63
  if (this.auth) {
34
64
  await this.auth.ensureReady();
35
65
  }
66
+ }
67
+ prepareRequest(options) {
36
68
  const method = options.method ?? "GET";
37
69
  const url = this.buildUrl(options.baseUrl ?? this.config.dataRestUrl, options.path, options.query);
38
70
  const headers = new Headers(options.headers);
39
71
  const body = this.normalizeBody(options.body, headers);
40
- // Merge auth headers.
41
- if (this.auth) {
42
- const authHeaders = await this.auth.getHttpHeaders();
43
- for (const [key, value] of Object.entries(authHeaders)) {
44
- headers.set(key, value);
45
- }
46
- }
47
72
  if (!options.omitSharingHeader) {
48
73
  headers.set("X-Doover-Sharing", this.config.sharing);
49
74
  }
@@ -57,29 +82,24 @@ class RestClient {
57
82
  const credentials = this.auth
58
83
  ? this.auth.getFetchCredentials()
59
84
  : "include";
85
+ return { url, method, headers, body, credentials };
86
+ }
87
+ async sendRequest(request) {
88
+ // Merge auth headers immediately before fetch so a retry can use refreshed
89
+ // credentials without mutating the first request's Headers instance.
90
+ if (this.auth) {
91
+ const authHeaders = await this.auth.getHttpHeaders();
92
+ for (const [key, value] of Object.entries(authHeaders)) {
93
+ request.headers.set(key, value);
94
+ }
95
+ }
60
96
  const fetchImpl = this.config.fetchImpl ?? fetch;
61
- const response = await fetchImpl(url, {
62
- method,
63
- headers,
64
- body,
65
- credentials,
97
+ return fetchImpl(request.url, {
98
+ method: request.method,
99
+ headers: request.headers,
100
+ body: request.body,
101
+ credentials: request.credentials,
66
102
  });
67
- const payload = await this.parseResponse(response);
68
- if (!response.ok) {
69
- throw new errors_1.DooverApiError({
70
- status: response.status,
71
- body: payload,
72
- url,
73
- method,
74
- message: typeof payload === "object" &&
75
- payload !== null &&
76
- "message" in payload &&
77
- typeof payload.message === "string"
78
- ? payload.message
79
- : undefined,
80
- });
81
- }
82
- return payload;
83
103
  }
84
104
  get(path, query, baseUrl) {
85
105
  return this.request({ path, query, baseUrl });
@@ -27,6 +27,21 @@ export interface UseMultiAgentChannelMessagesOptions {
27
27
  fields?: string[];
28
28
  /** Optional first-page `before` cursor (snowflake id). */
29
29
  initialBefore?: string;
30
+ /**
31
+ * Keep paging older automatically until `hasNextPage` is false. Each page
32
+ * resumes only the agents that hit `agentMessageLimit`, using their own
33
+ * cursors, so a noisy agent doesn't drag the quiet ones back through
34
+ * messages they've already returned. Almost always combined with `after`
35
+ * — without a lower bound this walks every agent's channel back to its
36
+ * first message. `maxPages` caps the walk.
37
+ */
38
+ autoPaginate?: boolean;
39
+ /**
40
+ * Hard cap on pages fetched when `autoPaginate` is set. Defaults to 20.
41
+ * Guards against a window that's wider than `agentMessageLimit × maxPages`
42
+ * messages turning into an unbounded fetch loop.
43
+ */
44
+ maxPages?: number;
30
45
  /**
31
46
  * Optional lower-bound snowflake id. Forwarded server-side so each
32
47
  * agent's pagination stops on its own once it walks past the bound —
@@ -45,7 +60,16 @@ export interface UseMultiAgentChannelMessagesOptions {
45
60
  }
46
61
  interface Page<TData> {
47
62
  results: MessageStructure<TData>[];
48
- next?: string;
63
+ /**
64
+ * Per-agent resume cursors, keyed by agent id. Present (and non-empty)
65
+ * only for agents that hit the per-agent limit before draining the
66
+ * window. This is the canonical "there is more" signal.
67
+ */
68
+ next_cursors?: Record<string, string>;
69
+ /** Legacy single-cursor signal, for servers predating `next_cursors`. */
70
+ next?: string | null;
71
+ /** Legacy: agent ids that may have more older messages. */
72
+ at_limit_agent_ids?: string[];
49
73
  }
50
74
  export interface UseMultiAgentChannelMessagesResult<TData> extends Omit<UseInfiniteQueryResult<InfiniteData<Page<TData>>>, "data"> {
51
75
  messages: MessageStructure<TData>[];
@@ -30,6 +30,33 @@ function multiAgentChannelMessagesQueryKey(channelName, agentIds, sources, scope
30
30
  ? [...base, scopeKey]
31
31
  : base;
32
32
  }
33
+ function nextPageParam(lastPage) {
34
+ if (!lastPage)
35
+ return undefined;
36
+ const cursors = lastPage.next_cursors;
37
+ if (cursors) {
38
+ const entries = Object.entries(cursors);
39
+ // Present-but-empty means every agent drained its window: we're done.
40
+ if (entries.length === 0)
41
+ return undefined;
42
+ return {
43
+ agentIds: entries.map(([id]) => id),
44
+ agentBefore: entries.map(([, cursor]) => cursor),
45
+ };
46
+ }
47
+ // Legacy server: one global `before` for whichever agents were at limit.
48
+ // Those agents whose own cursor is older than `next` re-return a few
49
+ // messages we already hold — harmless, since we dedupe by id below.
50
+ if (typeof lastPage.next === "string" && lastPage.next) {
51
+ return {
52
+ before: lastPage.next,
53
+ ...(lastPage.at_limit_agent_ids?.length
54
+ ? { agentIds: lastPage.at_limit_agent_ids }
55
+ : {}),
56
+ };
57
+ }
58
+ return undefined;
59
+ }
33
60
  function useMultiAgentChannelMessages(channelName, agentIds, options) {
34
61
  const client = (0, context_1.useDooverClient)();
35
62
  const queryClient = (0, react_query_1.useQueryClient)();
@@ -40,6 +67,8 @@ function useMultiAgentChannelMessages(channelName, agentIds, options) {
40
67
  const initialBefore = options?.initialBefore;
41
68
  const sources = options?.sources;
42
69
  const after = options?.after;
70
+ const autoPaginate = options?.autoPaginate ?? false;
71
+ const maxPages = options?.maxPages ?? 20;
43
72
  const key = multiAgentChannelMessagesQueryKey(channelName, agentIds, sources, {
44
73
  after,
45
74
  fields,
@@ -86,12 +115,13 @@ function useMultiAgentChannelMessages(channelName, agentIds, options) {
86
115
  queryKey: key,
87
116
  enabled: agentIds.length > 0,
88
117
  staleTime: Infinity,
89
- initialPageParam: initialBefore,
90
- getNextPageParam: (lastPage) => lastPage?.next,
118
+ initialPageParam: (initialBefore ? { before: initialBefore } : {}),
119
+ getNextPageParam: nextPageParam,
91
120
  queryFn: async ({ pageParam }) => {
92
121
  const params = {
93
- agent_id: agentIds,
94
- ...(typeof pageParam === "string" ? { before: pageParam } : {}),
122
+ agent_id: pageParam.agentIds ?? agentIds,
123
+ ...(pageParam.before ? { before: pageParam.before } : {}),
124
+ ...(pageParam.agentBefore ? { agent_before: pageParam.agentBefore } : {}),
95
125
  ...(limit !== undefined ? { limit } : {}),
96
126
  ...(agentMessageLimit !== undefined
97
127
  ? { agent_message_limit: agentMessageLimit }
@@ -108,6 +138,36 @@ function useMultiAgentChannelMessages(channelName, agentIds, options) {
108
138
  return page;
109
139
  },
110
140
  });
111
- const messages = (0, react_1.useMemo)(() => (query.data?.pages ?? []).flatMap((p) => p.results), [query.data]);
141
+ (0, react_1.useEffect)(() => {
142
+ if (!autoPaginate)
143
+ return;
144
+ if (query.isFetching || !query.hasNextPage)
145
+ return;
146
+ if ((query.data?.pages.length ?? 0) >= maxPages)
147
+ return;
148
+ query.fetchNextPage();
149
+ }, [
150
+ autoPaginate,
151
+ maxPages,
152
+ query.isFetching,
153
+ query.hasNextPage,
154
+ query.fetchNextPage,
155
+ query.data?.pages.length,
156
+ ]);
157
+ const messages = (0, react_1.useMemo)(() => {
158
+ // The legacy single-cursor path (and a live push that races a page
159
+ // fetch) can hand us the same message twice; keep the first copy.
160
+ const seen = new Set();
161
+ const out = [];
162
+ for (const page of query.data?.pages ?? []) {
163
+ for (const message of page.results) {
164
+ if (seen.has(message.id))
165
+ continue;
166
+ seen.add(message.id);
167
+ out.push(message);
168
+ }
169
+ }
170
+ return out;
171
+ }, [query.data]);
112
172
  return { ...query, messages };
113
173
  }
@@ -6,9 +6,31 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const chai_1 = require("chai");
7
7
  const mocha_1 = require("mocha");
8
8
  const sinon_1 = __importDefault(require("sinon"));
9
+ const doover_auth_1 = require("../auth/doover-auth");
10
+ const errors_1 = require("../auth/errors");
9
11
  const gateway_client_1 = require("../gateway/gateway-client");
10
12
  const snowflake_1 = require("../utils/snowflake");
11
13
  const helpers_1 = require("./helpers");
14
+ class RejectingAuth extends doover_auth_1.DooverAuth {
15
+ async getHttpHeaders() {
16
+ return {};
17
+ }
18
+ getFetchCredentials() {
19
+ return "omit";
20
+ }
21
+ async prepareWebSocket(url) {
22
+ return { url };
23
+ }
24
+ setToken() {
25
+ // no-op for test
26
+ }
27
+ setRefreshToken() {
28
+ // no-op for test
29
+ }
30
+ async ensureReady() {
31
+ throw new errors_1.DooverAuthError("Authentication is invalid; sign in again");
32
+ }
33
+ }
12
34
  (0, mocha_1.describe)("GatewayClient", () => {
13
35
  (0, mocha_1.beforeEach)(() => {
14
36
  helpers_1.MockWebSocket.reset();
@@ -229,6 +251,27 @@ const helpers_1 = require("./helpers");
229
251
  (0, chai_1.expect)(cancelled.calledOnce).to.equal(true);
230
252
  (0, chai_1.expect)(client.getSession()).to.equal(null);
231
253
  });
254
+ (0, mocha_1.it)("reports background connect auth failures without creating a socket", async () => {
255
+ const client = new gateway_client_1.GatewayClient({
256
+ dataRestUrl: "https://api.example.com",
257
+ controlApiUrl: "https://control.example.com",
258
+ dataWssUrl: "wss://ws.example.com",
259
+ webSocketImpl: helpers_1.MockWebSocket,
260
+ }, new RejectingAuth());
261
+ const wssError = sinon_1.default.spy();
262
+ client.on("wssError", wssError);
263
+ client.subscribe({ agent_id: "a1", name: "c1" });
264
+ await Promise.resolve();
265
+ await Promise.resolve();
266
+ await Promise.resolve();
267
+ await Promise.resolve();
268
+ await Promise.resolve();
269
+ (0, chai_1.expect)(helpers_1.MockWebSocket.instances).to.have.length(0);
270
+ (0, chai_1.expect)(wssError.calledOnce).to.equal(true);
271
+ (0, chai_1.expect)(wssError.firstCall.args[0]).to.deep.equal({
272
+ message: "Authentication is invalid; sign in again",
273
+ });
274
+ });
232
275
  (0, mocha_1.it)("reconnects with exponential backoff + jitter on unexpected close", () => {
233
276
  // Make jitter deterministic: Math.random() → 1 means "wait the full backoff window".
234
277
  const randomStub = sinon_1.default.stub(Math, "random").returns(1);
@@ -6,9 +6,38 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const chai_1 = require("chai");
7
7
  const chai_as_promised_1 = __importDefault(require("chai-as-promised"));
8
8
  const mocha_1 = require("mocha");
9
+ const doover_auth_1 = require("../auth/doover-auth");
9
10
  const rest_client_1 = require("../http/rest-client");
10
11
  const helpers_1 = require("./helpers");
11
12
  (0, chai_1.use)(chai_as_promised_1.default);
13
+ class RefreshableAuth extends doover_auth_1.DooverAuth {
14
+ constructor() {
15
+ super(...arguments);
16
+ this.refreshCount = 0;
17
+ }
18
+ async getHttpHeaders() {
19
+ return { Authorization: `Bearer token-${this.refreshCount}` };
20
+ }
21
+ getFetchCredentials() {
22
+ return "omit";
23
+ }
24
+ async prepareWebSocket(url) {
25
+ return { url };
26
+ }
27
+ setToken() {
28
+ // no-op for test
29
+ }
30
+ setRefreshToken() {
31
+ // no-op for test
32
+ }
33
+ async ensureReady() {
34
+ // no-op for test
35
+ }
36
+ async handleUnauthorized() {
37
+ this.refreshCount += 1;
38
+ return true;
39
+ }
40
+ }
12
41
  (0, mocha_1.describe)("RestClient", () => {
13
42
  (0, mocha_1.beforeEach)(() => {
14
43
  (0, helpers_1.installSessionStorageMock)();
@@ -119,6 +148,42 @@ const helpers_1 = require("./helpers");
119
148
  (0, chai_1.expect)(error.method).to.equal("GET");
120
149
  });
121
150
  });
151
+ (0, mocha_1.it)("refreshes auth once and retries after a 401 response", async () => {
152
+ const auth = new RefreshableAuth();
153
+ const fetchMock = (0, helpers_1.createFetchMock)(() => {
154
+ if (fetchMock.callCount === 1) {
155
+ return (0, helpers_1.createJsonResponse)({ message: "expired" }, { status: 401 });
156
+ }
157
+ return (0, helpers_1.createJsonResponse)({ ok: true });
158
+ });
159
+ const client = new rest_client_1.RestClient({
160
+ dataRestUrl: "https://api.example.com",
161
+ controlApiUrl: "https://control.example.com",
162
+ dataWssUrl: "wss://ws.example.com",
163
+ fetchImpl: fetchMock,
164
+ }, auth);
165
+ await (0, chai_1.expect)(client.get("/needs-auth")).to.eventually.deep.equal({ ok: true });
166
+ (0, chai_1.expect)(auth.refreshCount).to.equal(1);
167
+ (0, chai_1.expect)(fetchMock.callCount).to.equal(2);
168
+ (0, chai_1.expect)((fetchMock.getCall(0).args[1]?.headers).get("Authorization")).to.equal("Bearer token-0");
169
+ (0, chai_1.expect)((fetchMock.getCall(1).args[1]?.headers).get("Authorization")).to.equal("Bearer token-1");
170
+ });
171
+ (0, mocha_1.it)("does not retry a 401 response when auth refresh cannot recover", async () => {
172
+ const auth = new RefreshableAuth();
173
+ auth.handleUnauthorized = async () => false;
174
+ const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({ message: "expired" }, { status: 401 }));
175
+ const client = new rest_client_1.RestClient({
176
+ dataRestUrl: "https://api.example.com",
177
+ controlApiUrl: "https://control.example.com",
178
+ dataWssUrl: "wss://ws.example.com",
179
+ fetchImpl: fetchMock,
180
+ }, auth);
181
+ await (0, chai_1.expect)(client.get("/needs-auth")).to.be.rejected.then((error) => {
182
+ (0, chai_1.expect)(error.status).to.equal(401);
183
+ (0, chai_1.expect)(error.message).to.equal("expired");
184
+ });
185
+ (0, chai_1.expect)(fetchMock.callCount).to.equal(1);
186
+ });
122
187
  (0, mocha_1.it)("returns text bodies when the response is not JSON", async () => {
123
188
  const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createTextResponse)("plain text"));
124
189
  const client = new rest_client_1.RestClient({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doover-js",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "TypeScript client for Doover.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",