@abloatai/ablo 0.7.0 → 0.9.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.
Files changed (181) hide show
  1. package/CHANGELOG.md +72 -1
  2. package/README.md +80 -66
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +179 -5
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/SyncEngineContext.d.ts +2 -1
  8. package/dist/SyncEngineContext.js +5 -3
  9. package/dist/agent/session.js +6 -5
  10. package/dist/ai-sdk/coordination-context.js +4 -0
  11. package/dist/ai-sdk/index.d.ts +56 -47
  12. package/dist/ai-sdk/index.js +56 -47
  13. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  14. package/dist/ai-sdk/intent-broadcast.js +11 -4
  15. package/dist/ai-sdk/wrap.d.ts +14 -11
  16. package/dist/ai-sdk/wrap.js +11 -13
  17. package/dist/auth/credentialSource.d.ts +34 -0
  18. package/dist/auth/credentialSource.js +63 -0
  19. package/dist/auth/index.d.ts +2 -22
  20. package/dist/auth/index.js +26 -36
  21. package/dist/auth/schemas.d.ts +35 -0
  22. package/dist/auth/schemas.js +53 -0
  23. package/dist/client/Ablo.d.ts +259 -33
  24. package/dist/client/Ablo.js +276 -73
  25. package/dist/client/ApiClient.d.ts +52 -4
  26. package/dist/client/ApiClient.js +236 -66
  27. package/dist/client/auth.d.ts +21 -2
  28. package/dist/client/auth.js +77 -5
  29. package/dist/client/createInternalComponents.d.ts +2 -0
  30. package/dist/client/createInternalComponents.js +8 -1
  31. package/dist/client/createModelProxy.d.ts +187 -79
  32. package/dist/client/createModelProxy.js +203 -68
  33. package/dist/client/httpClient.d.ts +71 -0
  34. package/dist/client/httpClient.js +69 -0
  35. package/dist/client/identity.d.ts +2 -6
  36. package/dist/client/identity.js +63 -11
  37. package/dist/client/index.d.ts +1 -0
  38. package/dist/client/index.js +1 -0
  39. package/dist/client/registerDataSource.d.ts +19 -0
  40. package/dist/client/registerDataSource.js +59 -0
  41. package/dist/client/validateAbloOptions.d.ts +2 -1
  42. package/dist/client/validateAbloOptions.js +8 -7
  43. package/dist/core/DatabaseManager.js +30 -2
  44. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  45. package/dist/core/openIDBWithTimeout.js +88 -1
  46. package/dist/errorCodes.d.ts +92 -1
  47. package/dist/errorCodes.js +139 -7
  48. package/dist/errors.d.ts +54 -3
  49. package/dist/errors.js +192 -44
  50. package/dist/index.d.ts +23 -10
  51. package/dist/index.js +21 -8
  52. package/dist/keys/index.d.ts +76 -0
  53. package/dist/keys/index.js +171 -0
  54. package/dist/mutators/UndoManager.d.ts +86 -50
  55. package/dist/mutators/UndoManager.js +129 -22
  56. package/dist/mutators/inverseOp.d.ts +129 -0
  57. package/dist/mutators/inverseOp.js +74 -0
  58. package/dist/mutators/readerActions.d.ts +1 -1
  59. package/dist/mutators/undoApply.d.ts +42 -0
  60. package/dist/mutators/undoApply.js +143 -0
  61. package/dist/query/client.d.ts +10 -9
  62. package/dist/query/client.js +22 -14
  63. package/dist/react/AbloProvider.d.ts +23 -101
  64. package/dist/react/AbloProvider.js +61 -103
  65. package/dist/react/ClientSideSuspense.d.ts +1 -1
  66. package/dist/react/DefaultFallback.d.ts +1 -1
  67. package/dist/react/SyncGroupProvider.d.ts +1 -1
  68. package/dist/react/index.d.ts +3 -2
  69. package/dist/react/index.js +3 -2
  70. package/dist/react/useAblo.d.ts +4 -4
  71. package/dist/react/useAblo.js +10 -5
  72. package/dist/react/useCurrentUserId.d.ts +1 -1
  73. package/dist/react/useCurrentUserId.js +1 -1
  74. package/dist/react/useMutators.js +19 -12
  75. package/dist/react/useReactive.js +16 -3
  76. package/dist/schema/ddl.d.ts +26 -3
  77. package/dist/schema/ddl.js +152 -4
  78. package/dist/schema/index.d.ts +4 -0
  79. package/dist/schema/index.js +12 -0
  80. package/dist/schema/model.d.ts +11 -0
  81. package/dist/schema/model.js +2 -0
  82. package/dist/schema/openapi.d.ts +28 -0
  83. package/dist/schema/openapi.js +118 -0
  84. package/dist/schema/plane.d.ts +23 -0
  85. package/dist/schema/plane.js +19 -0
  86. package/dist/schema/relation.d.ts +20 -0
  87. package/dist/schema/serialize.d.ts +7 -3
  88. package/dist/schema/serialize.js +6 -2
  89. package/dist/schema/sync-delta-row.d.ts +157 -0
  90. package/dist/schema/sync-delta-row.js +102 -0
  91. package/dist/schema/sync-delta-wire.d.ts +180 -0
  92. package/dist/schema/sync-delta-wire.js +102 -0
  93. package/dist/server/adapter.d.ts +156 -0
  94. package/dist/server/adapter.js +19 -0
  95. package/dist/server/commit.d.ts +82 -0
  96. package/dist/server/commit.js +1 -0
  97. package/dist/server/index.d.ts +14 -0
  98. package/dist/server/index.js +1 -0
  99. package/dist/server/next.d.ts +51 -0
  100. package/dist/server/next.js +47 -0
  101. package/dist/server/read-config.d.ts +60 -0
  102. package/dist/server/read-config.js +8 -0
  103. package/dist/server/storage-mode.d.ts +17 -0
  104. package/dist/server/storage-mode.js +12 -0
  105. package/dist/source/adapter.d.ts +59 -0
  106. package/dist/source/adapter.js +19 -0
  107. package/dist/source/adapters/drizzle.d.ts +34 -0
  108. package/dist/source/adapters/drizzle.js +147 -0
  109. package/dist/source/adapters/memory.d.ts +12 -0
  110. package/dist/source/adapters/memory.js +114 -0
  111. package/dist/source/adapters/prisma.d.ts +57 -0
  112. package/dist/source/adapters/prisma.js +199 -0
  113. package/dist/source/conformance.d.ts +32 -0
  114. package/dist/source/conformance.js +134 -0
  115. package/dist/source/contract.d.ts +143 -0
  116. package/dist/source/contract.js +98 -0
  117. package/dist/source/index.d.ts +61 -10
  118. package/dist/source/index.js +98 -0
  119. package/dist/source/next.d.ts +33 -0
  120. package/dist/source/next.js +26 -0
  121. package/dist/sync/BootstrapHelper.d.ts +10 -0
  122. package/dist/sync/BootstrapHelper.js +56 -42
  123. package/dist/sync/ConnectionManager.d.ts +57 -1
  124. package/dist/sync/ConnectionManager.js +186 -11
  125. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  126. package/dist/sync/HydrationCoordinator.js +241 -41
  127. package/dist/sync/NetworkProbe.d.ts +60 -18
  128. package/dist/sync/NetworkProbe.js +121 -23
  129. package/dist/sync/SyncWebSocket.d.ts +45 -70
  130. package/dist/sync/SyncWebSocket.js +113 -89
  131. package/dist/sync/createIntentStream.js +10 -1
  132. package/dist/sync/participants.js +5 -2
  133. package/dist/transactions/TransactionQueue.js +13 -1
  134. package/dist/types/streams.d.ts +9 -0
  135. package/dist/utils/mobx-setup.js +1 -0
  136. package/dist/webhooks/events.d.ts +38 -0
  137. package/dist/webhooks/events.js +40 -0
  138. package/dist/webhooks/index.d.ts +10 -0
  139. package/dist/webhooks/index.js +10 -0
  140. package/dist/wire/errorEnvelope.d.ts +34 -0
  141. package/dist/wire/errorEnvelope.js +86 -0
  142. package/dist/wire/frames.d.ts +119 -0
  143. package/dist/wire/frames.js +1 -0
  144. package/dist/wire/index.d.ts +24 -0
  145. package/dist/wire/index.js +21 -0
  146. package/dist/wire/listEnvelope.d.ts +45 -0
  147. package/dist/wire/listEnvelope.js +17 -0
  148. package/docs/api-keys.md +5 -5
  149. package/docs/api.md +125 -65
  150. package/docs/audit.md +16 -9
  151. package/docs/cli.md +57 -47
  152. package/docs/client-behavior.md +54 -40
  153. package/docs/coordination.md +66 -80
  154. package/docs/data-sources.md +56 -34
  155. package/docs/examples/agent-human.md +74 -28
  156. package/docs/examples/ai-sdk-tool.md +29 -22
  157. package/docs/examples/existing-python-backend.md +41 -26
  158. package/docs/examples/nextjs.md +32 -17
  159. package/docs/examples/scoped-agent.md +43 -28
  160. package/docs/examples/server-agent.md +40 -15
  161. package/docs/guarantees.md +38 -27
  162. package/docs/identity.md +65 -59
  163. package/docs/index.md +30 -19
  164. package/docs/integration-guide.md +78 -78
  165. package/docs/interaction-model.md +43 -35
  166. package/docs/mcp/claude-code.md +11 -19
  167. package/docs/mcp/cursor.md +7 -25
  168. package/docs/mcp/windsurf.md +7 -20
  169. package/docs/mcp.md +103 -26
  170. package/docs/quickstart.md +63 -61
  171. package/docs/react.md +24 -16
  172. package/docs/roadmap.md +13 -13
  173. package/docs/schema-contract.md +111 -0
  174. package/docs/the-loop.md +21 -0
  175. package/examples/README.md +8 -4
  176. package/examples/data-source/README.md +10 -7
  177. package/examples/data-source/customer-server.ts +27 -25
  178. package/examples/data-source/run.ts +4 -3
  179. package/examples/quickstart.ts +1 -1
  180. package/llms.txt +55 -21
  181. package/package.json +48 -3
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Next.js App Router adapter for Data Source. The core `dataSource()` already
3
+ * returns a Web-standard `(Request) => Promise<Response>`, which Next App Router
4
+ * accepts directly — so this is pure ergonomics: wire an ORM `adapter` in via the
5
+ * bridge and hand back a named `POST` so the customer's route file is the minimum:
6
+ *
7
+ * // app/api/ablo/source/route.ts
8
+ * import { dataSourceNext } from '@abloatai/ablo/source/next';
9
+ * import { prismaDataSource } from '@abloatai/ablo/source';
10
+ * import { schema } from '@/ablo/schema';
11
+ * import { prisma } from '@/lib/prisma';
12
+ *
13
+ * export const { POST } = dataSourceNext({
14
+ * schema,
15
+ * apiKey: process.env.ABLO_API_KEY!,
16
+ * adapter: prismaDataSource(prisma, schema),
17
+ * });
18
+ *
19
+ * Day-one scope: Next + the adapter form only. Hand-written handlers use the core
20
+ * `dataSource()` directly; Hono/Express are the same one-liner and land on demand
21
+ * — not pre-built.
22
+ */
23
+ import { dataSource } from './index.js';
24
+ export function dataSourceNext(options) {
25
+ return { POST: dataSource(options) };
26
+ }
@@ -61,7 +61,13 @@ export interface BootstrapOptions {
61
61
  * old clients that don't send a models param).
62
62
  */
63
63
  instantModels?: string[];
64
+ /**
65
+ * Shared SDK credential getter. Preferred over `setAuthToken`; read at
66
+ * request time so token refreshes apply without recreating BootstrapHelper.
67
+ */
68
+ getAuthToken?: AuthTokenGetter;
64
69
  }
70
+ import { type AuthTokenGetter } from '../auth/credentialSource.js';
65
71
  import { type ValidatedServerDelta } from './schemas.js';
66
72
  export declare class BootstrapHelper {
67
73
  private options;
@@ -74,6 +80,10 @@ export declare class BootstrapHelper {
74
80
  */
75
81
  setCacheScope(cacheScope: string): void;
76
82
  setSyncGroups(syncGroups: readonly string[] | undefined): void;
83
+ /**
84
+ * Compatibility setter for direct BootstrapHelper users. The SDK-owned
85
+ * `Ablo()` path passes `getAuthToken` and does not mutate this helper.
86
+ */
77
87
  setAuthToken(authToken: string | undefined): void;
78
88
  /**
79
89
  * Create a promise that rejects after a timeout
@@ -3,7 +3,8 @@
3
3
  * Removed problematic caching that was serving stale data
4
4
  */
5
5
  import { getContext } from '../context.js';
6
- import { SyncSessionError, AbloConnectionError, translateHttpError } from '../errors.js';
6
+ import { SyncSessionError, AbloConnectionError, translateHttpError, toAbloError, isRetryableCode } from '../errors.js';
7
+ import { withAuthHeaders } from '../auth/credentialSource.js';
7
8
  // SyncObservability replaced by getContext().observability
8
9
  import { parseBootstrapResponse } from './schemas.js';
9
10
  export class BootstrapHelper {
@@ -46,6 +47,10 @@ export class BootstrapHelper {
46
47
  setSyncGroups(syncGroups) {
47
48
  this.options.syncGroups = [...(syncGroups ?? [])];
48
49
  }
50
+ /**
51
+ * Compatibility setter for direct BootstrapHelper users. The SDK-owned
52
+ * `Ablo()` path passes `getAuthToken` and does not mutate this helper.
53
+ */
49
54
  setAuthToken(authToken) {
50
55
  if (!authToken) {
51
56
  delete this.options.authToken;
@@ -60,7 +65,9 @@ export class BootstrapHelper {
60
65
  createTimeoutPromise(ms, operation) {
61
66
  return new Promise((_, reject) => {
62
67
  setTimeout(() => {
63
- reject(new Error(`Bootstrap ${operation} timed out after ${ms}ms`));
68
+ reject(new AbloConnectionError(`Bootstrap ${operation} timed out after ${ms}ms`, {
69
+ code: 'bootstrap_fetch_timeout',
70
+ }));
64
71
  }, ms);
65
72
  });
66
73
  }
@@ -138,6 +145,17 @@ export class BootstrapHelper {
138
145
  });
139
146
  throw error;
140
147
  }
148
+ // Don't retry NON-retryable errors. A 401/403/4xx auth or client error
149
+ // (api_key_required, jwt_issuer_untrusted, …) will NOT succeed by
150
+ // repeating the same request with the same credential — retrying just
151
+ // hammers the server and floods the console with doomed requests. Only
152
+ // transient failures (5xx, 429, timeouts, network blips, or an
153
+ // unclassified error with no code) flow through to the retry/backoff.
154
+ const ablo = toAbloError(error);
155
+ if (ablo.code && !isRetryableCode(ablo.code)) {
156
+ getContext().observability.breadcrumb('Bootstrap non-retryable error — failing fast', 'sync.bootstrap', 'warning', { code: ablo.code, httpStatus: ablo.httpStatus });
157
+ throw ablo;
158
+ }
141
159
  lastError = error;
142
160
  getContext().observability.breadcrumb('Bootstrap fetch failed', 'sync.bootstrap', 'warning', {
143
161
  attempt: attempt + 1,
@@ -157,7 +175,11 @@ export class BootstrapHelper {
157
175
  });
158
176
  return cached;
159
177
  }
160
- throw lastError || new Error('Failed to fetch bootstrap data');
178
+ throw lastError
179
+ ? toAbloError(lastError)
180
+ : new AbloConnectionError('Failed to fetch bootstrap data', {
181
+ code: 'bootstrap_fetch_timeout',
182
+ });
161
183
  }
162
184
  /**
163
185
  * Fetch bootstrap with ETag, returning 304 hints
@@ -180,15 +202,11 @@ export class BootstrapHelper {
180
202
  // conditional revalidation (If-None-Match) implement it at their own
181
203
  // level where they own the cache-key namespace. The 304 branch below
182
204
  // remains defensively in place for when a caller enables revalidation.
183
- const headers = { 'Content-Type': 'application/json' };
184
- if (this.options.authToken) {
185
- headers.Authorization = `Bearer ${this.options.authToken}`;
186
- }
205
+ const headers = withAuthHeaders(this.options.getAuthToken, { 'Content-Type': 'application/json' }, this.options.authToken);
187
206
  this.abortController = new AbortController();
188
207
  const res = await fetch(url, {
189
208
  method: 'GET',
190
209
  headers,
191
- credentials: 'include',
192
210
  signal: this.abortController.signal,
193
211
  });
194
212
  const etag = res.headers.get('ETag');
@@ -198,17 +216,6 @@ export class BootstrapHelper {
198
216
  return { notModified: true, etag };
199
217
  }
200
218
  if (!res.ok) {
201
- // Check for session/auth errors - these should redirect to login
202
- if (SyncSessionError.isSessionErrorResponse(res.status)) {
203
- let body = '';
204
- try {
205
- body = await res.text();
206
- }
207
- catch {
208
- // Ignore body parsing errors
209
- }
210
- throw new SyncSessionError(body || `Session expired or invalid: ${res.status}`, res.status);
211
- }
212
219
  const bodyText = await res.text().catch(() => '');
213
220
  let parsed = bodyText;
214
221
  if (bodyText) {
@@ -219,7 +226,21 @@ export class BootstrapHelper {
219
226
  // Keep as string.
220
227
  }
221
228
  }
222
- throw translateHttpError(res.status, parsed || `Bootstrap fetch failed: ${res.status} ${res.statusText}`, res.headers.get('x-request-id') ?? undefined);
229
+ // Translate the canonical envelope FIRST so the server's specific code +
230
+ // message survive (e.g. `api_key_required`, `jwt_issuer_untrusted`).
231
+ const translated = translateHttpError(res.status, parsed || `Bootstrap fetch failed: ${res.status} ${res.statusText}`, res.headers.get('x-request-id') ?? undefined);
232
+ // Only a genuine session/JWT EXPIRY — or a bare auth failure carrying no
233
+ // structured code — should drive the sign-in redirect. A specific auth
234
+ // code like `api_key_required` is NOT an expired session: re-logging-in
235
+ // mints the same credential and loops. Surface it as its real typed error
236
+ // instead of a `session_expired` wrapping the stringified body.
237
+ if (translated.code === 'session_expired' ||
238
+ translated.code === 'jwt_expired' ||
239
+ ((res.status === 401 || res.status === 403) &&
240
+ translated.code === undefined)) {
241
+ throw new SyncSessionError(translated.message, res.status);
242
+ }
243
+ throw translated;
223
244
  }
224
245
  const rawJson = await res.json();
225
246
  const data = parseBootstrapResponse(rawJson);
@@ -252,15 +273,11 @@ export class BootstrapHelper {
252
273
  try {
253
274
  response = await fetch(url, {
254
275
  method: 'GET',
255
- headers: {
276
+ headers: withAuthHeaders(this.options.getAuthToken, {
256
277
  'Content-Type': 'application/json',
257
278
  'Cache-Control': 'no-cache, no-store, must-revalidate',
258
279
  Pragma: 'no-cache',
259
- ...(this.options.authToken
260
- ? { Authorization: `Bearer ${this.options.authToken}` }
261
- : {}),
262
- },
263
- credentials: 'include',
280
+ }, this.options.authToken),
264
281
  signal: this.abortController.signal,
265
282
  cache: 'no-store', // Force browser to not cache
266
283
  });
@@ -275,17 +292,6 @@ export class BootstrapHelper {
275
292
  }
276
293
  clearTimeout(timeoutId);
277
294
  if (!response.ok) {
278
- // Check for session/auth errors - these should redirect to login
279
- if (SyncSessionError.isSessionErrorResponse(response.status)) {
280
- let body = '';
281
- try {
282
- body = await response.text();
283
- }
284
- catch {
285
- // Ignore body parsing errors
286
- }
287
- throw new SyncSessionError(body || `Session expired or invalid: ${response.status}`, response.status);
288
- }
289
295
  const bodyText = await response.text().catch(() => '');
290
296
  let parsed = bodyText;
291
297
  if (bodyText) {
@@ -296,7 +302,17 @@ export class BootstrapHelper {
296
302
  // Keep as string.
297
303
  }
298
304
  }
299
- throw translateHttpError(response.status, parsed || `Bootstrap fetch failed: ${response.status} ${response.statusText}`, response.headers.get('x-request-id') ?? undefined);
305
+ // Same code-aware handling as the primary bootstrap fetch: preserve the
306
+ // server's specific code/message; only a genuine expiry (or a bare,
307
+ // code-less auth failure) drives the sign-in redirect.
308
+ const translated = translateHttpError(response.status, parsed || `Bootstrap fetch failed: ${response.status} ${response.statusText}`, response.headers.get('x-request-id') ?? undefined);
309
+ if (translated.code === 'session_expired' ||
310
+ translated.code === 'jwt_expired' ||
311
+ ((response.status === 401 || response.status === 403) &&
312
+ translated.code === undefined)) {
313
+ throw new SyncSessionError(translated.message, response.status);
314
+ }
315
+ throw translated;
300
316
  }
301
317
  const rawJson = await response.json();
302
318
  const data = parseBootstrapResponse(rawJson);
@@ -318,10 +334,9 @@ export class BootstrapHelper {
318
334
  const url = `${this.options.baseUrl}/sync/entity/${modelName}/${id}`;
319
335
  const response = await fetch(url, {
320
336
  method: 'GET',
321
- headers: {
337
+ headers: withAuthHeaders(this.options.getAuthToken, {
322
338
  'Content-Type': 'application/json',
323
- },
324
- credentials: 'include',
339
+ }, this.options.authToken),
325
340
  });
326
341
  if (response.status === 404) {
327
342
  return null;
@@ -417,7 +432,6 @@ export class BootstrapHelper {
417
432
  try {
418
433
  const response = await fetch(`${this.options.baseUrl}/health`, {
419
434
  method: 'GET',
420
- credentials: 'include',
421
435
  signal: AbortSignal.timeout(5000),
422
436
  cache: 'no-store',
423
437
  });
@@ -34,7 +34,8 @@
34
34
  * instead of hard-reloading an already-offline browser.
35
35
  */
36
36
  import { type ProbeResult } from './NetworkProbe.js';
37
- export type ConnectionState = 'connected' | 'offline' | 'probing_network' | 'validating_session' | 'reconnecting' | 'backoff' | 'waiting_for_network' | 'session_expired';
37
+ import type { AuthTokenGetter } from '../auth/credentialSource.js';
38
+ export type ConnectionState = 'connected' | 'offline' | 'probing_network' | 'validating_session' | 'refreshing_credential' | 'reconnecting' | 'backoff' | 'waiting_for_network' | 'auth_blocked' | 'session_expired';
38
39
  export type ConnectionEvent = {
39
40
  type: 'NETWORK_LOST';
40
41
  } | {
@@ -52,8 +53,20 @@ export type ConnectionEvent = {
52
53
  } | {
53
54
  type: 'PROBE_SUCCESS';
54
55
  sessionValid: boolean;
56
+ } | {
57
+ type: 'PROBE_AUTH_BLOCKED';
58
+ }
59
+ /** The probe saw an expired ephemeral access key (`access_credential_expiry`).
60
+ * Recoverable: re-mint a fresh `ek_`/`rk_` and re-probe — never a sign-out. */
61
+ | {
62
+ type: 'PROBE_CREDENTIAL_STALE';
55
63
  } | {
56
64
  type: 'PROBE_FAILED';
65
+ }
66
+ /** A fresh access credential is available (the re-mint succeeded, or one was
67
+ * pushed in via `setAuthToken`). Re-probe so a parked connection picks it up. */
68
+ | {
69
+ type: 'CREDENTIAL_REFRESHED';
57
70
  } | {
58
71
  type: 'RECONNECT_SUCCESS';
59
72
  } | {
@@ -68,6 +81,20 @@ export type ConnectionEvent = {
68
81
  export interface ConnectionCallbacks {
69
82
  /** Run bootstrap + WebSocket reconnect. Returns the outcome. */
70
83
  onReconnect: () => Promise<'success' | 'session_error' | 'network_error'>;
84
+ /**
85
+ * Re-mint the short-lived access credential (the Stripe-style `ek_`/`rk_`)
86
+ * and push it into the credential source, then report the outcome. Invoked
87
+ * on `refreshing_credential` — i.e. when a probe found the access key stale
88
+ * (`PROBE_CREDENTIAL_STALE`). Mirrors the `getToken` contract:
89
+ * - `'refreshed'` → a fresh credential is in place; re-probe & reconnect.
90
+ * - `'session_error'` → the LONG-LIVED login is gone (mint returned null →
91
+ * 401/403); terminal → sign out.
92
+ * - `'network_error'` → couldn't reach the mint endpoint (offline/5xx/throw);
93
+ * transient → back off and retry, never sign out.
94
+ * Optional: a deployment with no re-mint path (e.g. a static `apiKey`) omits
95
+ * it, and the FSM falls back to a plain re-probe.
96
+ */
97
+ onRefreshCredential?: () => Promise<'refreshed' | 'session_error' | 'network_error'>;
71
98
  /** Called when the session is confirmed expired — route to signin. */
72
99
  onSessionExpired: () => void;
73
100
  /** Called to tear down the WebSocket when entering a dead state. */
@@ -88,6 +115,12 @@ export interface ConnectionManagerOptions {
88
115
  * default of `probeNetwork`.
89
116
  */
90
117
  baseUrl?: string;
118
+ /**
119
+ * Current bearer credential for authenticated probes. Read lazily so token
120
+ * refreshes pushed through `Ablo.setAuthToken()` are used by the next probe
121
+ * without recreating the manager.
122
+ */
123
+ getAuthToken?: AuthTokenGetter;
91
124
  /** Override retry ceilings / jitter. Production should leave defaults. */
92
125
  backoff?: Partial<typeof DEFAULT_BACKOFF>;
93
126
  }
@@ -107,8 +140,12 @@ export declare class ConnectionManager {
107
140
  private debounceTimer;
108
141
  private watchdogTimer;
109
142
  private stuckCycles;
143
+ /** Consecutive access-key re-mints in the current recovery cycle; reset on
144
+ * reaching `connected`. See {@link MAX_CREDENTIAL_REFRESH_ATTEMPTS}. */
145
+ private credentialRefreshAttempts;
110
146
  private disposed;
111
147
  private readonly baseUrl?;
148
+ private readonly getAuthToken?;
112
149
  private readonly backoff;
113
150
  private handleBrowserOnline;
114
151
  private handleBrowserOffline;
@@ -120,6 +157,25 @@ export declare class ConnectionManager {
120
157
  private transition;
121
158
  private onEnterState;
122
159
  private runProbe;
160
+ /**
161
+ * Re-mint the short-lived access key on `refreshing_credential`. Delegates to
162
+ * the `onRefreshCredential` callback (which mints a fresh `ek_`/`rk_` from the
163
+ * still-valid login and pushes it into the credential source) and maps its
164
+ * tri-state outcome onto the FSM:
165
+ * - `refreshed` → `CREDENTIAL_REFRESHED` → re-probe & reconnect.
166
+ * - `session_error` → `BOOTSTRAP_FAILED_SESSION` → sign out (login is gone).
167
+ * - `network_error` → `RECONNECT_FAILED` → back off & retry (never sign out).
168
+ *
169
+ * A bounded attempt counter guards against a hot loop where the server keeps
170
+ * reporting the key stale even after a "successful" re-mint (e.g. a clock skew
171
+ * or a mint that returns an already-rejected key): after
172
+ * `MAX_CREDENTIAL_REFRESH_ATTEMPTS` we fall through to `auth_blocked` (stop,
173
+ * no sign-out) rather than spin. The counter resets once we reach `connected`.
174
+ *
175
+ * When no refresher is wired (e.g. a static `apiKey` deployment), we re-probe
176
+ * directly — the credential source's own scheduler owns refresh there.
177
+ */
178
+ private runRefreshCredential;
123
179
  private runReconnect;
124
180
  private scheduleBackoff;
125
181
  private setupBrowserListeners;
@@ -46,6 +46,10 @@ const DEFAULT_BACKOFF = {
46
46
  const ONLINE_DEBOUNCE_MS = 500;
47
47
  const WATCHDOG_INTERVAL_MS = 30_000;
48
48
  const MAX_STUCK_CYCLES_BEFORE_RELOAD = 6;
49
+ /** Cap on consecutive access-key re-mints before giving up to `auth_blocked`.
50
+ * Stops a hot loop if the server keeps reporting the key stale even after a
51
+ * "successful" re-mint (clock skew, a mint returning an already-rejected key). */
52
+ const MAX_CREDENTIAL_REFRESH_ATTEMPTS = 3;
49
53
  // ─── ConnectionManager ────────────────────────────────────────────────────
50
54
  export class ConnectionManager {
51
55
  // Observable state
@@ -59,14 +63,19 @@ export class ConnectionManager {
59
63
  debounceTimer = null;
60
64
  watchdogTimer = null;
61
65
  stuckCycles = 0;
66
+ /** Consecutive access-key re-mints in the current recovery cycle; reset on
67
+ * reaching `connected`. See {@link MAX_CREDENTIAL_REFRESH_ATTEMPTS}. */
68
+ credentialRefreshAttempts = 0;
62
69
  disposed = false;
63
70
  baseUrl;
71
+ getAuthToken;
64
72
  backoff;
65
73
  handleBrowserOnline = null;
66
74
  handleBrowserOffline = null;
67
75
  handleVisibilityChange = null;
68
76
  constructor(options = {}) {
69
77
  this.baseUrl = options.baseUrl;
78
+ this.getAuthToken = options.getAuthToken;
70
79
  this.backoff = { ...DEFAULT_BACKOFF, ...(options.backoff ?? {}) };
71
80
  makeAutoObservable(this, {}, { autoBind: true });
72
81
  }
@@ -151,6 +160,7 @@ export class ConnectionManager {
151
160
  case 'MANUAL_RETRY':
152
161
  case 'TAB_VISIBLE':
153
162
  case 'WS_HANDSHAKE_FAILED':
163
+ case 'CREDENTIAL_REFRESHED':
154
164
  return 'probing_network';
155
165
  case 'WS_SESSION_ERROR':
156
166
  case 'BOOTSTRAP_FAILED_SESSION':
@@ -162,6 +172,11 @@ export class ConnectionManager {
162
172
  switch (event.type) {
163
173
  case 'PROBE_SUCCESS':
164
174
  return event.sessionValid ? 'reconnecting' : 'session_expired';
175
+ case 'PROBE_CREDENTIAL_STALE':
176
+ // Access key expired but the login is fine — re-mint, don't sign out.
177
+ return 'refreshing_credential';
178
+ case 'PROBE_AUTH_BLOCKED':
179
+ return 'auth_blocked';
165
180
  case 'PROBE_FAILED':
166
181
  return 'waiting_for_network';
167
182
  case 'NETWORK_LOST':
@@ -175,6 +190,7 @@ export class ConnectionManager {
175
190
  case 'TAB_VISIBLE':
176
191
  case 'MANUAL_RETRY':
177
192
  case 'BACKOFF_ELAPSED':
193
+ case 'CREDENTIAL_REFRESHED':
178
194
  return 'probing_network';
179
195
  case 'NETWORK_LOST':
180
196
  return 'offline';
@@ -185,6 +201,35 @@ export class ConnectionManager {
185
201
  switch (event.type) {
186
202
  case 'PROBE_SUCCESS':
187
203
  return event.sessionValid ? 'reconnecting' : 'session_expired';
204
+ case 'PROBE_CREDENTIAL_STALE':
205
+ return 'refreshing_credential';
206
+ case 'PROBE_AUTH_BLOCKED':
207
+ return 'auth_blocked';
208
+ case 'NETWORK_LOST':
209
+ return 'offline';
210
+ default:
211
+ return null;
212
+ }
213
+ case 'refreshing_credential':
214
+ // Re-minting the short-lived access key (the Stripe-style `ek_`/`rk_`).
215
+ // The login is presumed valid; this is NOT a sign-out state.
216
+ switch (event.type) {
217
+ case 'CREDENTIAL_REFRESHED':
218
+ // Fresh key in hand — re-probe so we reconnect with it.
219
+ return 'probing_network';
220
+ case 'BOOTSTRAP_FAILED_SESSION':
221
+ // The re-mint hit a genuine 401/403: the long-lived login itself is
222
+ // gone. THIS is the only path from here to sign-out.
223
+ return 'session_expired';
224
+ case 'RECONNECT_FAILED':
225
+ // Couldn't reach the mint endpoint (offline/5xx/throw) — transient.
226
+ // Back off and retry; never sign out for a network failure.
227
+ return 'backoff';
228
+ case 'PROBE_AUTH_BLOCKED':
229
+ // Bounded-attempt fallback: the key keeps coming back stale even
230
+ // after re-mint (see runRefreshCredential's attempt guard). Stop
231
+ // looping without signing out.
232
+ return 'auth_blocked';
188
233
  case 'NETWORK_LOST':
189
234
  return 'offline';
190
235
  default:
@@ -214,10 +259,32 @@ export class ConnectionManager {
214
259
  return 'probing_network';
215
260
  case 'NETWORK_ONLINE':
216
261
  case 'TAB_VISIBLE':
217
- // Network came back while we were waiting out a backoff
218
- // delay jump straight to probing instead of waiting the
219
- // full exponential interval. Fixes the "doesn't retrigger
220
- // when internet comes back" bug.
262
+ case 'CREDENTIAL_REFRESHED':
263
+ // Network came back (or a fresh credential arrived) while we were
264
+ // waiting out a backoff delay jump straight to probing instead of
265
+ // waiting the full exponential interval. Fixes the "doesn't
266
+ // retrigger when internet comes back" bug.
267
+ return 'probing_network';
268
+ case 'NETWORK_LOST':
269
+ return 'offline';
270
+ case 'WS_SESSION_ERROR':
271
+ case 'BOOTSTRAP_FAILED_SESSION':
272
+ return 'session_expired';
273
+ default:
274
+ return null;
275
+ }
276
+ case 'auth_blocked':
277
+ // Reachable, but the data-plane rejected the credential (non-retryable,
278
+ // non-expiry — e.g. api_key_required, jwt_issuer_untrusted). Don't
279
+ // auto-reconnect and don't sign out. Allow a manual retry or a
280
+ // tab-focus / network-return / fresh-credential re-probe (e.g. after a
281
+ // server deploy or an out-of-band re-mint); a network drop parks
282
+ // offline; a genuine session error still expires.
283
+ switch (event.type) {
284
+ case 'MANUAL_RETRY':
285
+ case 'TAB_VISIBLE':
286
+ case 'NETWORK_ONLINE':
287
+ case 'CREDENTIAL_REFRESHED':
221
288
  return 'probing_network';
222
289
  case 'NETWORK_LOST':
223
290
  return 'offline';
@@ -238,6 +305,9 @@ export class ConnectionManager {
238
305
  switch (state) {
239
306
  case 'connected':
240
307
  this.clearBackoffTimer();
308
+ runInAction(() => {
309
+ this.credentialRefreshAttempts = 0;
310
+ });
241
311
  break;
242
312
  case 'offline':
243
313
  this.clearBackoffTimer();
@@ -252,9 +322,22 @@ export class ConnectionManager {
252
322
  case 'reconnecting':
253
323
  this.runReconnect();
254
324
  break;
325
+ case 'refreshing_credential':
326
+ this.runRefreshCredential();
327
+ break;
255
328
  case 'backoff':
256
329
  this.scheduleBackoff();
257
330
  break;
331
+ case 'auth_blocked':
332
+ // Stop — reachable but the credential was rejected (e.g.
333
+ // api_key_required / jwt_issuer_untrusted from the data plane). Neither
334
+ // reconnecting nor re-auth fixes it. Drop the socket and wait for a
335
+ // manual retry / re-probe. Crucially NOT onSessionExpired (no sign-out)
336
+ // and NOT a reconnect — that's the whole point of this state.
337
+ this.clearBackoffTimer();
338
+ this.callbacks?.onDisconnectWebSocket();
339
+ getContext().observability.breadcrumb('Auth blocked — reachable but credential rejected; not reconnecting or signing out', 'sync.offline', 'error');
340
+ break;
258
341
  case 'session_expired':
259
342
  this.clearBackoffTimer();
260
343
  this.callbacks?.onDisconnectWebSocket();
@@ -266,21 +349,108 @@ export class ConnectionManager {
266
349
  // ── Async operations ─────────────────────────────────────────────────
267
350
  async runProbe() {
268
351
  try {
269
- const result = await probeNetwork(this.baseUrl);
352
+ const result = await probeNetwork({
353
+ baseUrl: this.baseUrl,
354
+ getAuthToken: this.getAuthToken,
355
+ });
270
356
  runInAction(() => {
271
357
  this.lastProbeResult = result;
272
358
  });
273
- if (result.reachable) {
274
- this.send({ type: 'PROBE_SUCCESS', sessionValid: result.sessionValid ?? true });
275
- }
276
- else {
277
- this.send({ type: 'PROBE_FAILED' });
359
+ // One probe outcome → one event. Exhaustive over ProbeOutcome so a new
360
+ // outcome can't be silently dropped.
361
+ switch (result.outcome) {
362
+ case 'reachable':
363
+ this.send({ type: 'PROBE_SUCCESS', sessionValid: true });
364
+ break;
365
+ case 'session_expired':
366
+ // Genuine login expiry — terminal. (PROBE_SUCCESS with
367
+ // sessionValid:false routes to session_expired in the FSM.)
368
+ this.send({ type: 'PROBE_SUCCESS', sessionValid: false });
369
+ break;
370
+ case 'credential_stale':
371
+ // Access key expired but the login is fine — re-mint, don't sign out.
372
+ this.send({ type: 'PROBE_CREDENTIAL_STALE' });
373
+ break;
374
+ case 'auth_blocked':
375
+ this.send({ type: 'PROBE_AUTH_BLOCKED' });
376
+ break;
377
+ case 'unreachable':
378
+ this.send({ type: 'PROBE_FAILED' });
379
+ break;
380
+ default: {
381
+ const _exhaustive = result.outcome;
382
+ void _exhaustive;
383
+ this.send({ type: 'PROBE_FAILED' });
384
+ }
278
385
  }
279
386
  }
280
387
  catch {
281
388
  this.send({ type: 'PROBE_FAILED' });
282
389
  }
283
390
  }
391
+ /**
392
+ * Re-mint the short-lived access key on `refreshing_credential`. Delegates to
393
+ * the `onRefreshCredential` callback (which mints a fresh `ek_`/`rk_` from the
394
+ * still-valid login and pushes it into the credential source) and maps its
395
+ * tri-state outcome onto the FSM:
396
+ * - `refreshed` → `CREDENTIAL_REFRESHED` → re-probe & reconnect.
397
+ * - `session_error` → `BOOTSTRAP_FAILED_SESSION` → sign out (login is gone).
398
+ * - `network_error` → `RECONNECT_FAILED` → back off & retry (never sign out).
399
+ *
400
+ * A bounded attempt counter guards against a hot loop where the server keeps
401
+ * reporting the key stale even after a "successful" re-mint (e.g. a clock skew
402
+ * or a mint that returns an already-rejected key): after
403
+ * `MAX_CREDENTIAL_REFRESH_ATTEMPTS` we fall through to `auth_blocked` (stop,
404
+ * no sign-out) rather than spin. The counter resets once we reach `connected`.
405
+ *
406
+ * When no refresher is wired (e.g. a static `apiKey` deployment), we re-probe
407
+ * directly — the credential source's own scheduler owns refresh there.
408
+ */
409
+ async runRefreshCredential() {
410
+ if (this.credentialRefreshAttempts >= MAX_CREDENTIAL_REFRESH_ATTEMPTS) {
411
+ getContext().logger.warn('[ConnectionManager] Access key still stale after repeated re-mints — stopping', { attempts: this.credentialRefreshAttempts });
412
+ runInAction(() => {
413
+ this.credentialRefreshAttempts = 0;
414
+ });
415
+ this.send({ type: 'PROBE_AUTH_BLOCKED' });
416
+ return;
417
+ }
418
+ runInAction(() => {
419
+ this.credentialRefreshAttempts += 1;
420
+ });
421
+ const refresher = this.callbacks?.onRefreshCredential;
422
+ if (!refresher) {
423
+ // No re-mint path wired — re-probe with whatever the credential source
424
+ // holds (a static-key deployment refreshes out-of-band, if at all).
425
+ this.send({ type: 'CREDENTIAL_REFRESHED' });
426
+ return;
427
+ }
428
+ try {
429
+ const result = await refresher();
430
+ switch (result) {
431
+ case 'refreshed':
432
+ this.send({ type: 'CREDENTIAL_REFRESHED' });
433
+ break;
434
+ case 'session_error':
435
+ this.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
436
+ break;
437
+ case 'network_error':
438
+ this.send({ type: 'RECONNECT_FAILED' });
439
+ break;
440
+ default: {
441
+ const _exhaustive = result;
442
+ void _exhaustive;
443
+ this.send({ type: 'RECONNECT_FAILED' });
444
+ }
445
+ }
446
+ }
447
+ catch (error) {
448
+ // A thrown refresher is transient by contract (offline / mint endpoint
449
+ // unreachable) — back off and retry, never sign out.
450
+ getContext().logger.warn('[ConnectionManager] Credential re-mint threw (transient)', { error });
451
+ this.send({ type: 'RECONNECT_FAILED' });
452
+ }
453
+ }
284
454
  async runReconnect() {
285
455
  if (!this.callbacks)
286
456
  return;
@@ -395,6 +565,7 @@ export class ConnectionManager {
395
565
  const isStuck = this.state !== 'connected' &&
396
566
  this.state !== 'session_expired' &&
397
567
  this.state !== 'probing_network' &&
568
+ this.state !== 'refreshing_credential' &&
398
569
  this.state !== 'reconnecting';
399
570
  if (isStuck) {
400
571
  this.stuckCycles++;
@@ -431,7 +602,11 @@ export class ConnectionManager {
431
602
  get isConnected() { return this.state === 'connected'; }
432
603
  get isOffline() { return this.state === 'offline' || this.state === 'waiting_for_network'; }
433
604
  get isReconnecting() {
434
- return this.state === 'probing_network' || this.state === 'reconnecting' || this.state === 'backoff';
605
+ return (this.state === 'probing_network' ||
606
+ this.state === 'validating_session' ||
607
+ this.state === 'refreshing_credential' ||
608
+ this.state === 'reconnecting' ||
609
+ this.state === 'backoff');
435
610
  }
436
611
  get isSessionExpired() { return this.state === 'session_expired'; }
437
612
  get offlineDuration() {