@abloatai/ablo 0.8.0 → 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.
Files changed (165) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +33 -28
  3. package/dist/BaseSyncedStore.d.ts +83 -0
  4. package/dist/BaseSyncedStore.js +194 -2
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/agent/session.js +3 -3
  8. package/dist/ai-sdk/coordination-context.js +4 -0
  9. package/dist/ai-sdk/index.d.ts +56 -47
  10. package/dist/ai-sdk/index.js +56 -47
  11. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  12. package/dist/ai-sdk/intent-broadcast.js +11 -4
  13. package/dist/ai-sdk/wrap.d.ts +14 -11
  14. package/dist/ai-sdk/wrap.js +11 -13
  15. package/dist/auth/credentialSource.d.ts +34 -0
  16. package/dist/auth/credentialSource.js +63 -0
  17. package/dist/auth/index.d.ts +2 -22
  18. package/dist/auth/index.js +4 -42
  19. package/dist/auth/schemas.d.ts +35 -0
  20. package/dist/auth/schemas.js +53 -0
  21. package/dist/client/Ablo.d.ts +160 -42
  22. package/dist/client/Ablo.js +145 -75
  23. package/dist/client/ApiClient.d.ts +20 -4
  24. package/dist/client/ApiClient.js +166 -28
  25. package/dist/client/auth.d.ts +14 -5
  26. package/dist/client/auth.js +60 -7
  27. package/dist/client/createInternalComponents.d.ts +2 -0
  28. package/dist/client/createInternalComponents.js +8 -1
  29. package/dist/client/createModelProxy.d.ts +130 -66
  30. package/dist/client/createModelProxy.js +152 -49
  31. package/dist/client/httpClient.d.ts +71 -0
  32. package/dist/client/httpClient.js +69 -0
  33. package/dist/client/identity.d.ts +2 -6
  34. package/dist/client/identity.js +49 -11
  35. package/dist/client/index.d.ts +1 -0
  36. package/dist/client/index.js +1 -0
  37. package/dist/client/registerDataSource.d.ts +3 -3
  38. package/dist/client/registerDataSource.js +11 -9
  39. package/dist/client/validateAbloOptions.js +1 -1
  40. package/dist/core/DatabaseManager.js +30 -2
  41. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  42. package/dist/core/openIDBWithTimeout.js +88 -1
  43. package/dist/errorCodes.d.ts +70 -1
  44. package/dist/errorCodes.js +108 -9
  45. package/dist/errors.d.ts +2 -2
  46. package/dist/errors.js +72 -22
  47. package/dist/index.d.ts +17 -8
  48. package/dist/index.js +15 -6
  49. package/dist/keys/index.d.ts +16 -1
  50. package/dist/keys/index.js +26 -6
  51. package/dist/mutators/UndoManager.d.ts +158 -50
  52. package/dist/mutators/UndoManager.js +345 -22
  53. package/dist/mutators/inverseOp.d.ts +129 -0
  54. package/dist/mutators/inverseOp.js +74 -0
  55. package/dist/mutators/readerActions.d.ts +1 -1
  56. package/dist/mutators/undoApply.d.ts +42 -0
  57. package/dist/mutators/undoApply.js +143 -0
  58. package/dist/query/client.d.ts +10 -9
  59. package/dist/query/client.js +3 -6
  60. package/dist/react/AbloProvider.d.ts +23 -126
  61. package/dist/react/AbloProvider.js +62 -199
  62. package/dist/react/context.d.ts +31 -0
  63. package/dist/react/useAblo.d.ts +2 -2
  64. package/dist/react/useCurrentUserId.d.ts +1 -1
  65. package/dist/react/useCurrentUserId.js +1 -1
  66. package/dist/react/useMutators.js +19 -12
  67. package/dist/schema/ddl.d.ts +34 -3
  68. package/dist/schema/ddl.js +162 -4
  69. package/dist/schema/index.d.ts +5 -1
  70. package/dist/schema/index.js +13 -1
  71. package/dist/schema/model.d.ts +11 -0
  72. package/dist/schema/model.js +2 -0
  73. package/dist/schema/openapi.d.ts +28 -0
  74. package/dist/schema/openapi.js +118 -0
  75. package/dist/schema/plane.d.ts +23 -0
  76. package/dist/schema/plane.js +19 -0
  77. package/dist/schema/relation.d.ts +20 -0
  78. package/dist/schema/serialize.d.ts +4 -0
  79. package/dist/schema/serialize.js +4 -0
  80. package/dist/schema/sync-delta-row.d.ts +157 -0
  81. package/dist/schema/sync-delta-row.js +102 -0
  82. package/dist/schema/sync-delta-wire.d.ts +180 -0
  83. package/dist/schema/sync-delta-wire.js +102 -0
  84. package/dist/server/adapter.d.ts +156 -0
  85. package/dist/server/adapter.js +19 -0
  86. package/dist/server/commit.d.ts +82 -0
  87. package/dist/server/commit.js +1 -0
  88. package/dist/server/index.d.ts +14 -0
  89. package/dist/server/index.js +1 -0
  90. package/dist/server/next.d.ts +51 -0
  91. package/dist/server/next.js +47 -0
  92. package/dist/server/read-config.d.ts +60 -0
  93. package/dist/server/read-config.js +8 -0
  94. package/dist/server/storage-mode.d.ts +17 -0
  95. package/dist/server/storage-mode.js +12 -0
  96. package/dist/source/adapter.d.ts +65 -0
  97. package/dist/source/adapter.js +20 -0
  98. package/dist/source/adapters/drizzle.d.ts +43 -0
  99. package/dist/source/adapters/drizzle.js +185 -0
  100. package/dist/source/adapters/memory.d.ts +12 -0
  101. package/dist/source/adapters/memory.js +114 -0
  102. package/dist/source/adapters/prisma.d.ts +57 -0
  103. package/dist/source/adapters/prisma.js +176 -0
  104. package/dist/source/conformance.d.ts +32 -0
  105. package/dist/source/conformance.js +134 -0
  106. package/dist/source/contract.d.ts +144 -0
  107. package/dist/source/contract.js +99 -0
  108. package/dist/source/index.d.ts +62 -10
  109. package/dist/source/index.js +99 -0
  110. package/dist/source/migrations.d.ts +14 -0
  111. package/dist/source/migrations.js +39 -0
  112. package/dist/source/next.d.ts +33 -0
  113. package/dist/source/next.js +26 -0
  114. package/dist/sync/BootstrapHelper.d.ts +10 -0
  115. package/dist/sync/BootstrapHelper.js +10 -15
  116. package/dist/sync/ConnectionManager.d.ts +55 -1
  117. package/dist/sync/ConnectionManager.js +155 -16
  118. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  119. package/dist/sync/HydrationCoordinator.js +238 -39
  120. package/dist/sync/NetworkProbe.d.ts +58 -24
  121. package/dist/sync/NetworkProbe.js +118 -42
  122. package/dist/sync/SyncWebSocket.d.ts +45 -70
  123. package/dist/sync/SyncWebSocket.js +70 -36
  124. package/dist/sync/createIntentStream.js +10 -1
  125. package/dist/types/streams.d.ts +9 -0
  126. package/dist/utils/mobx-setup.js +1 -0
  127. package/dist/webhooks/events.d.ts +38 -0
  128. package/dist/webhooks/events.js +40 -0
  129. package/dist/webhooks/index.d.ts +10 -0
  130. package/dist/webhooks/index.js +10 -0
  131. package/dist/wire/errorEnvelope.d.ts +34 -0
  132. package/dist/wire/errorEnvelope.js +86 -0
  133. package/dist/wire/frames.d.ts +119 -0
  134. package/dist/wire/frames.js +1 -0
  135. package/dist/wire/index.d.ts +24 -0
  136. package/dist/wire/index.js +21 -0
  137. package/dist/wire/listEnvelope.d.ts +45 -0
  138. package/dist/wire/listEnvelope.js +17 -0
  139. package/docs/api.md +47 -44
  140. package/docs/cli.md +44 -44
  141. package/docs/client-behavior.md +30 -30
  142. package/docs/coordination.md +33 -36
  143. package/docs/data-sources.md +35 -15
  144. package/docs/examples/agent-human.md +45 -43
  145. package/docs/examples/ai-sdk-tool.md +20 -16
  146. package/docs/examples/existing-python-backend.md +16 -12
  147. package/docs/examples/nextjs.md +14 -12
  148. package/docs/examples/scoped-agent.md +1 -1
  149. package/docs/examples/server-agent.md +24 -21
  150. package/docs/guarantees.md +15 -13
  151. package/docs/index.md +2 -2
  152. package/docs/integration-guide.md +30 -30
  153. package/docs/interaction-model.md +19 -23
  154. package/docs/mcp/claude-code.md +3 -3
  155. package/docs/mcp/cursor.md +1 -1
  156. package/docs/mcp/windsurf.md +2 -2
  157. package/docs/mcp.md +6 -6
  158. package/docs/quickstart.md +41 -31
  159. package/docs/react.md +13 -9
  160. package/docs/schema-contract.md +12 -10
  161. package/docs/the-loop.md +21 -0
  162. package/examples/data-source/README.md +4 -5
  163. package/examples/data-source/customer-server.ts +27 -25
  164. package/llms.txt +28 -5
  165. package/package.json +43 -3
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The table-creation SQL every ORM adapter ships for its OWN infrastructure tables —
3
+ * `ablo_idempotency` (dedupe by clientTxId) and `ablo_outbox` (transactional
4
+ * outbox the `events()` feed reads). Defined ONCE here so the Prisma adapter, the
5
+ * Drizzle adapter, and `ablo migrate` can never disagree on the shape (they used
6
+ * to inline their own copies, which had already drifted in whitespace).
7
+ *
8
+ * These are NOT model tables and are NOT emitted by the hosted provisioner
9
+ * (`generateProvisionPlan`) — the hosted path uses `sync_deltas` directly. They
10
+ * exist only on a customer's own database in Data Source mode.
11
+ */
12
+ /** Canonical adapter-owned table-creation SQL. Idempotent (`IF NOT EXISTS`). */
13
+ export function adapterTableMigrations() {
14
+ return [
15
+ {
16
+ name: 'ablo_idempotency',
17
+ up: `CREATE TABLE IF NOT EXISTS ablo_idempotency (
18
+ client_tx_id TEXT PRIMARY KEY,
19
+ response JSONB NOT NULL,
20
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
21
+ );`,
22
+ },
23
+ {
24
+ name: 'ablo_outbox',
25
+ up: `CREATE TABLE IF NOT EXISTS ablo_outbox (
26
+ cursor BIGSERIAL PRIMARY KEY,
27
+ id TEXT NOT NULL UNIQUE,
28
+ model TEXT NOT NULL,
29
+ entity_id TEXT NOT NULL,
30
+ type TEXT NOT NULL,
31
+ data JSONB,
32
+ organization_id TEXT,
33
+ client_tx_id TEXT,
34
+ occurred_at BIGINT,
35
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
36
+ );`,
37
+ },
38
+ ];
39
+ }
@@ -0,0 +1,33 @@
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 type { SchemaRecord } from '../schema/schema.js';
24
+ import { type DataSourceOptions } from './index.js';
25
+ /**
26
+ * Next options ARE the core options — the `adapter` field lives on the core
27
+ * handler now, so there is no bridging, no cast, and no per-model-typed boundary
28
+ * at the call site. Pass `{ schema, apiKey, adapter }`.
29
+ */
30
+ export type DataSourceNextOptions<S extends SchemaRecord, TAuth = unknown> = DataSourceOptions<S, TAuth>;
31
+ export declare function dataSourceNext<const S extends SchemaRecord, TAuth = unknown>(options: DataSourceNextOptions<S, TAuth>): {
32
+ readonly POST: (request: Request) => Promise<Response>;
33
+ };
@@ -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
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { getContext } from '../context.js';
6
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;
@@ -197,15 +202,11 @@ export class BootstrapHelper {
197
202
  // conditional revalidation (If-None-Match) implement it at their own
198
203
  // level where they own the cache-key namespace. The 304 branch below
199
204
  // remains defensively in place for when a caller enables revalidation.
200
- const headers = { 'Content-Type': 'application/json' };
201
- if (this.options.authToken) {
202
- headers.Authorization = `Bearer ${this.options.authToken}`;
203
- }
205
+ const headers = withAuthHeaders(this.options.getAuthToken, { 'Content-Type': 'application/json' }, this.options.authToken);
204
206
  this.abortController = new AbortController();
205
207
  const res = await fetch(url, {
206
208
  method: 'GET',
207
209
  headers,
208
- credentials: 'include',
209
210
  signal: this.abortController.signal,
210
211
  });
211
212
  const etag = res.headers.get('ETag');
@@ -272,15 +273,11 @@ export class BootstrapHelper {
272
273
  try {
273
274
  response = await fetch(url, {
274
275
  method: 'GET',
275
- headers: {
276
+ headers: withAuthHeaders(this.options.getAuthToken, {
276
277
  'Content-Type': 'application/json',
277
278
  'Cache-Control': 'no-cache, no-store, must-revalidate',
278
279
  Pragma: 'no-cache',
279
- ...(this.options.authToken
280
- ? { Authorization: `Bearer ${this.options.authToken}` }
281
- : {}),
282
- },
283
- credentials: 'include',
280
+ }, this.options.authToken),
284
281
  signal: this.abortController.signal,
285
282
  cache: 'no-store', // Force browser to not cache
286
283
  });
@@ -337,10 +334,9 @@ export class BootstrapHelper {
337
334
  const url = `${this.options.baseUrl}/sync/entity/${modelName}/${id}`;
338
335
  const response = await fetch(url, {
339
336
  method: 'GET',
340
- headers: {
337
+ headers: withAuthHeaders(this.options.getAuthToken, {
341
338
  'Content-Type': 'application/json',
342
- },
343
- credentials: 'include',
339
+ }, this.options.authToken),
344
340
  });
345
341
  if (response.status === 404) {
346
342
  return null;
@@ -436,7 +432,6 @@ export class BootstrapHelper {
436
432
  try {
437
433
  const response = await fetch(`${this.options.baseUrl}/health`, {
438
434
  method: 'GET',
439
- credentials: 'include',
440
435
  signal: AbortSignal.timeout(5000),
441
436
  cache: 'no-store',
442
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' | 'auth_blocked' | '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
  } | {
@@ -54,8 +55,18 @@ export type ConnectionEvent = {
54
55
  sessionValid: boolean;
55
56
  } | {
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';
57
63
  } | {
58
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';
59
70
  } | {
60
71
  type: 'RECONNECT_SUCCESS';
61
72
  } | {
@@ -70,6 +81,20 @@ export type ConnectionEvent = {
70
81
  export interface ConnectionCallbacks {
71
82
  /** Run bootstrap + WebSocket reconnect. Returns the outcome. */
72
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'>;
73
98
  /** Called when the session is confirmed expired — route to signin. */
74
99
  onSessionExpired: () => void;
75
100
  /** Called to tear down the WebSocket when entering a dead state. */
@@ -90,6 +115,12 @@ export interface ConnectionManagerOptions {
90
115
  * default of `probeNetwork`.
91
116
  */
92
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;
93
124
  /** Override retry ceilings / jitter. Production should leave defaults. */
94
125
  backoff?: Partial<typeof DEFAULT_BACKOFF>;
95
126
  }
@@ -109,8 +140,12 @@ export declare class ConnectionManager {
109
140
  private debounceTimer;
110
141
  private watchdogTimer;
111
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;
112
146
  private disposed;
113
147
  private readonly baseUrl?;
148
+ private readonly getAuthToken?;
114
149
  private readonly backoff;
115
150
  private handleBrowserOnline;
116
151
  private handleBrowserOffline;
@@ -122,6 +157,25 @@ export declare class ConnectionManager {
122
157
  private transition;
123
158
  private onEnterState;
124
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;
125
179
  private runReconnect;
126
180
  private scheduleBackoff;
127
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,9 @@ 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';
165
178
  case 'PROBE_AUTH_BLOCKED':
166
179
  return 'auth_blocked';
167
180
  case 'PROBE_FAILED':
@@ -177,6 +190,7 @@ export class ConnectionManager {
177
190
  case 'TAB_VISIBLE':
178
191
  case 'MANUAL_RETRY':
179
192
  case 'BACKOFF_ELAPSED':
193
+ case 'CREDENTIAL_REFRESHED':
180
194
  return 'probing_network';
181
195
  case 'NETWORK_LOST':
182
196
  return 'offline';
@@ -187,6 +201,8 @@ export class ConnectionManager {
187
201
  switch (event.type) {
188
202
  case 'PROBE_SUCCESS':
189
203
  return event.sessionValid ? 'reconnecting' : 'session_expired';
204
+ case 'PROBE_CREDENTIAL_STALE':
205
+ return 'refreshing_credential';
190
206
  case 'PROBE_AUTH_BLOCKED':
191
207
  return 'auth_blocked';
192
208
  case 'NETWORK_LOST':
@@ -194,6 +210,31 @@ export class ConnectionManager {
194
210
  default:
195
211
  return null;
196
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';
233
+ case 'NETWORK_LOST':
234
+ return 'offline';
235
+ default:
236
+ return null;
237
+ }
197
238
  case 'reconnecting':
198
239
  switch (event.type) {
199
240
  case 'RECONNECT_SUCCESS':
@@ -218,10 +259,11 @@ export class ConnectionManager {
218
259
  return 'probing_network';
219
260
  case 'NETWORK_ONLINE':
220
261
  case 'TAB_VISIBLE':
221
- // Network came back while we were waiting out a backoff
222
- // delay jump straight to probing instead of waiting the
223
- // full exponential interval. Fixes the "doesn't retrigger
224
- // 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.
225
267
  return 'probing_network';
226
268
  case 'NETWORK_LOST':
227
269
  return 'offline';
@@ -235,12 +277,14 @@ export class ConnectionManager {
235
277
  // Reachable, but the data-plane rejected the credential (non-retryable,
236
278
  // non-expiry — e.g. api_key_required, jwt_issuer_untrusted). Don't
237
279
  // auto-reconnect and don't sign out. Allow a manual retry or a
238
- // tab-focus / network-return re-probe (e.g. after a server deploy);
239
- // a network drop parks offline; a genuine session error still expires.
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.
240
283
  switch (event.type) {
241
284
  case 'MANUAL_RETRY':
242
285
  case 'TAB_VISIBLE':
243
286
  case 'NETWORK_ONLINE':
287
+ case 'CREDENTIAL_REFRESHED':
244
288
  return 'probing_network';
245
289
  case 'NETWORK_LOST':
246
290
  return 'offline';
@@ -261,6 +305,9 @@ export class ConnectionManager {
261
305
  switch (state) {
262
306
  case 'connected':
263
307
  this.clearBackoffTimer();
308
+ runInAction(() => {
309
+ this.credentialRefreshAttempts = 0;
310
+ });
264
311
  break;
265
312
  case 'offline':
266
313
  this.clearBackoffTimer();
@@ -275,6 +322,9 @@ export class ConnectionManager {
275
322
  case 'reconnecting':
276
323
  this.runReconnect();
277
324
  break;
325
+ case 'refreshing_credential':
326
+ this.runRefreshCredential();
327
+ break;
278
328
  case 'backoff':
279
329
  this.scheduleBackoff();
280
330
  break;
@@ -299,24 +349,108 @@ export class ConnectionManager {
299
349
  // ── Async operations ─────────────────────────────────────────────────
300
350
  async runProbe() {
301
351
  try {
302
- const result = await probeNetwork(this.baseUrl);
352
+ const result = await probeNetwork({
353
+ baseUrl: this.baseUrl,
354
+ getAuthToken: this.getAuthToken,
355
+ });
303
356
  runInAction(() => {
304
357
  this.lastProbeResult = result;
305
358
  });
306
- if (result.authBlocked) {
307
- this.send({ type: 'PROBE_AUTH_BLOCKED' });
308
- }
309
- else if (result.reachable) {
310
- this.send({ type: 'PROBE_SUCCESS', sessionValid: result.sessionValid ?? true });
311
- }
312
- else {
313
- 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
+ }
314
385
  }
315
386
  }
316
387
  catch {
317
388
  this.send({ type: 'PROBE_FAILED' });
318
389
  }
319
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
+ }
320
454
  async runReconnect() {
321
455
  if (!this.callbacks)
322
456
  return;
@@ -431,6 +565,7 @@ export class ConnectionManager {
431
565
  const isStuck = this.state !== 'connected' &&
432
566
  this.state !== 'session_expired' &&
433
567
  this.state !== 'probing_network' &&
568
+ this.state !== 'refreshing_credential' &&
434
569
  this.state !== 'reconnecting';
435
570
  if (isStuck) {
436
571
  this.stuckCycles++;
@@ -467,7 +602,11 @@ export class ConnectionManager {
467
602
  get isConnected() { return this.state === 'connected'; }
468
603
  get isOffline() { return this.state === 'offline' || this.state === 'waiting_for_network'; }
469
604
  get isReconnecting() {
470
- 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');
471
610
  }
472
611
  get isSessionExpired() { return this.state === 'session_expired'; }
473
612
  get offlineDuration() {