doover-js 0.8.1 → 0.9.1

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 CHANGED
@@ -37,6 +37,43 @@ const client = new DooverClient({
37
37
  const channels = await client.viewer.getChannels({ agentId: "123" });
38
38
  ```
39
39
 
40
+ ### Cookie usage with session refresh (browser apps behind FusionAuth)
41
+
42
+ Pass `authServerUrl` — and no token material — to get a `CookieAuth` that can
43
+ renew the session itself. The client then POSTs the hosted-backend refresh
44
+ endpoint (`/app/refresh/` by default) when the access token has expired: before
45
+ a request goes out, and again if one comes back 401, replaying it once.
46
+
47
+ This exists because the FusionAuth React SDK's auto-refresh is a single
48
+ `setTimeout`. A tab the browser suspends (backgrounded, machine asleep) never
49
+ fires it, so it wakes with a dead access-token cookie and 401s on everything —
50
+ even though the refresh token itself is usually still valid.
51
+
52
+ ```ts
53
+ const client = new DooverClient({
54
+ dataRestUrl: "https://example.com/api",
55
+ controlApiUrl: "https://example.com/control",
56
+ dataWssUrl: "wss://example.com/gateway",
57
+ authServerUrl: "https://auth.example.com",
58
+ });
59
+ ```
60
+
61
+ `CookieAuth` also exposes the session state apps need for their own lifecycle
62
+ hooks — typically a proactive refresh when a tab regains visibility:
63
+
64
+ ```ts
65
+ const auth = client.auth as CookieAuth;
66
+
67
+ document.addEventListener("visibilitychange", () => {
68
+ if (document.visibilityState !== "visible") return;
69
+ if (!auth.isAccessTokenStale(5 * 60_000)) return;
70
+ void auth.refreshAccessToken();
71
+ });
72
+ ```
73
+
74
+ Without `authServerUrl`, `CookieAuth` behaves exactly as before: ambient
75
+ cookies, no refresh.
76
+
40
77
  ### Explicit token usage
41
78
 
42
79
  Pass a token directly to use bearer auth. The client will send `Authorization: Bearer <token>` on every HTTP request and use `credentials: "omit"`.
@@ -28,10 +28,11 @@ interface BuildAuthOptions extends AuthConfig {
28
28
  *
29
29
  * Rules:
30
30
  * 1. If `auth` is provided it is returned as-is.
31
- * 2. If any token-related field is present, build `DooverTokenAuth`.
31
+ * 2. If any token material is present, build `DooverTokenAuth`.
32
32
  * 3. If `profile` is an `AuthProfile`, use it to seed a `DooverTokenAuth`.
33
33
  * 4. If `profile` is a string, look it up via `configManager`.
34
- * 5. Otherwise fall back to `CookieAuth`.
34
+ * 5. Otherwise fall back to `CookieAuth`, refresh-capable when
35
+ * `authServerUrl` is known.
35
36
  */
36
37
  export declare function buildAuth(options: BuildAuthOptions): DooverAuth;
37
38
  export {};
@@ -12,15 +12,31 @@ const TOKEN_FIELDS = [
12
12
  "authServerUrl",
13
13
  "authServerClientId",
14
14
  ];
15
+ /**
16
+ * The subset of `TOKEN_FIELDS` that actually implies bearer-token auth.
17
+ *
18
+ * `authServerUrl` / `authServerClientId` describe *where* to refresh, not what
19
+ * we hold: on their own they now configure a refresh-capable `CookieAuth`
20
+ * (a browser session in an app that knows its auth server), which is far more
21
+ * useful than the tokenless `DooverTokenAuth` they used to produce — that
22
+ * instance could only ever throw "missing refreshToken" on first use.
23
+ */
24
+ const TOKEN_MATERIAL_FIELDS = [
25
+ "token",
26
+ "tokenExpires",
27
+ "refreshToken",
28
+ "refreshTokenId",
29
+ ];
15
30
  /**
16
31
  * Build a `DooverAuth` instance from the provided configuration.
17
32
  *
18
33
  * Rules:
19
34
  * 1. If `auth` is provided it is returned as-is.
20
- * 2. If any token-related field is present, build `DooverTokenAuth`.
35
+ * 2. If any token material is present, build `DooverTokenAuth`.
21
36
  * 3. If `profile` is an `AuthProfile`, use it to seed a `DooverTokenAuth`.
22
37
  * 4. If `profile` is a string, look it up via `configManager`.
23
- * 5. Otherwise fall back to `CookieAuth`.
38
+ * 5. Otherwise fall back to `CookieAuth`, refresh-capable when
39
+ * `authServerUrl` is known.
24
40
  */
25
41
  function buildAuth(options) {
26
42
  if (options.auth) {
@@ -48,11 +64,15 @@ function buildAuth(options) {
48
64
  resolvedProfile = options.profile;
49
65
  }
50
66
  // Determine whether we need token auth.
51
- const hasTokenInput = TOKEN_FIELDS.some((k) => options[k] !== undefined);
67
+ const hasTokenInput = TOKEN_MATERIAL_FIELDS.some((k) => options[k] !== undefined);
52
68
  const profileHasToken = resolvedProfile?.token != null;
53
69
  if (!hasTokenInput && !profileHasToken) {
54
- // No token data at all — fall back to cookie auth.
55
- const auth = new cookie_auth_1.CookieAuth();
70
+ // No token data at all — fall back to cookie auth, which can still renew
71
+ // the session when the app has told us where its auth server lives.
72
+ const auth = new cookie_auth_1.CookieAuth({
73
+ authServerUrl: options.authServerUrl ?? resolvedProfile?.authServerUrl ?? null,
74
+ fetchImpl: options.fetchImpl,
75
+ });
56
76
  if (resolvedProfile) {
57
77
  auth.attachProfile(resolvedProfile, options.configManager);
58
78
  }
@@ -1,11 +1,49 @@
1
1
  import { DooverAuth } from "./doover-auth";
2
+ export interface CookieAuthOptions {
3
+ /**
4
+ * Base URL of the auth server that serves the hosted-backend refresh
5
+ * endpoint. Required for any refresh behaviour; without it `CookieAuth`
6
+ * behaves exactly as it always has (no refresh, no recovery).
7
+ */
8
+ authServerUrl?: string | null;
9
+ /** Refresh endpoint path. Defaults to `/app/refresh/`. */
10
+ refreshPath?: string;
11
+ /** Name of the access-token expiry cookie. Defaults to `app.at_exp`. */
12
+ accessTokenExpiryCookieName?: string;
13
+ /** Override `fetch` (tests, native hosts). */
14
+ fetchImpl?: typeof fetch;
15
+ /** Override how the cookie string is read. Defaults to `document.cookie`. */
16
+ cookieReader?: () => string;
17
+ }
2
18
  /**
3
19
  * Cookie-based auth — the default browser strategy.
4
20
  *
5
21
  * Relies on ambient cookies (`credentials: "include"`) and does not add any
6
- * `Authorization` header. Token refresh is not applicable.
22
+ * `Authorization` header. The access token itself is httpOnly and invisible to
23
+ * JS; the only readable part of the session is a companion expiry cookie
24
+ * (`app.at_exp`).
25
+ *
26
+ * ## Why this refreshes
27
+ *
28
+ * The FusionAuth React SDK's auto-refresh is a single `setTimeout` per token.
29
+ * A browser that suspends the tab (backgrounded, laptop asleep) never fires it,
30
+ * so the tab wakes with an expired access-token cookie and every request 401s —
31
+ * even though the refresh token itself is usually still valid. Rather than
32
+ * leaving every browser app to work around that on its own, `CookieAuth`
33
+ * handles it whenever `authServerUrl` is configured: `ensureReady` renews an
34
+ * already-expired token before a request goes out (which also covers gateway
35
+ * reconnects, since `GatewayClient` awaits it before opening a socket), and
36
+ * `handleUnauthorized` lets `RestClient` replay a 401 once after a renewal.
7
37
  */
8
38
  export declare class CookieAuth extends DooverAuth {
39
+ private readonly authServerUrl;
40
+ private readonly refreshPath;
41
+ private readonly expiryCookieName;
42
+ private readonly fetchImpl;
43
+ private readonly cookieReader;
44
+ private refreshInFlight;
45
+ private lastAttempt;
46
+ constructor(options?: CookieAuthOptions);
9
47
  getHttpHeaders(): Promise<Record<string, string>>;
10
48
  getFetchCredentials(): RequestCredentials;
11
49
  prepareWebSocket(url: string, _canUseHeaders: boolean): Promise<{
@@ -14,5 +52,40 @@ export declare class CookieAuth extends DooverAuth {
14
52
  }>;
15
53
  setToken(_token: string | null, _tokenExpires?: Date | number | null): void;
16
54
  setRefreshToken(_refreshToken: string | null): void;
55
+ /**
56
+ * Renew the access token when it has already expired, so the request that
57
+ * follows isn't a guaranteed 401. A token that is merely close to expiry is
58
+ * left alone: requests must not block on a refresh they don't need, and
59
+ * `handleUnauthorized` is there for the race.
60
+ */
17
61
  ensureReady(): Promise<void>;
62
+ /**
63
+ * Called by `RestClient` after a 401. A successful refresh means the request
64
+ * is worth replaying once.
65
+ */
66
+ handleUnauthorized(): Promise<boolean>;
67
+ /** Expiry of the current access token, or `null` if there is no session. */
68
+ getAccessTokenExpiry(): Date | null;
69
+ /** True when a session cookie exists and has not yet expired. */
70
+ isAccessTokenValid(): boolean;
71
+ /**
72
+ * True when the access token expires within `thresholdMs` (or already has).
73
+ * Apps use this to decide whether a `visibilitychange` is worth a proactive
74
+ * refresh. False when there is no session at all — nothing to refresh.
75
+ */
76
+ isAccessTokenStale(thresholdMs?: number): boolean;
77
+ /**
78
+ * Refresh the access token, returning whether a valid one is now in place.
79
+ * Concurrent callers share one request, and the result is reused briefly so
80
+ * a burst of 401s costs a single round-trip.
81
+ *
82
+ * Safe to call directly — e.g. from a `visibilitychange` handler when
83
+ * {@link isAccessTokenStale} says the token is nearly up.
84
+ */
85
+ refreshAccessToken(): Promise<boolean>;
86
+ private canRefresh;
87
+ private performRefresh;
88
+ /** The expiry cookie in epoch milliseconds, or `null` when absent. */
89
+ private readExpiryCookie;
90
+ private readCookieString;
18
91
  }
@@ -2,13 +2,52 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CookieAuth = void 0;
4
4
  const doover_auth_1 = require("./doover-auth");
5
+ /** Default FusionAuth hosted-backend refresh endpoint. */
6
+ const DEFAULT_REFRESH_PATH = "/app/refresh/";
7
+ /** Default name of the cookie holding the access token's expiry (Unix seconds). */
8
+ const DEFAULT_EXPIRY_COOKIE = "app.at_exp";
9
+ /**
10
+ * How long a refresh result is reused. Collapses the burst of callers that all
11
+ * discover an expired token at the same moment (every queued request 401-ing
12
+ * after a tab wakes up), and — more importantly — stops a *failed* refresh from
13
+ * being retried in a tight loop.
14
+ */
15
+ const ATTEMPT_TTL_MS = 5000;
5
16
  /**
6
17
  * Cookie-based auth — the default browser strategy.
7
18
  *
8
19
  * Relies on ambient cookies (`credentials: "include"`) and does not add any
9
- * `Authorization` header. Token refresh is not applicable.
20
+ * `Authorization` header. The access token itself is httpOnly and invisible to
21
+ * JS; the only readable part of the session is a companion expiry cookie
22
+ * (`app.at_exp`).
23
+ *
24
+ * ## Why this refreshes
25
+ *
26
+ * The FusionAuth React SDK's auto-refresh is a single `setTimeout` per token.
27
+ * A browser that suspends the tab (backgrounded, laptop asleep) never fires it,
28
+ * so the tab wakes with an expired access-token cookie and every request 401s —
29
+ * even though the refresh token itself is usually still valid. Rather than
30
+ * leaving every browser app to work around that on its own, `CookieAuth`
31
+ * handles it whenever `authServerUrl` is configured: `ensureReady` renews an
32
+ * already-expired token before a request goes out (which also covers gateway
33
+ * reconnects, since `GatewayClient` awaits it before opening a socket), and
34
+ * `handleUnauthorized` lets `RestClient` replay a 401 once after a renewal.
10
35
  */
11
36
  class CookieAuth extends doover_auth_1.DooverAuth {
37
+ constructor(options = {}) {
38
+ super();
39
+ this.refreshInFlight = null;
40
+ this.lastAttempt = null;
41
+ this.authServerUrl = options.authServerUrl ?? null;
42
+ this.refreshPath = options.refreshPath ?? DEFAULT_REFRESH_PATH;
43
+ this.expiryCookieName =
44
+ options.accessTokenExpiryCookieName ?? DEFAULT_EXPIRY_COOKIE;
45
+ this.fetchImpl = options.fetchImpl ?? fetch;
46
+ this.cookieReader = options.cookieReader ?? null;
47
+ }
48
+ // ------------------------------------------------------------------
49
+ // DooverAuth interface
50
+ // ------------------------------------------------------------------
12
51
  async getHttpHeaders() {
13
52
  return {};
14
53
  }
@@ -20,13 +59,142 @@ class CookieAuth extends doover_auth_1.DooverAuth {
20
59
  return { url };
21
60
  }
22
61
  setToken(_token, _tokenExpires) {
23
- // No-op for cookie auth.
62
+ // No-op for cookie auth — the token lives in an httpOnly cookie.
24
63
  }
25
64
  setRefreshToken(_refreshToken) {
26
- // No-op for cookie auth.
65
+ // No-op for cookie auth — the refresh token lives in an httpOnly cookie.
27
66
  }
67
+ /**
68
+ * Renew the access token when it has already expired, so the request that
69
+ * follows isn't a guaranteed 401. A token that is merely close to expiry is
70
+ * left alone: requests must not block on a refresh they don't need, and
71
+ * `handleUnauthorized` is there for the race.
72
+ */
28
73
  async ensureReady() {
29
- // Nothing to prepare for cookie auth.
74
+ if (!this.canRefresh()) {
75
+ return;
76
+ }
77
+ if (this.isAccessTokenValid()) {
78
+ return;
79
+ }
80
+ // A failed refresh is not fatal here — let the request go out and be
81
+ // handled by `handleUnauthorized` / the caller's error handling.
82
+ await this.refreshAccessToken();
83
+ }
84
+ /**
85
+ * Called by `RestClient` after a 401. A successful refresh means the request
86
+ * is worth replaying once.
87
+ */
88
+ async handleUnauthorized() {
89
+ if (!this.canRefresh()) {
90
+ return false;
91
+ }
92
+ return this.refreshAccessToken();
93
+ }
94
+ // ------------------------------------------------------------------
95
+ // Public helpers — apps need these for their own lifecycle hooks
96
+ // ------------------------------------------------------------------
97
+ /** Expiry of the current access token, or `null` if there is no session. */
98
+ getAccessTokenExpiry() {
99
+ const expiryMs = this.readExpiryCookie();
100
+ return expiryMs == null ? null : new Date(expiryMs);
101
+ }
102
+ /** True when a session cookie exists and has not yet expired. */
103
+ isAccessTokenValid() {
104
+ const expiryMs = this.readExpiryCookie();
105
+ return expiryMs != null && expiryMs > Date.now();
106
+ }
107
+ /**
108
+ * True when the access token expires within `thresholdMs` (or already has).
109
+ * Apps use this to decide whether a `visibilitychange` is worth a proactive
110
+ * refresh. False when there is no session at all — nothing to refresh.
111
+ */
112
+ isAccessTokenStale(thresholdMs = 60000) {
113
+ const expiryMs = this.readExpiryCookie();
114
+ if (expiryMs == null) {
115
+ return false;
116
+ }
117
+ return expiryMs - Date.now() < thresholdMs;
118
+ }
119
+ /**
120
+ * Refresh the access token, returning whether a valid one is now in place.
121
+ * Concurrent callers share one request, and the result is reused briefly so
122
+ * a burst of 401s costs a single round-trip.
123
+ *
124
+ * Safe to call directly — e.g. from a `visibilitychange` handler when
125
+ * {@link isAccessTokenStale} says the token is nearly up.
126
+ */
127
+ async refreshAccessToken() {
128
+ if (!this.canRefresh()) {
129
+ return false;
130
+ }
131
+ if (this.refreshInFlight) {
132
+ return this.refreshInFlight;
133
+ }
134
+ // No expiry cookie means we were never logged in, or have since logged
135
+ // out. The refresh endpoint would just 400.
136
+ if (this.readExpiryCookie() == null) {
137
+ return false;
138
+ }
139
+ if (this.lastAttempt && Date.now() - this.lastAttempt.at < ATTEMPT_TTL_MS) {
140
+ return this.lastAttempt.result;
141
+ }
142
+ const inFlight = this.performRefresh().then((result) => {
143
+ this.lastAttempt = { result, at: Date.now() };
144
+ this.refreshInFlight = null;
145
+ return result;
146
+ });
147
+ this.refreshInFlight = inFlight;
148
+ return inFlight;
149
+ }
150
+ // ------------------------------------------------------------------
151
+ // Internal
152
+ // ------------------------------------------------------------------
153
+ canRefresh() {
154
+ return this.authServerUrl != null && this.authServerUrl !== "";
155
+ }
156
+ async performRefresh() {
157
+ const url = `${this.authServerUrl}${this.refreshPath}`;
158
+ try {
159
+ const response = await this.fetchImpl(url, {
160
+ method: "POST",
161
+ credentials: "include",
162
+ headers: { "Content-Type": "text/plain" },
163
+ });
164
+ return response.ok;
165
+ }
166
+ catch {
167
+ // Offline, DNS failure, blocked request — indistinguishable from here,
168
+ // and all mean "no new token".
169
+ return false;
170
+ }
171
+ }
172
+ /** The expiry cookie in epoch milliseconds, or `null` when absent. */
173
+ readExpiryCookie() {
174
+ const cookieString = this.readCookieString();
175
+ if (!cookieString) {
176
+ return null;
177
+ }
178
+ const match = cookieString.match(new RegExp(`(?:^|;\\s*)${escapeRegExp(this.expiryCookieName)}=([^;]+)`));
179
+ if (!match) {
180
+ return null;
181
+ }
182
+ // Stored as Unix *seconds* (matching the FusionAuth SDK's own parser);
183
+ // callers compare against Date.now(), so normalise to milliseconds.
184
+ const seconds = parseInt(decodeURIComponent(match[1]), 10);
185
+ return Number.isFinite(seconds) ? seconds * 1000 : null;
186
+ }
187
+ readCookieString() {
188
+ if (this.cookieReader) {
189
+ return this.cookieReader();
190
+ }
191
+ if (typeof document === "undefined") {
192
+ return null;
193
+ }
194
+ return document.cookie;
30
195
  }
31
196
  }
32
197
  exports.CookieAuth = CookieAuth;
198
+ function escapeRegExp(value) {
199
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
200
+ }
package/dist/index.d.ts CHANGED
@@ -38,6 +38,7 @@ export type { DataClient, AgentScope, DataClientStatus, DataClientConnectionStat
38
38
  export type { SourceProvenance, SourceProvenanceViaRest, SourceProvenanceViaGateway, } from "./types/provenance";
39
39
  export { DooverAuth } from "./auth/doover-auth";
40
40
  export { CookieAuth } from "./auth/cookie-auth";
41
+ export type { CookieAuthOptions } from "./auth/cookie-auth";
41
42
  export { DooverTokenAuth } from "./auth/doover-token-auth";
42
43
  export { AuthProfile } from "./auth/auth-profile";
43
44
  export type { AuthProfileData } from "./auth/auth-profile";
@@ -65,6 +65,12 @@ export declare class ChannelRangeStore {
65
65
  * between them, and claiming coverage across it would hide messages forever.
66
66
  */
67
67
  recordLive(message: MessageStructure): void;
68
+ /**
69
+ * Replace a message already held by one or more proven ranges. MessageUpdate
70
+ * carries the complete server representation, so both data and attachments
71
+ * supersede the cached create-time copy without changing range coverage.
72
+ */
73
+ recordUpdate(message: MessageStructure): void;
68
74
  /**
69
75
  * Stop trusting the live feed to extend coverage — call when the socket drops.
70
76
  * Messages sent while disconnected would leave a hole, so segments go back to
@@ -69,6 +69,18 @@ class ChannelRangeStore {
69
69
  if (id >= tip.hi)
70
70
  tip.hi = id + 1n;
71
71
  }
72
+ /**
73
+ * Replace a message already held by one or more proven ranges. MessageUpdate
74
+ * carries the complete server representation, so both data and attachments
75
+ * supersede the cached create-time copy without changing range coverage.
76
+ */
77
+ recordUpdate(message) {
78
+ for (const segment of this.segments) {
79
+ const index = segment.messages.findIndex((item) => item.id === message.id);
80
+ if (index >= 0)
81
+ segment.messages[index] = message;
82
+ }
83
+ }
72
84
  /**
73
85
  * Stop trusting the live feed to extend coverage — call when the socket drops.
74
86
  * Messages sent while disconnected would leave a hole, so segments go back to
@@ -75,8 +75,9 @@ export interface UseChannelMessagesResult<TData> extends Omit<UseInfiniteQueryRe
75
75
  }
76
76
  /**
77
77
  * Paginated infinite query over `DooverDataProvider.getMessages`, with live
78
- * `messageCreate` pushes prepended/appended to the newest page. The "next"
79
- * page fetches older messages (cursor = oldest-loaded message id).
78
+ * `messageCreate` pushes are appended to the newest page and `messageUpdate`
79
+ * pushes replace matching cached messages. The "next" page fetches older
80
+ * messages (cursor = oldest-loaded message id).
80
81
  */
81
82
  export declare function useChannelMessages<TData = unknown>(identifier: ChannelIdentifier, options?: UseChannelMessagesOptions): UseChannelMessagesResult<TData>;
82
83
  export {};
@@ -33,8 +33,9 @@ function channelMessagesQueryKey(agentId, channelName, fields, sources, anchor)
33
33
  const DEFAULT_PAGE_LIMIT = 10;
34
34
  /**
35
35
  * Paginated infinite query over `DooverDataProvider.getMessages`, with live
36
- * `messageCreate` pushes prepended/appended to the newest page. The "next"
37
- * page fetches older messages (cursor = oldest-loaded message id).
36
+ * `messageCreate` pushes are appended to the newest page and `messageUpdate`
37
+ * pushes replace matching cached messages. The "next" page fetches older
38
+ * messages (cursor = oldest-loaded message id).
38
39
  */
39
40
  function useChannelMessages(identifier, options) {
40
41
  const client = (0, context_1.useDooverClient)();
@@ -94,7 +95,40 @@ function useChannelMessages(identifier, options) {
94
95
  },
95
96
  // eslint-disable-next-line react-hooks/exhaustive-deps
96
97
  [queryClient, agentId, channelName, fields?.join(","), sources?.join(",")]);
97
- (0, useChannelSubscription_1.useChannelSubscription)(liveUpdates ? identifier : undefined, { onMessage });
98
+ const onMessageUpdate = (0, react_1.useCallback)((message) => {
99
+ // A filtered stream must not accept an update that does not belong to it.
100
+ // Newly qualifying messages are left for a refetch because inserting one
101
+ // into an arbitrary cursor page could invalidate that page's boundaries.
102
+ if (fields && fields.length > 0) {
103
+ const data = message.data;
104
+ if (!data ||
105
+ typeof data !== "object" ||
106
+ !fields.some((field) => field in data)) {
107
+ return;
108
+ }
109
+ }
110
+ if (rangeCacheable)
111
+ store.recordUpdate(message);
112
+ queryClient.setQueryData(key, (current) => {
113
+ if (!current)
114
+ return current;
115
+ let changed = false;
116
+ const typed = message;
117
+ const pages = current.pages.map((page) => page.map((item) => {
118
+ if (item.id !== typed.id)
119
+ return item;
120
+ changed = true;
121
+ return typed;
122
+ }));
123
+ return changed ? { ...current, pages } : current;
124
+ });
125
+ },
126
+ // eslint-disable-next-line react-hooks/exhaustive-deps
127
+ [queryClient, agentId, channelName, fields?.join(","), sources?.join(",")]);
128
+ (0, useChannelSubscription_1.useChannelSubscription)(liveUpdates ? identifier : undefined, {
129
+ onMessage,
130
+ onMessageUpdate,
131
+ });
98
132
  const query = (0, react_query_1.useInfiniteQuery)({
99
133
  queryKey: key,
100
134
  enabled: !!agentId && !!channelName,
@@ -50,6 +50,21 @@ class MemoryProfileStore {
50
50
  }
51
51
  }
52
52
  // ---------------------------------------------------------------------------
53
+ // Helpers: cookie-auth session state
54
+ // ---------------------------------------------------------------------------
55
+ /** An `app.at_exp` cookie string expiring `secondsFromNow` from now. */
56
+ function expiryCookie(secondsFromNow) {
57
+ return `app.at_exp=${Math.floor(Date.now() / 1000) + secondsFromNow}`;
58
+ }
59
+ /** A refresh-capable CookieAuth over a fixed cookie string. */
60
+ function cookieAuth(options) {
61
+ return new cookie_auth_1.CookieAuth({
62
+ authServerUrl: "https://auth.example.com",
63
+ fetchImpl: (options.fetchMock ?? (0, helpers_1.createFetchMock)()),
64
+ cookieReader: () => options.cookie,
65
+ });
66
+ }
67
+ // ---------------------------------------------------------------------------
53
68
  // CookieAuth
54
69
  // ---------------------------------------------------------------------------
55
70
  (0, mocha_1.describe)("CookieAuth", () => {
@@ -67,6 +82,127 @@ class MemoryProfileStore {
67
82
  const auth = new cookie_auth_1.CookieAuth();
68
83
  await auth.ensureReady(); // should not throw
69
84
  });
85
+ (0, mocha_1.it)("does not refresh when no authServerUrl is configured", async () => {
86
+ const fetchMock = (0, helpers_1.createFetchMock)();
87
+ const auth = new cookie_auth_1.CookieAuth({
88
+ fetchImpl: fetchMock,
89
+ cookieReader: () => expiryCookie(-60),
90
+ });
91
+ (0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
92
+ await auth.ensureReady();
93
+ (0, chai_1.expect)(fetchMock.callCount).to.equal(0);
94
+ });
95
+ // -- Session inspection ---------------------------------------------------
96
+ (0, mocha_1.it)("reads the expiry cookie", () => {
97
+ const auth = cookieAuth({ cookie: expiryCookie(3600) });
98
+ (0, chai_1.expect)(auth.isAccessTokenValid()).to.equal(true);
99
+ (0, chai_1.expect)(auth.isAccessTokenStale()).to.equal(false);
100
+ (0, chai_1.expect)(auth.getAccessTokenExpiry()).to.be.instanceOf(Date);
101
+ });
102
+ (0, mocha_1.it)("treats an expired cookie as invalid but refreshable", () => {
103
+ const auth = cookieAuth({ cookie: expiryCookie(-60) });
104
+ (0, chai_1.expect)(auth.isAccessTokenValid()).to.equal(false);
105
+ (0, chai_1.expect)(auth.isAccessTokenStale()).to.equal(true);
106
+ });
107
+ (0, mocha_1.it)("reports a near-expiry token as stale while still valid", () => {
108
+ const auth = cookieAuth({ cookie: expiryCookie(60) });
109
+ (0, chai_1.expect)(auth.isAccessTokenValid()).to.equal(true);
110
+ (0, chai_1.expect)(auth.isAccessTokenStale(5 * 60000)).to.equal(true);
111
+ });
112
+ (0, mocha_1.it)("reports no session when the cookie is absent", () => {
113
+ const auth = cookieAuth({ cookie: "other=1" });
114
+ (0, chai_1.expect)(auth.getAccessTokenExpiry()).to.equal(null);
115
+ (0, chai_1.expect)(auth.isAccessTokenValid()).to.equal(false);
116
+ // Nothing to refresh — must not read as "stale".
117
+ (0, chai_1.expect)(auth.isAccessTokenStale()).to.equal(false);
118
+ });
119
+ (0, mocha_1.it)("ignores a non-numeric expiry cookie", () => {
120
+ const auth = cookieAuth({ cookie: "app.at_exp=garbage" });
121
+ (0, chai_1.expect)(auth.getAccessTokenExpiry()).to.equal(null);
122
+ (0, chai_1.expect)(auth.isAccessTokenValid()).to.equal(false);
123
+ });
124
+ (0, mocha_1.it)("honours a custom expiry cookie name", () => {
125
+ const auth = new cookie_auth_1.CookieAuth({
126
+ authServerUrl: "https://auth.example.com",
127
+ accessTokenExpiryCookieName: "custom.exp",
128
+ cookieReader: () => `custom.exp=${Math.floor(Date.now() / 1000) + 3600}`,
129
+ });
130
+ (0, chai_1.expect)(auth.isAccessTokenValid()).to.equal(true);
131
+ });
132
+ // -- Refresh behaviour ----------------------------------------------------
133
+ (0, mocha_1.it)("refreshes an expired token via the hosted-backend endpoint", async () => {
134
+ const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
135
+ const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
136
+ (0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(true);
137
+ (0, chai_1.expect)(fetchMock.callCount).to.equal(1);
138
+ (0, chai_1.expect)(fetchMock.firstCall.args[0]).to.equal("https://auth.example.com/app/refresh/");
139
+ const init = fetchMock.firstCall.args[1];
140
+ (0, chai_1.expect)(init.method).to.equal("POST");
141
+ (0, chai_1.expect)(init.credentials).to.equal("include");
142
+ });
143
+ (0, mocha_1.it)("honours a custom refresh path", async () => {
144
+ const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
145
+ const auth = new cookie_auth_1.CookieAuth({
146
+ authServerUrl: "https://auth.example.com",
147
+ refreshPath: "/proxy/renew",
148
+ fetchImpl: fetchMock,
149
+ cookieReader: () => expiryCookie(-60),
150
+ });
151
+ await auth.refreshAccessToken();
152
+ (0, chai_1.expect)(fetchMock.firstCall.args[0]).to.equal("https://auth.example.com/proxy/renew");
153
+ });
154
+ (0, mocha_1.it)("reports failure when the refresh endpoint rejects", async () => {
155
+ const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}, { status: 400 }));
156
+ const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
157
+ (0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
158
+ });
159
+ (0, mocha_1.it)("reports failure when the refresh request throws", async () => {
160
+ const fetchMock = sinon_1.default.stub().rejects(new Error("offline"));
161
+ const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
162
+ (0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
163
+ });
164
+ (0, mocha_1.it)("skips the refresh call when there is no session cookie", async () => {
165
+ const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
166
+ const auth = cookieAuth({ cookie: "", fetchMock });
167
+ (0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
168
+ (0, chai_1.expect)(fetchMock.callCount).to.equal(0);
169
+ });
170
+ (0, mocha_1.it)("collapses concurrent refreshes onto one request", async () => {
171
+ const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
172
+ const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
173
+ const results = await Promise.all([
174
+ auth.handleUnauthorized(),
175
+ auth.handleUnauthorized(),
176
+ auth.handleUnauthorized(),
177
+ ]);
178
+ (0, chai_1.expect)(results).to.deep.equal([true, true, true]);
179
+ (0, chai_1.expect)(fetchMock.callCount).to.equal(1);
180
+ });
181
+ (0, mocha_1.it)("reuses a recent failure instead of retrying in a loop", async () => {
182
+ const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}, { status: 400 }));
183
+ const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
184
+ (0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
185
+ (0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
186
+ (0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
187
+ (0, chai_1.expect)(fetchMock.callCount).to.equal(1);
188
+ });
189
+ (0, mocha_1.it)("ensureReady refreshes an expired token before the request goes out", async () => {
190
+ const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
191
+ const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
192
+ await auth.ensureReady();
193
+ (0, chai_1.expect)(fetchMock.callCount).to.equal(1);
194
+ });
195
+ (0, mocha_1.it)("ensureReady leaves a valid token alone", async () => {
196
+ const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
197
+ const auth = cookieAuth({ cookie: expiryCookie(30), fetchMock });
198
+ await auth.ensureReady();
199
+ (0, chai_1.expect)(fetchMock.callCount).to.equal(0);
200
+ });
201
+ (0, mocha_1.it)("ensureReady does not reject when the refresh fails", async () => {
202
+ const fetchMock = sinon_1.default.stub().rejects(new Error("offline"));
203
+ const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
204
+ await auth.ensureReady(); // should not throw
205
+ });
70
206
  });
71
207
  // ---------------------------------------------------------------------------
72
208
  // DooverTokenAuth
@@ -310,6 +446,24 @@ class MemoryProfileStore {
310
446
  (0, mocha_1.it)("builds CookieAuth when no token-related input is present", () => {
311
447
  (0, chai_1.expect)((0, build_auth_1.buildAuth)({})).to.be.instanceOf(cookie_auth_1.CookieAuth);
312
448
  });
449
+ (0, mocha_1.it)("builds a refresh-capable CookieAuth from authServerUrl alone", async () => {
450
+ const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
451
+ const auth = (0, build_auth_1.buildAuth)({
452
+ authServerUrl: "https://auth.example.com",
453
+ fetchImpl: fetchMock,
454
+ });
455
+ // authServerUrl says where to refresh; it is not token material, so this
456
+ // stays cookie auth rather than becoming a tokenless DooverTokenAuth.
457
+ (0, chai_1.expect)(auth).to.be.instanceOf(cookie_auth_1.CookieAuth);
458
+ (0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false); // no session cookie
459
+ });
460
+ (0, mocha_1.it)("still builds DooverTokenAuth when token material accompanies authServerUrl", () => {
461
+ const auth = (0, build_auth_1.buildAuth)({
462
+ authServerUrl: "https://auth.example.com",
463
+ refreshToken: "rt",
464
+ });
465
+ (0, chai_1.expect)(auth).to.be.instanceOf(doover_token_auth_1.DooverTokenAuth);
466
+ });
313
467
  (0, mocha_1.it)("builds DooverTokenAuth when token is provided", () => {
314
468
  const auth = (0, build_auth_1.buildAuth)({ token: "tok" });
315
469
  (0, chai_1.expect)(auth).to.be.instanceOf(doover_token_auth_1.DooverTokenAuth);
@@ -506,6 +660,39 @@ class MemoryProfileStore {
506
660
  (0, chai_1.expect)(headers.get("Authorization")).to.equal(null);
507
661
  (0, chai_1.expect)(init?.credentials).to.equal("include");
508
662
  });
663
+ (0, mocha_1.it)("refreshes and replays a 401 with refresh-capable cookie auth", async () => {
664
+ let itemsCalls = 0;
665
+ const fetchMock = (0, helpers_1.createFetchMock)((url) => {
666
+ if (url === "https://auth.example.com/app/refresh/") {
667
+ return (0, helpers_1.createJsonResponse)({});
668
+ }
669
+ itemsCalls += 1;
670
+ // First attempt uses the dead cookie; the replay succeeds.
671
+ return itemsCalls === 1
672
+ ? (0, helpers_1.createJsonResponse)({ detail: "expired" }, { status: 401 })
673
+ : (0, helpers_1.createJsonResponse)({ items: [] });
674
+ });
675
+ const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
676
+ const client = new rest_client_1.RestClient({
677
+ dataRestUrl: "https://api.example.com",
678
+ controlApiUrl: "https://control.example.com",
679
+ dataWssUrl: "wss://ws.example.com",
680
+ fetchImpl: fetchMock,
681
+ }, auth);
682
+ const result = await client.get("/items");
683
+ (0, chai_1.expect)(result).to.deep.equal({ items: [] });
684
+ (0, chai_1.expect)(itemsCalls).to.equal(2);
685
+ });
686
+ (0, mocha_1.it)("surfaces the 401 when cookie auth cannot refresh", async () => {
687
+ const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({ detail: "expired" }, { status: 401 }));
688
+ const client = new rest_client_1.RestClient({
689
+ dataRestUrl: "https://api.example.com",
690
+ controlApiUrl: "https://control.example.com",
691
+ dataWssUrl: "wss://ws.example.com",
692
+ fetchImpl: fetchMock,
693
+ }, new cookie_auth_1.CookieAuth());
694
+ await (0, chai_1.expect)(client.get("/items")).to.be.rejected;
695
+ });
509
696
  (0, mocha_1.it)("uses credentials: include when no auth is provided (backward compat)", async () => {
510
697
  const fetchMock = (0, helpers_1.createFetchMock)();
511
698
  const client = new rest_client_1.RestClient({
@@ -95,6 +95,22 @@ const ids = (messages) => (messages ?? []).map((m) => m.id);
95
95
  (0, chai_1.expect)(store.snapshot()).to.have.length(1);
96
96
  (0, chai_1.expect)(ids(store.read(idAt(0), 10))).to.deep.equal(ids(page));
97
97
  });
98
+ (0, mocha_1.it)("replaces an updated message without changing range coverage", () => {
99
+ const page = makeMessages(5, 20);
100
+ store.record({ before: idAt(0), limit: 5, page });
101
+ const before = store.snapshot();
102
+ const updated = {
103
+ ...page[2],
104
+ data: { i: 2, analysed_by: "detector" },
105
+ attachments: [{ id: "updated-image" }],
106
+ };
107
+ store.recordUpdate(updated);
108
+ const read = store.read(idAt(0), 5);
109
+ (0, chai_1.expect)(read[2]).to.equal(updated);
110
+ (0, chai_1.expect)(ids(read)).to.deep.equal(ids(page));
111
+ (0, chai_1.expect)(store.snapshot().map(({ lo, hi, atStart }) => ({ lo, hi, atStart })))
112
+ .to.deep.equal(before.map(({ lo, hi, atStart }) => ({ lo, hi, atStart })));
113
+ });
98
114
  (0, mocha_1.it)("keeps disjoint ranges apart rather than claiming the gap", () => {
99
115
  const ancient = makeMessages(5, 500);
100
116
  const recent = makeMessages(5, 20);
@@ -208,7 +208,7 @@ function wrapper(client) {
208
208
  const cached = queryClient.getQueryData((0, react_2.channelAggregateQueryKey)("a1", "ui_cmds"));
209
209
  (0, chai_1.expect)(cached).to.deep.include({ data: { y: 2 }, attachments: [] });
210
210
  });
211
- (0, mocha_1.it)("useChannelMessages paginates and prepends on live messageCreate", async () => {
211
+ (0, mocha_1.it)("useChannelMessages applies live creates and updates", async () => {
212
212
  const oldId = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2025-12-31T23:59:00.000Z"));
213
213
  const newId = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2026-01-01T00:00:01.000Z"));
214
214
  const liveId = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2026-01-01T00:00:02.000Z"));
@@ -269,6 +269,28 @@ function wrapper(client) {
269
269
  newId,
270
270
  liveId,
271
271
  ]));
272
+ await (0, react_1.act)(async () => {
273
+ helpers_1.MockWebSocket.instances[0].receive({
274
+ op: 0,
275
+ t: "MessageUpdate",
276
+ d: {
277
+ message: {
278
+ id: newId,
279
+ author_id: "u1",
280
+ channel: { agent_id: "a1", name: "notes" },
281
+ data: { v: 22, analysed_by: "detector" },
282
+ attachments: [{ id: "analysed-image" }],
283
+ },
284
+ request_data: {},
285
+ },
286
+ });
287
+ });
288
+ await (0, react_1.waitFor)(() => {
289
+ const updated = result.current.messages.find((message) => message.id === newId);
290
+ (0, chai_1.expect)(updated?.data).to.deep.equal({ v: 22, analysed_by: "detector" });
291
+ (0, chai_1.expect)(updated?.attachments).to.have.length(1);
292
+ (0, chai_1.expect)(updated?.attachments?.[0]).to.deep.include({ id: "analysed-image" });
293
+ });
272
294
  });
273
295
  (0, mocha_1.it)("useChannelMessage seeds via REST and patches on MessageUpdate", async () => {
274
296
  const messageId = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2026-01-01T00:00:00.000Z"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doover-js",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "TypeScript client for Doover.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",