@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
@@ -11,17 +11,22 @@
11
11
  * const sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
12
12
  *
13
13
  * const reports = sync.reports.list({ where: { status: 'todo' } });
14
- * await sync.reports.create({ title: 'Fix bug' });
15
- * await sync.reports.update(reportId, { status: 'ready' });
16
- * await sync.reports.delete(reportId);
14
+ * await sync.reports.create({ data: { title: 'Fix bug' } });
15
+ * await sync.reports.update({
16
+ * id: reportId,
17
+ * data: { status: 'ready' },
18
+ * });
19
+ * await sync.reports.delete({ id: reportId });
17
20
  */
18
21
  import { z } from 'zod';
19
- import { AbloClaimedError, AbloError, AbloConnectionError, AbloValidationError, translateHttpError } from '../errors.js';
22
+ import { AbloClaimedError, AbloError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, translateHttpError, hasWireCode, toAbloError } from '../errors.js';
20
23
  import { LoadStrategy, PropertyType } from '../types/index.js';
21
24
  import { initSyncEngine } from '../context.js';
22
25
  import { noopObservability, browserOnlineStatus, defaultSessionErrorDetector, noopAnalytics, } from '../SyncEngineContext.js';
23
26
  import { alwaysOnline } from '../adapters/alwaysOnline.js';
24
27
  import { validateAbloOptions } from './validateAbloOptions.js';
28
+ import { exchangeApiKey } from '../auth/index.js';
29
+ import { createAuthCredentialSource } from '../auth/credentialSource.js';
25
30
  import { createInternalComponents } from './createInternalComponents.js';
26
31
  import { resolveParticipantIdentity } from './identity.js';
27
32
  import { Model } from '../Model.js';
@@ -32,7 +37,8 @@ import { awaitIntentGrant } from '../sync/awaitIntentGrant.js';
32
37
  import { createSnapshot } from '../sync/createSnapshot.js';
33
38
  import { createParticipantManager } from '../sync/participants.js';
34
39
  import { createProtocolClient, } from './ApiClient.js';
35
- import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveAuthToken, resolveBaseURL, } from './auth.js';
40
+ import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, } from './auth.js';
41
+ import { registerDataSource } from './registerDataSource.js';
36
42
  import { shouldUseInMemoryPersistence, } from './persistence.js';
37
43
  import { createModelProxy } from './createModelProxy.js';
38
44
  // ── Config derivation from schema ─────────────────────────────────────────
@@ -644,6 +650,37 @@ function createDefaultMutationDispatcher(executor) {
644
650
  },
645
651
  };
646
652
  }
653
+ // ── Auth normalization ─────────────────────────────────────────────────────
654
+ /**
655
+ * The one resolver the credential lifecycle needs: an async `() => token | null`,
656
+ * or `null` when auth is static (a plain long-lived `apiKey` with no refresh —
657
+ * the common case). Only the short-lived per-user path sets this, via `getToken`
658
+ * (the primitive) or `authEndpoint` (sugar that POSTs for `{ token }`).
659
+ */
660
+ function resolveCredentialResolver(options) {
661
+ if (options.getToken)
662
+ return options.getToken;
663
+ if (options.authEndpoint) {
664
+ const endpoint = options.authEndpoint;
665
+ const fetchImpl = options.fetch ?? globalThis.fetch;
666
+ return async () => {
667
+ // The endpoint lives on the consumer's OWN backend and is authed by the
668
+ // user's session cookie (hence `credentials: 'include'`); it returns the
669
+ // `ek_` to carry to the sync-server. A non-OK response is terminal
670
+ // (`null` → sign out), matching the `getToken` contract.
671
+ const res = await fetchImpl(endpoint, {
672
+ method: 'POST',
673
+ credentials: 'include',
674
+ headers: { 'Content-Type': 'application/json' },
675
+ });
676
+ if (!res.ok)
677
+ return null;
678
+ const body = (await res.json());
679
+ return body.token ?? null;
680
+ };
681
+ }
682
+ return null;
683
+ }
647
684
  export function Ablo(options) {
648
685
  if (options.schema == null) {
649
686
  return createProtocolClient(options);
@@ -653,8 +690,16 @@ export function Ablo(options) {
653
690
  const authInput = { options, env };
654
691
  const configuredApiKey = resolveApiKey(authInput);
655
692
  const configuredAuthToken = resolveAuthToken(authInput);
693
+ // The client OWNS its credential lifecycle (not the React layer): this resolver
694
+ // drives both the reactive re-mint (FSM `credential_stale`) and the proactive
695
+ // refresh timer + wake/online/focus triggers. Null for the common static
696
+ // `apiKey` path — no refresh needed.
697
+ const credentialResolver = resolveCredentialResolver(options);
698
+ const authCredentials = createAuthCredentialSource(internalOptions.capabilityToken ?? configuredAuthToken);
699
+ const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
656
700
  assertBrowserSafety({
657
701
  apiKey: configuredApiKey,
702
+ databaseUrl: configuredDatabaseUrl,
658
703
  dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
659
704
  });
660
705
  const { logger = consoleLogger } = internalOptions;
@@ -720,7 +765,12 @@ export function Ablo(options) {
720
765
  // the schema-to-Model-class translation depends on private
721
766
  // helpers (`createDynamicModelClass`, `unwrapZodType`, etc.)
722
767
  // that aren't worth pulling into the components module.
723
- const { modelRegistry, objectPool, bootstrapHelper, database, syncClient, hydration, } = createInternalComponents({ schema, url, options: internalOptions });
768
+ const { modelRegistry, objectPool, bootstrapHelper, database, syncClient, hydration, } = createInternalComponents({
769
+ schema,
770
+ url,
771
+ options: internalOptions,
772
+ auth: authCredentials,
773
+ });
724
774
  registerModelsFromSchema(schema, modelRegistry);
725
775
  // 5. BaseSyncedStore handles the initialization orchestration
726
776
  // (open DB → hydrate IDB → connect WS → fetch bootstrap → hydrate again →
@@ -738,7 +788,15 @@ export function Ablo(options) {
738
788
  modelRegistry,
739
789
  schema,
740
790
  url,
791
+ auth: authCredentials,
741
792
  });
793
+ // Hand the credential lifecycle to the client (refresher + proactive refresh
794
+ // timer + wake/online/focus re-mint). Installed once here so refresh works for
795
+ // ANY consumer of `Ablo({ auth })` — not only those who render `<AbloProvider>`.
796
+ // The first mint happens in `ready()` so the first connection carries a token.
797
+ if (credentialResolver) {
798
+ store.startCredentialLifecycle(credentialResolver);
799
+ }
742
800
  // Wire the store back into the default executor's lazy getter (see
743
801
  // `storeHolder` above). The executor was constructed before the store
744
802
  // existed; this late binding closes the loop so commits dispatch over
@@ -808,16 +866,6 @@ export function Ablo(options) {
808
866
  // source of truth. No duplicate closure variables.
809
867
  let _readyPromise = null;
810
868
  let _refreshScheduler = null;
811
- let currentCapabilityToken = internalOptions.capabilityToken ?? configuredAuthToken ?? undefined;
812
- // Wire the cap token into HydrationCoordinator's HTTP path. Without
813
- // this, `ablo.<model>.load(...)` / `ablo.<model>.retrieve(...)` go
814
- // through `postQuery` with `credentials: 'include'` only — fine in
815
- // browsers (session cookies), but Node consumers (agent-worker)
816
- // have no cookies and the request lands with no credential at all.
817
- // The WS path was already wired (token rides the upgrade URL); this
818
- // closes the gap on HTTP. Closure-over-binding so cap rotation
819
- // (`applyRotatedToken` in the refresh scheduler below) propagates.
820
- hydration.setCapabilityTokenProvider(() => currentCapabilityToken ?? null);
821
869
  async function ready() {
822
870
  if (_readyPromise)
823
871
  return _readyPromise;
@@ -827,6 +875,33 @@ export function Ablo(options) {
827
875
  }
828
876
  _readyPromise = (async () => {
829
877
  try {
878
+ // Mint the FIRST access credential before we connect, so the initial
879
+ // WebSocket upgrade + bootstrap carry a valid bearer (no tokenless first
880
+ // connect that has to self-heal). Only when a refreshing resolver is
881
+ // wired AND no static credential is already present. Contract mirrors
882
+ // `getToken`: `null` ⇒ the login is gone (terminal — fail ready so the
883
+ // app shows sign-in); a THROW ⇒ transient (rethrown; autoStart swallows
884
+ // and the lifecycle's online/wake triggers retry).
885
+ if (credentialResolver && !authCredentials.getAuthToken()) {
886
+ const token = await credentialResolver();
887
+ if (!token) {
888
+ throw new AbloAuthenticationError('Auth resolver returned null before connect — the user is not signed in.', { code: 'auth_no_credentials' });
889
+ }
890
+ authCredentials.setAuthToken(token);
891
+ }
892
+ // Register the caller's own database for write-back BEFORE bootstrap, so
893
+ // the server resolves this org's data plane to the customer's DB rather
894
+ // than serving an empty/wrong store. The org is derived server-side from
895
+ // the API key. Idempotent server-side (register-or-update). Skipped when
896
+ // no `databaseUrl` was configured (Ablo-managed storage).
897
+ if (configuredDatabaseUrl) {
898
+ await registerDataSource({
899
+ baseUrl: resolveBootstrapBaseUrl({ url }),
900
+ apiKey: await resolveApiKeyValue(configuredApiKey),
901
+ databaseUrl: configuredDatabaseUrl,
902
+ ...(internalOptions.fetch ? { fetchImpl: internalOptions.fetch } : {}),
903
+ });
904
+ }
830
905
  // Resolve participant identity + scope. Three branches —
831
906
  // hosted-cloud apiKey exchange, self-derived from capability
832
907
  // token, or legacy explicit options. See `./identity.ts`.
@@ -836,15 +911,18 @@ export function Ablo(options) {
836
911
  url,
837
912
  kind,
838
913
  configuredApiKey,
839
- configuredAuthToken,
914
+ // Resolve identity against the LIVE token, not the construction-time
915
+ // `configuredAuthToken`. Consumers using `getToken` (apps/web) never
916
+ // pass `authToken` at construction — they call `setAuthToken()` before
917
+ // `ready()`, which updates the shared credential source. Reading the frozen
918
+ // `configuredAuthToken` here made `/auth/identity` fire with no Bearer
919
+ // (→ `no_matching_provider` / `session_expired`) even though the JWT
920
+ // was present. Mirrors every other transport by reading the shared
921
+ // credential source.
922
+ configuredAuthToken: authCredentials.getAuthToken() ?? configuredAuthToken,
840
923
  bootstrapHelper,
924
+ auth: authCredentials,
841
925
  logger,
842
- applyRotatedToken: (token) => {
843
- currentCapabilityToken = token;
844
- bootstrapHelper.setAuthToken(token);
845
- const ws = store.getSyncWebSocket();
846
- ws?.setCapabilityToken(token);
847
- },
848
926
  });
849
927
  const { userId, accountScope, teamIds, capabilityToken, syncGroups, participantKind, } = resolved;
850
928
  // Fail-loud guard: detect the degenerate "no real sync groups
@@ -870,8 +948,6 @@ export function Ablo(options) {
870
948
  '`["org:${orgId}", "user:${userId}"]`) or verify your auth ' +
871
949
  'provider populates them. See packages/sync-engine/src/client/identity.ts.', { participantKind, resolvedSyncGroups });
872
950
  }
873
- currentCapabilityToken = capabilityToken;
874
- bootstrapHelper.setAuthToken(capabilityToken);
875
951
  if (resolved.refreshScheduler) {
876
952
  _refreshScheduler = resolved.refreshScheduler;
877
953
  }
@@ -902,7 +978,11 @@ export function Ablo(options) {
902
978
  }
903
979
  const result = current.value;
904
980
  if (!result.success) {
905
- throw result.error ?? new Error('Sync engine initialization failed');
981
+ throw result.error
982
+ ? toAbloError(result.error)
983
+ : new AbloConnectionError('Sync engine initialization failed', {
984
+ code: 'bootstrap_fetch_timeout',
985
+ });
906
986
  }
907
987
  // Wire presence + intents to the now-open transport.
908
988
  // `getSyncWebSocket()` returns non-null after a successful
@@ -916,11 +996,33 @@ export function Ablo(options) {
916
996
  logger.info('Sync engine ready', { models: Object.keys(schema.models).length });
917
997
  }
918
998
  catch (err) {
919
- const error = err instanceof Error ? err : new Error(String(err));
999
+ // Coerce so the rejection a consumer awaiting `ready()` catches is
1000
+ // always an AbloError — connection setup is held to the same
1001
+ // never-leak-untagged contract as the model operations.
1002
+ const error = toAbloError(err);
920
1003
  // Make sure syncStatus reflects the failure for observer() components
921
1004
  store.syncStatus.state = 'error';
922
1005
  store.syncStatus.error = error;
923
- logger.error('Sync engine failed to initialize', { error: error.message });
1006
+ // Log the typed envelope (type + code + status), not just the bare
1007
+ // message — so the console line names it as an Ablo error and carries
1008
+ // the code (e.g. AbloAuthenticationError/identity_resolve_failed on a
1009
+ // 401) instead of reading like an untagged failure.
1010
+ logger.error('Sync engine failed to initialize', {
1011
+ type: error.type,
1012
+ code: error.code,
1013
+ httpStatus: error.httpStatus,
1014
+ error: error.message,
1015
+ });
1016
+ // Clear the memo so a FUTURE `ready()` re-attempts bootstrap instead of
1017
+ // replaying this rejection forever. Bootstrap failures here are
1018
+ // transient by nature — offline, an IndexedDB open timeout, a bootstrap
1019
+ // fetch hiccup — and used to brick the engine until a full page reload
1020
+ // because line ~2013 (`if (_readyPromise) return _readyPromise`) handed
1021
+ // every later caller this same dead promise. Nulling it lets the
1022
+ // provider's online/wake/retry triggers drive a clean re-bootstrap.
1023
+ // (The terminal `_validationError` branch above intentionally stays
1024
+ // cached — config can't change without recreating the engine.)
1025
+ _readyPromise = null;
924
1026
  throw error;
925
1027
  }
926
1028
  })();
@@ -951,14 +1053,7 @@ export function Ablo(options) {
951
1053
  }
952
1054
  const fetchImpl = options.fetch ?? globalThis.fetch;
953
1055
  function authHeaders() {
954
- const headers = { 'Content-Type': 'application/json' };
955
- if (currentCapabilityToken) {
956
- headers.Authorization = `Bearer ${currentCapabilityToken}`;
957
- }
958
- else if (configuredAuthToken) {
959
- headers.Authorization = `Bearer ${configuredAuthToken}`;
960
- }
961
- return headers;
1056
+ return authCredentials.withAuthHeaders({ 'Content-Type': 'application/json' });
962
1057
  }
963
1058
  function createClientTxId(idempotencyKey) {
964
1059
  if (idempotencyKey && idempotencyKey.length > 0)
@@ -1007,11 +1102,15 @@ export function Ablo(options) {
1007
1102
  return inputOperations.map((op) => normalizeCommitOperation(op, commitOptions));
1008
1103
  }
1009
1104
  function modelClaimFromActive(intent) {
1105
+ const description = typeof intent.target.meta?.description === 'string'
1106
+ ? intent.target.meta.description
1107
+ : undefined;
1010
1108
  return {
1011
1109
  id: intent.id,
1012
1110
  actor: intent.heldBy,
1013
1111
  participantKind: intent.participantKind,
1014
1112
  action: intent.reason,
1113
+ ...(description ? { description } : {}),
1015
1114
  field: intent.target.field,
1016
1115
  status: 'active',
1017
1116
  expiresAt: intent.expiresAt,
@@ -1031,6 +1130,7 @@ export function Ablo(options) {
1031
1130
  actor: intent.heldBy,
1032
1131
  participantKind: intent.participantKind,
1033
1132
  action: intent.action,
1133
+ ...(intent.description ? { description: intent.description } : {}),
1034
1134
  field: intent.target.field,
1035
1135
  status: 'queued',
1036
1136
  position: intent.position,
@@ -1279,7 +1379,6 @@ export function Ablo(options) {
1279
1379
  const res = await fetchImpl(`${bootstrapHelper.baseUrl}/sync/query`, {
1280
1380
  method: 'POST',
1281
1381
  headers: authHeaders(),
1282
- credentials: 'include',
1283
1382
  body: JSON.stringify({
1284
1383
  queries: [
1285
1384
  {
@@ -1321,59 +1420,59 @@ export function Ablo(options) {
1321
1420
  }
1322
1421
  function model(name) {
1323
1422
  return {
1324
- retrieve(id, options) {
1325
- return retrieveModel(name, id, options);
1423
+ retrieve(params) {
1424
+ return retrieveModel(name, params.id, params);
1326
1425
  },
1327
- async create(data, mutationOptions) {
1328
- const id = mutationOptions?.id ?? createModelId();
1329
- await applyClaimedPolicy({ model: name, id }, mutationOptions);
1426
+ async create(params) {
1427
+ const id = params.id ?? createModelId();
1428
+ await applyClaimedPolicy({ model: name, id }, params);
1330
1429
  return commits.create({
1331
- intent: mutationOptions?.intent,
1332
- idempotencyKey: mutationOptions?.idempotencyKey,
1333
- readAt: mutationOptions?.readAt,
1334
- onStale: mutationOptions?.onStale,
1335
- wait: mutationOptions?.wait,
1430
+ intent: params.intent,
1431
+ idempotencyKey: params.idempotencyKey,
1432
+ readAt: params.readAt,
1433
+ onStale: params.onStale,
1434
+ wait: params.wait,
1336
1435
  operations: [
1337
1436
  {
1338
1437
  action: 'create',
1339
1438
  model: name,
1340
1439
  id,
1341
- data,
1440
+ data: params.data,
1342
1441
  },
1343
1442
  ],
1344
1443
  });
1345
1444
  },
1346
- async update(id, data, mutationOptions) {
1347
- await applyClaimedPolicy({ model: name, id }, mutationOptions);
1445
+ async update(params) {
1446
+ await applyClaimedPolicy({ model: name, id: params.id }, params);
1348
1447
  return commits.create({
1349
- intent: mutationOptions?.intent,
1350
- idempotencyKey: mutationOptions?.idempotencyKey,
1351
- readAt: mutationOptions?.readAt,
1352
- onStale: mutationOptions?.onStale,
1353
- wait: mutationOptions?.wait,
1448
+ intent: params.intent,
1449
+ idempotencyKey: params.idempotencyKey,
1450
+ readAt: params.readAt,
1451
+ onStale: params.onStale,
1452
+ wait: params.wait,
1354
1453
  operations: [
1355
1454
  {
1356
1455
  action: 'update',
1357
1456
  model: name,
1358
- id,
1359
- data,
1457
+ id: params.id,
1458
+ data: params.data,
1360
1459
  },
1361
1460
  ],
1362
1461
  });
1363
1462
  },
1364
- async delete(id, mutationOptions) {
1365
- await applyClaimedPolicy({ model: name, id }, mutationOptions);
1463
+ async delete(params) {
1464
+ await applyClaimedPolicy({ model: name, id: params.id }, params);
1366
1465
  return commits.create({
1367
- intent: mutationOptions?.intent,
1368
- idempotencyKey: mutationOptions?.idempotencyKey,
1369
- readAt: mutationOptions?.readAt,
1370
- onStale: mutationOptions?.onStale,
1371
- wait: mutationOptions?.wait,
1466
+ intent: params.intent,
1467
+ idempotencyKey: params.idempotencyKey,
1468
+ readAt: params.readAt,
1469
+ onStale: params.onStale,
1470
+ wait: params.wait,
1372
1471
  operations: [
1373
1472
  {
1374
1473
  action: 'delete',
1375
1474
  model: name,
1376
- id,
1475
+ id: params.id,
1377
1476
  },
1378
1477
  ],
1379
1478
  });
@@ -1384,6 +1483,83 @@ export function Ablo(options) {
1384
1483
  ...modelProxies,
1385
1484
  ready,
1386
1485
  waitForFlush,
1486
+ setAuthToken(token) {
1487
+ // The single credential source is read lazily by bootstrap HTTP,
1488
+ // lazy query HTTP, network probes, and WebSocket reconnect URL auth.
1489
+ // Updating it here is enough for the next request/connect to use the
1490
+ // refreshed token; no per-transport patching.
1491
+ authCredentials.setAuthToken(token);
1492
+ // A fresh credential is useless to a connection parked in offline /
1493
+ // backoff / auth_blocked until the next probe trigger — so kick one now.
1494
+ // Harmless while connected (the FSM ignores the nudge there).
1495
+ store.nudgeReconnect();
1496
+ },
1497
+ async getAuthToken() {
1498
+ // The live short-lived bearer (set via `setAuthToken`/`getToken` refresh)
1499
+ // is the canonical credential; fall back to a configured API key.
1500
+ return (authCredentials.getAuthToken() ??
1501
+ (await resolveApiKeyValue(configuredApiKey)) ??
1502
+ configuredAuthToken ??
1503
+ null);
1504
+ },
1505
+ setCredentialRefresher(refresher) {
1506
+ store.setCredentialRefresher(refresher);
1507
+ },
1508
+ nudgeReconnect() {
1509
+ store.nudgeReconnect();
1510
+ },
1511
+ sessions: {
1512
+ // Stripe `ephemeralKeys.create` shape: a BACKEND (holding `sk_`) mints a
1513
+ // short-lived scoped token for one end user OR one agent. Thin wrapper over
1514
+ // the `/auth/capability` exchange, reshaped to a Stripe-style resource.
1515
+ async create(params) {
1516
+ const apiKey = await resolveApiKeyValue(configuredApiKey);
1517
+ if (!apiKey) {
1518
+ throw new AbloAuthenticationError('sessions.create requires a secret (sk_) API key — call it from your backend, not the browser.', { code: 'apikey_missing' });
1519
+ }
1520
+ const baseUrl = resolveBootstrapBaseUrl({
1521
+ url,
1522
+ bootstrapBaseUrl: internalOptions.bootstrapBaseUrl,
1523
+ });
1524
+ // Discriminate the union: `{ user }` → full-authority `ek_` (no op
1525
+ // allowlist); `{ agent, can }` → scoped `rk_`. `can: { Task: ['update'] }`
1526
+ // serializes to the wire allowlist `['task.update']` — the Hub matches
1527
+ // `${model.toLowerCase()}.${op}` (Hub.ts handleCommit).
1528
+ let participantKind;
1529
+ let participantId;
1530
+ let operations;
1531
+ if (params.user) {
1532
+ participantKind = 'user';
1533
+ participantId = params.user.id;
1534
+ operations = undefined;
1535
+ }
1536
+ else {
1537
+ participantKind = 'agent';
1538
+ participantId = params.agent.id;
1539
+ operations = Object.entries(params.can).flatMap(([model, ops]) => (ops ?? []).map((op) => `${model.toLowerCase()}.${op}`));
1540
+ }
1541
+ const res = await exchangeApiKey({
1542
+ apiKey,
1543
+ baseUrl,
1544
+ participantKind,
1545
+ participantId,
1546
+ ...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
1547
+ ...(operations ? { operations } : {}),
1548
+ ttlSeconds: params.ttlSeconds ?? 900,
1549
+ ...(params.userMeta ? { userMeta: params.userMeta } : {}),
1550
+ ...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
1551
+ });
1552
+ return {
1553
+ object: 'session',
1554
+ id: res.capabilityId,
1555
+ token: res.token,
1556
+ expiresAt: res.expiresAt,
1557
+ organizationId: res.organizationId,
1558
+ scope: res.scope,
1559
+ userMeta: res.userMeta,
1560
+ };
1561
+ },
1562
+ },
1387
1563
  async dispose() {
1388
1564
  _refreshScheduler?.dispose();
1389
1565
  _refreshScheduler = null;
@@ -1476,10 +1652,7 @@ export function Ablo(options) {
1476
1652
  async beginTurn(beginOptions) {
1477
1653
  const baseUrl = url.replace(/\/+$/, '');
1478
1654
  const turnUrl = `${baseUrl.replace(/^ws/, 'http')}/api/agent/turn`;
1479
- const headers = { 'Content-Type': 'application/json' };
1480
- if (currentCapabilityToken) {
1481
- headers.Authorization = `Bearer ${currentCapabilityToken}`;
1482
- }
1655
+ const headers = authCredentials.withAuthHeaders({ 'Content-Type': 'application/json' });
1483
1656
  const res = await fetch(turnUrl, {
1484
1657
  method: 'POST',
1485
1658
  headers,
@@ -1491,8 +1664,24 @@ export function Ablo(options) {
1491
1664
  }),
1492
1665
  });
1493
1666
  if (!res.ok) {
1494
- const body = await res.text().catch(() => '<no body>');
1495
- throw new AbloError(`beginTurn failed: ${res.status} ${body}`, { code: 'turn_open_failed', httpStatus: res.status });
1667
+ const text = await res.text().catch(() => '');
1668
+ let parsed = text;
1669
+ if (text) {
1670
+ try {
1671
+ parsed = JSON.parse(text);
1672
+ }
1673
+ catch {
1674
+ /* keep raw text */
1675
+ }
1676
+ }
1677
+ // Preserve the server's structured envelope (code/message/doc_url) when
1678
+ // present; fall back to turn_open_failed for a bare/non-Ablo body.
1679
+ throw hasWireCode(parsed)
1680
+ ? translateHttpError(res.status, parsed, res.headers.get('x-request-id') ?? undefined)
1681
+ : new AbloError(`beginTurn failed: ${res.status} ${text}`, {
1682
+ code: 'turn_open_failed',
1683
+ httpStatus: res.status,
1684
+ });
1496
1685
  }
1497
1686
  const json = (await res.json());
1498
1687
  const turnId = json.turnId;
@@ -1515,8 +1704,22 @@ export function Ablo(options) {
1515
1704
  }),
1516
1705
  });
1517
1706
  if (!closeRes.ok) {
1518
- const body = await closeRes.text().catch(() => '<no body>');
1519
- throw new AbloError(`closeTurn failed: ${closeRes.status} ${body}`, { code: 'turn_close_failed', httpStatus: closeRes.status });
1707
+ const text = await closeRes.text().catch(() => '');
1708
+ let parsed = text;
1709
+ if (text) {
1710
+ try {
1711
+ parsed = JSON.parse(text);
1712
+ }
1713
+ catch {
1714
+ /* keep raw text */
1715
+ }
1716
+ }
1717
+ throw hasWireCode(parsed)
1718
+ ? translateHttpError(closeRes.status, parsed, closeRes.headers.get('x-request-id') ?? undefined)
1719
+ : new AbloError(`closeTurn failed: ${closeRes.status} ${text}`, {
1720
+ code: 'turn_close_failed',
1721
+ httpStatus: closeRes.status,
1722
+ });
1520
1723
  }
1521
1724
  };
1522
1725
  const dispose = () => {
@@ -73,10 +73,42 @@ export interface CapabilityRevocation {
73
73
  readonly deleted: boolean;
74
74
  readonly activeSessionsClosed?: number;
75
75
  }
76
+ export interface CapabilityRotateOptions {
77
+ /**
78
+ * Overlap window — the OLD token keeps authenticating for this long after
79
+ * rotation, so you can deploy the replacement with zero downtime. Default
80
+ * 24h server-side.
81
+ */
82
+ readonly grace?: Duration;
83
+ readonly graceSeconds?: number;
84
+ /**
85
+ * Lifetime of the REPLACEMENT capability. Omit to inherit the original's
86
+ * lifetime.
87
+ */
88
+ readonly lease?: Duration;
89
+ readonly leaseSeconds?: number;
90
+ }
91
+ /** The fresh capability returned by `rotate`, plus a pointer to the old one. */
92
+ export interface RotatedCapability extends Capability {
93
+ /**
94
+ * The capability that was rotated out. Its token keeps working until
95
+ * `expiresAt` (the end of the grace window), then expires.
96
+ */
97
+ readonly rotatedFrom: {
98
+ readonly id: string;
99
+ readonly expiresAt: string;
100
+ };
101
+ }
76
102
  export interface CapabilityResource {
77
103
  create(options: CapabilityCreateOptions): Promise<Capability>;
78
104
  retrieve(id: string): Promise<CapabilityRecord>;
79
105
  revoke(id: string): Promise<CapabilityRevocation>;
106
+ /**
107
+ * Rotate with overlap (Stripe's "roll" model): mint a fresh capability
108
+ * carrying the SAME scope, and keep the old token working for a grace
109
+ * window so you can roll out the replacement without downtime.
110
+ */
111
+ rotate(id: string, options?: CapabilityRotateOptions): Promise<RotatedCapability>;
80
112
  /**
81
113
  * Alias for `create`. Kept because "mint" is common capability-token
82
114
  * language, but `create` is the canonical SDK verb.
@@ -168,12 +200,20 @@ export interface AgentModelMutationOptions extends Omit<ModelMutationOptions, 'i
168
200
  } | null;
169
201
  }
170
202
  export interface AgentModelClient<T = Record<string, unknown>> {
171
- retrieve(id: string, options?: AgentModelReadOptions): Promise<ModelRead<T>>;
172
- create(data: Record<string, unknown>, options?: AgentModelMutationOptions & {
203
+ retrieve(params: AgentModelReadOptions & {
204
+ readonly id: string;
205
+ }): Promise<ModelRead<T>>;
206
+ create(params: AgentModelMutationOptions & {
207
+ readonly data: Record<string, unknown>;
173
208
  readonly id?: string | null;
174
209
  }): Promise<CommitReceipt>;
175
- update(id: string, data: Record<string, unknown>, options?: AgentModelMutationOptions): Promise<CommitReceipt>;
176
- delete(id: string, options?: AgentModelMutationOptions): Promise<CommitReceipt>;
210
+ update(params: AgentModelMutationOptions & {
211
+ readonly id: string;
212
+ readonly data: Record<string, unknown>;
213
+ }): Promise<CommitReceipt>;
214
+ delete(params: AgentModelMutationOptions & {
215
+ readonly id: string;
216
+ }): Promise<CommitReceipt>;
177
217
  }
178
218
  export interface AgentRunContext {
179
219
  readonly task: Task;
@@ -196,5 +236,13 @@ export interface AbloApi {
196
236
  agent(id: string, options: AgentOptions): Agent;
197
237
  model<T = Record<string, unknown>>(name: string): ModelClient<T>;
198
238
  beginTurn(options: TaskCreateOptions): Promise<Turn>;
239
+ /**
240
+ * Resolve the active bearer credential this client authenticates with — the
241
+ * same token its own requests carry in `Authorization`. Returns `null` when
242
+ * no credential is configured. Async because the API key may be supplied as
243
+ * an async setter. Use it to authenticate side-band requests to the same
244
+ * sync-server (e.g. the S3 presign endpoint) without re-minting.
245
+ */
246
+ getAuthToken(): Promise<string | null>;
199
247
  }
200
248
  export declare function createProtocolClient(options: AbloApiClientOptions): AbloApi;