@abloatai/ablo 0.6.0 → 0.8.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 (121) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +95 -57
  3. package/dist/BaseSyncedStore.d.ts +1 -1
  4. package/dist/BaseSyncedStore.js +8 -4
  5. package/dist/SyncEngineContext.d.ts +2 -1
  6. package/dist/SyncEngineContext.js +5 -3
  7. package/dist/agent/session.js +3 -2
  8. package/dist/auth/index.js +39 -11
  9. package/dist/client/Ablo.d.ts +112 -3
  10. package/dist/client/Ablo.js +144 -10
  11. package/dist/client/ApiClient.d.ts +32 -0
  12. package/dist/client/ApiClient.js +76 -44
  13. package/dist/client/auth.d.ts +11 -1
  14. package/dist/client/auth.js +21 -2
  15. package/dist/client/createModelProxy.d.ts +120 -53
  16. package/dist/client/createModelProxy.js +66 -31
  17. package/dist/client/identity.js +14 -0
  18. package/dist/client/registerDataSource.d.ts +19 -0
  19. package/dist/client/registerDataSource.js +57 -0
  20. package/dist/client/validateAbloOptions.d.ts +2 -1
  21. package/dist/client/validateAbloOptions.js +8 -7
  22. package/dist/coordination/index.d.ts +6 -0
  23. package/dist/coordination/index.js +6 -0
  24. package/dist/coordination/schema.d.ts +329 -0
  25. package/dist/coordination/schema.js +209 -0
  26. package/dist/core/QueryView.d.ts +4 -1
  27. package/dist/core/QueryView.js +1 -1
  28. package/dist/core/query-utils.d.ts +7 -10
  29. package/dist/core/query-utils.js +2 -3
  30. package/dist/errorCodes.d.ts +286 -0
  31. package/dist/errorCodes.js +284 -0
  32. package/dist/errors.d.ts +103 -7
  33. package/dist/errors.js +192 -41
  34. package/dist/index.d.ts +11 -6
  35. package/dist/index.js +10 -6
  36. package/dist/keys/index.d.ts +61 -0
  37. package/dist/keys/index.js +151 -0
  38. package/dist/policy/index.d.ts +1 -1
  39. package/dist/policy/index.js +1 -1
  40. package/dist/policy/types.d.ts +31 -0
  41. package/dist/policy/types.js +15 -0
  42. package/dist/query/client.js +19 -8
  43. package/dist/react/AbloProvider.d.ts +37 -0
  44. package/dist/react/AbloProvider.js +107 -4
  45. package/dist/react/ClientSideSuspense.d.ts +1 -1
  46. package/dist/react/DefaultFallback.d.ts +1 -1
  47. package/dist/react/SyncGroupProvider.d.ts +1 -1
  48. package/dist/react/index.d.ts +3 -2
  49. package/dist/react/index.js +3 -2
  50. package/dist/react/useAblo.d.ts +4 -4
  51. package/dist/react/useAblo.js +10 -5
  52. package/dist/react/useReactive.js +16 -3
  53. package/dist/schema/ddl.d.ts +62 -0
  54. package/dist/schema/ddl.js +317 -0
  55. package/dist/schema/diff.d.ts +6 -0
  56. package/dist/schema/diff.js +21 -3
  57. package/dist/schema/field.d.ts +16 -19
  58. package/dist/schema/field.js +30 -17
  59. package/dist/schema/index.d.ts +7 -4
  60. package/dist/schema/index.js +9 -3
  61. package/dist/schema/model.d.ts +87 -25
  62. package/dist/schema/model.js +33 -3
  63. package/dist/schema/relation.d.ts +17 -0
  64. package/dist/schema/roles.d.ts +148 -0
  65. package/dist/schema/roles.js +149 -0
  66. package/dist/schema/schema.d.ts +2 -112
  67. package/dist/schema/schema.js +50 -62
  68. package/dist/schema/select.d.ts +25 -0
  69. package/dist/schema/select.js +55 -0
  70. package/dist/schema/serialize.d.ts +16 -12
  71. package/dist/schema/serialize.js +16 -12
  72. package/dist/schema/sugar.d.ts +20 -3
  73. package/dist/schema/sugar.js +5 -1
  74. package/dist/schema/tenancy.d.ts +66 -0
  75. package/dist/schema/tenancy.js +58 -0
  76. package/dist/sync/BootstrapHelper.js +46 -27
  77. package/dist/sync/ConnectionManager.d.ts +3 -1
  78. package/dist/sync/ConnectionManager.js +37 -1
  79. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  80. package/dist/sync/HydrationCoordinator.js +26 -19
  81. package/dist/sync/NetworkProbe.d.ts +8 -0
  82. package/dist/sync/NetworkProbe.js +24 -2
  83. package/dist/sync/SyncWebSocket.d.ts +1 -1
  84. package/dist/sync/SyncWebSocket.js +43 -53
  85. package/dist/sync/createIntentStream.d.ts +2 -1
  86. package/dist/sync/createIntentStream.js +46 -1
  87. package/dist/sync/participants.js +10 -16
  88. package/dist/transactions/TransactionQueue.js +13 -1
  89. package/dist/types/streams.d.ts +53 -33
  90. package/docs/api-keys.md +47 -3
  91. package/docs/api.md +103 -57
  92. package/docs/audit.md +16 -9
  93. package/docs/cli.md +222 -0
  94. package/docs/client-behavior.md +35 -21
  95. package/docs/coordination.md +74 -36
  96. package/docs/data-sources.md +23 -21
  97. package/docs/examples/agent-human.md +72 -28
  98. package/docs/examples/ai-sdk-tool.md +14 -11
  99. package/docs/examples/existing-python-backend.md +30 -19
  100. package/docs/examples/nextjs.md +21 -8
  101. package/docs/examples/scoped-agent.md +93 -0
  102. package/docs/examples/server-agent.md +27 -5
  103. package/docs/guarantees.md +29 -17
  104. package/docs/identity.md +198 -121
  105. package/docs/index.md +35 -18
  106. package/docs/integration-guide.md +79 -83
  107. package/docs/interaction-model.md +40 -25
  108. package/docs/mcp/claude-code.md +9 -17
  109. package/docs/mcp/cursor.md +6 -24
  110. package/docs/mcp/windsurf.md +6 -19
  111. package/docs/mcp.md +103 -26
  112. package/docs/quickstart.md +31 -39
  113. package/docs/react.md +18 -14
  114. package/docs/roadmap.md +15 -3
  115. package/docs/schema-contract.md +109 -0
  116. package/examples/README.md +8 -4
  117. package/examples/data-source/README.md +6 -2
  118. package/examples/data-source/run.ts +4 -3
  119. package/examples/quickstart.ts +1 -1
  120. package/llms.txt +27 -16
  121. package/package.json +13 -1
@@ -88,6 +88,21 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
88
88
  * usually don't pass this explicitly server-side.
89
89
  */
90
90
  apiKey?: string | ApiKeySetter | null | undefined;
91
+ /**
92
+ * Connection string to YOUR OWN Postgres. When set, Ablo registers this
93
+ * database as your project's data store and writes synced rows back into it
94
+ * (dedicated/BYO tenant), so your data stays canonical in your DB while Ablo
95
+ * runs the sync/coordination plane. Defaults to `process.env['DATABASE_URL']`.
96
+ *
97
+ * SERVER-ONLY: this carries credentials, so it is never sent from the browser
98
+ * — constructing a client with `databaseUrl` and `dangerouslyAllowBrowser`
99
+ * throws. Provide Ablo a NON-superuser, non-`BYPASSRLS` role: the server runs
100
+ * the tenant plane with row-level security forced, and rejects a privileged
101
+ * role that couldn't enforce isolation.
102
+ *
103
+ * Omit it to use Ablo-managed storage (the hosted default).
104
+ */
105
+ databaseUrl?: string | null | undefined;
91
106
  /**
92
107
  * Local persistence mode. Pass `indexeddb` only when you want offline
93
108
  * queueing and a reload-surviving browser cache.
@@ -112,8 +127,8 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
112
127
  defaultQuery?: Record<string, string | undefined> | undefined;
113
128
  /**
114
129
  * Client-side use is disabled by default because private API keys should
115
- * not ship to browsers. Set this only when using a publishable/browser-safe
116
- * key or a controlled server proxy.
130
+ * not ship to browsers. Set this only when the browser holds a minted
131
+ * session token (`ek_`/`rk_`) or you route through a controlled server proxy.
117
132
  */
118
133
  dangerouslyAllowBrowser?: boolean | undefined;
119
134
  }
@@ -168,7 +183,7 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
168
183
  * Client-side use of this SDK is disabled by default — your apiKey
169
184
  * would ship to every visitor's network tab. Only set this to
170
185
  * `true` if you've understood the risk and have appropriate
171
- * mitigations (a publishable key, a server-side proxy, etc).
186
+ * mitigations (a minted session token, a server-side proxy, etc).
172
187
  */
173
188
  dangerouslyAllowBrowser?: boolean | undefined;
174
189
  /**
@@ -459,6 +474,72 @@ export interface ModelClient<T = Record<string, unknown>> {
459
474
  update(id: string, data: Record<string, unknown>, options?: ModelMutationOptions): Promise<CommitReceipt>;
460
475
  delete(id: string, options?: ModelMutationOptions): Promise<CommitReceipt>;
461
476
  }
477
+ /** A single data operation a scoped **agent** session may perform on a model. */
478
+ export type SessionOperation = 'read' | 'create' | 'update' | 'delete';
479
+ /** Mint params for an **end-user** session — full data authority within the
480
+ * org (the Stripe `ephemeralKeys.create` / Supabase session shape). Mints an
481
+ * `ek_` token. `user.id` is your end user's external IdP id (becomes the
482
+ * session's `participantId`); Ablo does not model your users, so it's an
483
+ * honest string at the trust boundary. */
484
+ export interface CreateUserSessionParams {
485
+ /** Your end user. `id` becomes the token's `participantId`. */
486
+ user: {
487
+ id: string;
488
+ };
489
+ /** Sync groups this session may subscribe to. Omit to inherit the key's scope. */
490
+ syncGroups?: readonly string[];
491
+ /** Token lifetime in seconds. Defaults to 900 (15m, the Stripe ephemeral default). */
492
+ ttlSeconds?: number;
493
+ /** Opaque identity blob echoed back to the client as `ablo.user`. */
494
+ userMeta?: Record<string, unknown>;
495
+ agent?: never;
496
+ can?: never;
497
+ }
498
+ /** Mint params for a scoped **agent** session — mints a restricted `rk_` token
499
+ * gated to exactly the operations named in `can`. `can` is typed off your
500
+ * schema (no magic `'task.update'` strings): `{ Task: ['update'], Deck: ['read'] }`
501
+ * — the SDK serializes each entry to the wire allowlist (`task.update`). */
502
+ export interface CreateAgentSessionParams<S extends SchemaRecord> {
503
+ /** Your agent. `id` becomes the token's `participantId`. */
504
+ agent: {
505
+ id: string;
506
+ };
507
+ /** Per-model operation allowlist, typed against the schema's model names. */
508
+ can: {
509
+ [M in keyof S & string]?: readonly SessionOperation[];
510
+ };
511
+ /** Sync groups this session may subscribe to. Omit to inherit the key's scope. */
512
+ syncGroups?: readonly string[];
513
+ /** Token lifetime in seconds. Defaults to 900 (15m, the Stripe ephemeral default). */
514
+ ttlSeconds?: number;
515
+ /** Opaque identity blob echoed back to the client as `ablo.agent`. */
516
+ userMeta?: Record<string, unknown>;
517
+ user?: never;
518
+ }
519
+ /** Params for {@link Ablo.sessions}.create — a discriminated union: pass
520
+ * `{ user }` for a full-authority end-user session (`ek_`) or `{ agent, can }`
521
+ * for a scoped agent session (`rk_`). */
522
+ export type CreateSessionParams<S extends SchemaRecord> = CreateUserSessionParams | CreateAgentSessionParams<S>;
523
+ /** A minted end-user session token — the Stripe ephemeral-key / Supabase
524
+ * session resource. `token` is the secret the browser presents as its bearer. */
525
+ export interface AbloSession {
526
+ object: 'session';
527
+ /** Stable id of the minted credential (for revocation). */
528
+ id: string;
529
+ /** The short-lived `rk_` session token. Hand this to the user's browser. */
530
+ token: string;
531
+ /** ISO-8601 expiry. */
532
+ expiresAt: string;
533
+ organizationId: string;
534
+ scope: {
535
+ organizationId: string;
536
+ syncGroups: readonly string[];
537
+ operations: readonly string[];
538
+ participantKind: 'user' | 'agent' | 'system';
539
+ participantId: string;
540
+ };
541
+ userMeta: Record<string, unknown>;
542
+ }
462
543
  /** The typed sync engine client — one property per model in the schema */
463
544
  export type Ablo<S extends SchemaRecord> = {
464
545
  readonly [K in keyof S & string]: ModelOperations<InferModel<Schema<S>, K>, InferCreate<Schema<S>, K>>;
@@ -502,6 +583,31 @@ export type Ablo<S extends SchemaRecord> = {
502
583
  waitForFlush(timeoutMs?: number): Promise<void>;
503
584
  /** Disconnect and clean up */
504
585
  dispose(): Promise<void>;
586
+ /**
587
+ * Replace the bearer auth token used for the WebSocket upgrade and HTTP
588
+ * requests, WITHOUT tearing down the engine. Use to push a refreshed
589
+ * short-lived token (e.g. a 15m JWT) before it expires — `<AbloProvider>`'s
590
+ * `getToken` refresh loop calls this. Reuses the same rotation path as the
591
+ * internal capability-token refresh; safe to call before `ready()`.
592
+ */
593
+ setAuthToken(token: string): void;
594
+ /**
595
+ * Mint a short-lived, scoped **session token** for one end user — the
596
+ * Stripe `ephemeralKeys.create` / Supabase session shape. Call this on YOUR
597
+ * BACKEND (where the `sk_` secret key lives), then hand the returned
598
+ * `token` to that user's browser (typically via an authEndpoint the client
599
+ * fetches). The browser presents it as the bearer; the sync-server verifies
600
+ * the scoped `rk_` token via `apiKeyProvider`.
601
+ *
602
+ * The browser must NEVER see the `sk_` key — only the per-user session token.
603
+ *
604
+ * Pass `{ user: { id } }` for a full-authority end-user session (mints `ek_`),
605
+ * or `{ agent: { id }, can: { Task: ['update'] } }` for a scoped agent
606
+ * session (mints `rk_`); `can` is typed against your schema's model names.
607
+ */
608
+ sessions: {
609
+ create(params: CreateSessionParams<S>): Promise<AbloSession>;
610
+ };
505
611
  /**
506
612
  * Destroy every IndexedDB database owned by this engine. Disconnects
507
613
  * the WebSocket, releases timers, and deletes all `ablo_*` / `ablo-*`
@@ -766,6 +872,8 @@ export declare namespace Ablo {
766
872
  type CapabilityRecord = import('./ApiClient.js').CapabilityRecord;
767
873
  type CapabilityResource = import('./ApiClient.js').CapabilityResource;
768
874
  type CapabilityRevocation = import('./ApiClient.js').CapabilityRevocation;
875
+ type CapabilityRotateOptions = import('./ApiClient.js').CapabilityRotateOptions;
876
+ type RotatedCapability = import('./ApiClient.js').RotatedCapability;
769
877
  type Task = import('./ApiClient.js').Task;
770
878
  type TaskCreateOptions = import('./ApiClient.js').TaskCreateOptions;
771
879
  type TaskCloseOptions = import('./ApiClient.js').TaskCloseOptions;
@@ -784,6 +892,7 @@ export declare namespace Ablo {
784
892
  type ActiveIntent = _Streams.ActiveIntent;
785
893
  type Claim = _Streams.Claim;
786
894
  type IntentRejection = _Streams.IntentRejection;
895
+ type IntentLost = _Streams.IntentLost;
787
896
  type Snapshot<TSchema extends _SchemaTypes.Schema = _SchemaTypes.Schema, K extends keyof TSchema['models'] = keyof TSchema['models']> = _Streams.Snapshot<TSchema, K>;
788
897
  type Turn = import('./Ablo.js').Turn;
789
898
  namespace Auth {
@@ -16,12 +16,13 @@
16
16
  * await sync.reports.delete(reportId);
17
17
  */
18
18
  import { z } from 'zod';
19
- import { AbloClaimedError, AbloError, AbloConnectionError, AbloValidationError, translateHttpError } from '../errors.js';
19
+ import { AbloClaimedError, AbloError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, translateHttpError, hasWireCode, toAbloError } from '../errors.js';
20
20
  import { LoadStrategy, PropertyType } from '../types/index.js';
21
21
  import { initSyncEngine } from '../context.js';
22
22
  import { noopObservability, browserOnlineStatus, defaultSessionErrorDetector, noopAnalytics, } from '../SyncEngineContext.js';
23
23
  import { alwaysOnline } from '../adapters/alwaysOnline.js';
24
24
  import { validateAbloOptions } from './validateAbloOptions.js';
25
+ import { exchangeApiKey } from '../auth/index.js';
25
26
  import { createInternalComponents } from './createInternalComponents.js';
26
27
  import { resolveParticipantIdentity } from './identity.js';
27
28
  import { Model } from '../Model.js';
@@ -32,7 +33,8 @@ import { awaitIntentGrant } from '../sync/awaitIntentGrant.js';
32
33
  import { createSnapshot } from '../sync/createSnapshot.js';
33
34
  import { createParticipantManager } from '../sync/participants.js';
34
35
  import { createProtocolClient, } from './ApiClient.js';
35
- import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveAuthToken, resolveBaseURL, } from './auth.js';
36
+ import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, } from './auth.js';
37
+ import { registerDataSource } from './registerDataSource.js';
36
38
  import { shouldUseInMemoryPersistence, } from './persistence.js';
37
39
  import { createModelProxy } from './createModelProxy.js';
38
40
  // ── Config derivation from schema ─────────────────────────────────────────
@@ -653,8 +655,10 @@ export function Ablo(options) {
653
655
  const authInput = { options, env };
654
656
  const configuredApiKey = resolveApiKey(authInput);
655
657
  const configuredAuthToken = resolveAuthToken(authInput);
658
+ const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
656
659
  assertBrowserSafety({
657
660
  apiKey: configuredApiKey,
661
+ databaseUrl: configuredDatabaseUrl,
658
662
  dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
659
663
  });
660
664
  const { logger = consoleLogger } = internalOptions;
@@ -827,6 +831,19 @@ export function Ablo(options) {
827
831
  }
828
832
  _readyPromise = (async () => {
829
833
  try {
834
+ // Register the caller's own database for write-back BEFORE bootstrap, so
835
+ // the server resolves this org's data plane to the customer's DB rather
836
+ // than serving an empty/wrong store. The org is derived server-side from
837
+ // the API key. Idempotent server-side (register-or-update). Skipped when
838
+ // no `databaseUrl` was configured (Ablo-managed storage).
839
+ if (configuredDatabaseUrl) {
840
+ await registerDataSource({
841
+ baseUrl: resolveBootstrapBaseUrl({ url }),
842
+ apiKey: await resolveApiKeyValue(configuredApiKey),
843
+ databaseUrl: configuredDatabaseUrl,
844
+ ...(internalOptions.fetch ? { fetchImpl: internalOptions.fetch } : {}),
845
+ });
846
+ }
830
847
  // Resolve participant identity + scope. Three branches —
831
848
  // hosted-cloud apiKey exchange, self-derived from capability
832
849
  // token, or legacy explicit options. See `./identity.ts`.
@@ -836,7 +853,15 @@ export function Ablo(options) {
836
853
  url,
837
854
  kind,
838
855
  configuredApiKey,
839
- configuredAuthToken,
856
+ // Resolve identity against the LIVE token, not the construction-time
857
+ // `configuredAuthToken`. Consumers using `getToken` (apps/web) never
858
+ // pass `authToken` at construction — they call `setAuthToken()` before
859
+ // `ready()`, which updates `currentCapabilityToken`. Reading the frozen
860
+ // `configuredAuthToken` here made `/auth/identity` fire with no Bearer
861
+ // (→ `no_matching_provider` / `session_expired`) even though the JWT
862
+ // was present. Mirrors `authHeaders()`'s `currentCapabilityToken ??
863
+ // configuredAuthToken` precedence.
864
+ configuredAuthToken: currentCapabilityToken ?? configuredAuthToken,
840
865
  bootstrapHelper,
841
866
  logger,
842
867
  applyRotatedToken: (token) => {
@@ -902,7 +927,11 @@ export function Ablo(options) {
902
927
  }
903
928
  const result = current.value;
904
929
  if (!result.success) {
905
- throw result.error ?? new Error('Sync engine initialization failed');
930
+ throw result.error
931
+ ? toAbloError(result.error)
932
+ : new AbloConnectionError('Sync engine initialization failed', {
933
+ code: 'bootstrap_fetch_timeout',
934
+ });
906
935
  }
907
936
  // Wire presence + intents to the now-open transport.
908
937
  // `getSyncWebSocket()` returns non-null after a successful
@@ -916,11 +945,23 @@ export function Ablo(options) {
916
945
  logger.info('Sync engine ready', { models: Object.keys(schema.models).length });
917
946
  }
918
947
  catch (err) {
919
- const error = err instanceof Error ? err : new Error(String(err));
948
+ // Coerce so the rejection a consumer awaiting `ready()` catches is
949
+ // always an AbloError — connection setup is held to the same
950
+ // never-leak-untagged contract as the model operations.
951
+ const error = toAbloError(err);
920
952
  // Make sure syncStatus reflects the failure for observer() components
921
953
  store.syncStatus.state = 'error';
922
954
  store.syncStatus.error = error;
923
- logger.error('Sync engine failed to initialize', { error: error.message });
955
+ // Log the typed envelope (type + code + status), not just the bare
956
+ // message — so the console line names it as an Ablo error and carries
957
+ // the code (e.g. AbloAuthenticationError/identity_resolve_failed on a
958
+ // 401) instead of reading like an untagged failure.
959
+ logger.error('Sync engine failed to initialize', {
960
+ type: error.type,
961
+ code: error.code,
962
+ httpStatus: error.httpStatus,
963
+ error: error.message,
964
+ });
924
965
  throw error;
925
966
  }
926
967
  })();
@@ -1211,6 +1252,7 @@ export function Ablo(options) {
1211
1252
  entities: { [modelKey]: id },
1212
1253
  }),
1213
1254
  queue: (target) => publicIntents.queueFor({ type: target.model, id: target.id }),
1255
+ reorder: (target, order) => publicIntents.reorder({ type: target.model, id: target.id }, order),
1214
1256
  observe: (target) => {
1215
1257
  // The live intent stream only tracks *open* (active) claims;
1216
1258
  // terminal states (committed / expired / canceled) drop out of
@@ -1383,6 +1425,68 @@ export function Ablo(options) {
1383
1425
  ...modelProxies,
1384
1426
  ready,
1385
1427
  waitForFlush,
1428
+ setAuthToken(token) {
1429
+ // Same rotation path as the internal capability-token refresh
1430
+ // (`applyRotatedToken` in `ready()`): update the closure binding the
1431
+ // HTTP hydration provider reads, push to the bootstrap helper's header,
1432
+ // and swap it on the live WebSocket. Decoupled from `ready()` so a
1433
+ // refreshed JWT can be pushed at any point in the engine's lifetime.
1434
+ currentCapabilityToken = token;
1435
+ bootstrapHelper.setAuthToken(token);
1436
+ store.getSyncWebSocket()?.setCapabilityToken(token);
1437
+ },
1438
+ sessions: {
1439
+ // Stripe `ephemeralKeys.create` shape: a BACKEND (holding `sk_`) mints a
1440
+ // short-lived scoped token for one end user OR one agent. Thin wrapper over
1441
+ // the `/auth/capability` exchange, reshaped to a Stripe-style resource.
1442
+ async create(params) {
1443
+ const apiKey = await resolveApiKeyValue(configuredApiKey);
1444
+ if (!apiKey) {
1445
+ throw new AbloAuthenticationError('sessions.create requires a secret (sk_) API key — call it from your backend, not the browser.', { code: 'apikey_missing' });
1446
+ }
1447
+ const baseUrl = resolveBootstrapBaseUrl({
1448
+ url,
1449
+ bootstrapBaseUrl: internalOptions.bootstrapBaseUrl,
1450
+ });
1451
+ // Discriminate the union: `{ user }` → full-authority `ek_` (no op
1452
+ // allowlist); `{ agent, can }` → scoped `rk_`. `can: { Task: ['update'] }`
1453
+ // serializes to the wire allowlist `['task.update']` — the Hub matches
1454
+ // `${model.toLowerCase()}.${op}` (Hub.ts handleCommit).
1455
+ let participantKind;
1456
+ let participantId;
1457
+ let operations;
1458
+ if (params.user) {
1459
+ participantKind = 'user';
1460
+ participantId = params.user.id;
1461
+ operations = undefined;
1462
+ }
1463
+ else {
1464
+ participantKind = 'agent';
1465
+ participantId = params.agent.id;
1466
+ operations = Object.entries(params.can).flatMap(([model, ops]) => (ops ?? []).map((op) => `${model.toLowerCase()}.${op}`));
1467
+ }
1468
+ const res = await exchangeApiKey({
1469
+ apiKey,
1470
+ baseUrl,
1471
+ participantKind,
1472
+ participantId,
1473
+ ...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
1474
+ ...(operations ? { operations } : {}),
1475
+ ttlSeconds: params.ttlSeconds ?? 900,
1476
+ ...(params.userMeta ? { userMeta: params.userMeta } : {}),
1477
+ ...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
1478
+ });
1479
+ return {
1480
+ object: 'session',
1481
+ id: res.capabilityId,
1482
+ token: res.token,
1483
+ expiresAt: res.expiresAt,
1484
+ organizationId: res.organizationId,
1485
+ scope: res.scope,
1486
+ userMeta: res.userMeta,
1487
+ };
1488
+ },
1489
+ },
1386
1490
  async dispose() {
1387
1491
  _refreshScheduler?.dispose();
1388
1492
  _refreshScheduler = null;
@@ -1490,8 +1594,24 @@ export function Ablo(options) {
1490
1594
  }),
1491
1595
  });
1492
1596
  if (!res.ok) {
1493
- const body = await res.text().catch(() => '<no body>');
1494
- throw new AbloError(`beginTurn failed: ${res.status} ${body}`, { code: 'turn_open_failed', httpStatus: res.status });
1597
+ const text = await res.text().catch(() => '');
1598
+ let parsed = text;
1599
+ if (text) {
1600
+ try {
1601
+ parsed = JSON.parse(text);
1602
+ }
1603
+ catch {
1604
+ /* keep raw text */
1605
+ }
1606
+ }
1607
+ // Preserve the server's structured envelope (code/message/doc_url) when
1608
+ // present; fall back to turn_open_failed for a bare/non-Ablo body.
1609
+ throw hasWireCode(parsed)
1610
+ ? translateHttpError(res.status, parsed, res.headers.get('x-request-id') ?? undefined)
1611
+ : new AbloError(`beginTurn failed: ${res.status} ${text}`, {
1612
+ code: 'turn_open_failed',
1613
+ httpStatus: res.status,
1614
+ });
1495
1615
  }
1496
1616
  const json = (await res.json());
1497
1617
  const turnId = json.turnId;
@@ -1514,8 +1634,22 @@ export function Ablo(options) {
1514
1634
  }),
1515
1635
  });
1516
1636
  if (!closeRes.ok) {
1517
- const body = await closeRes.text().catch(() => '<no body>');
1518
- throw new AbloError(`closeTurn failed: ${closeRes.status} ${body}`, { code: 'turn_close_failed', httpStatus: closeRes.status });
1637
+ const text = await closeRes.text().catch(() => '');
1638
+ let parsed = text;
1639
+ if (text) {
1640
+ try {
1641
+ parsed = JSON.parse(text);
1642
+ }
1643
+ catch {
1644
+ /* keep raw text */
1645
+ }
1646
+ }
1647
+ throw hasWireCode(parsed)
1648
+ ? translateHttpError(closeRes.status, parsed, closeRes.headers.get('x-request-id') ?? undefined)
1649
+ : new AbloError(`closeTurn failed: ${closeRes.status} ${text}`, {
1650
+ code: 'turn_close_failed',
1651
+ httpStatus: closeRes.status,
1652
+ });
1519
1653
  }
1520
1654
  };
1521
1655
  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.
@@ -439,6 +439,35 @@ export function createProtocolClient(options) {
439
439
  activeSessionsClosed: body.activeSessionsClosed,
440
440
  };
441
441
  },
442
+ async rotate(id, rotateOptions = {}) {
443
+ const graceSeconds = rotateOptions.graceSeconds ??
444
+ (rotateOptions.grace !== undefined ? toSeconds(rotateOptions.grace) : undefined);
445
+ const leaseSeconds = rotateOptions.leaseSeconds ??
446
+ (rotateOptions.lease !== undefined ? toSeconds(rotateOptions.lease) : undefined);
447
+ const body = await requestJson(`/v1/capabilities/${encodeURIComponent(id)}/rotate`, {
448
+ method: 'POST',
449
+ body: JSON.stringify({
450
+ ...(graceSeconds !== undefined ? { graceSeconds } : {}),
451
+ ...(leaseSeconds !== undefined ? { ttlSeconds: leaseSeconds } : {}),
452
+ }),
453
+ });
454
+ const newId = body.capabilityId ?? body.id;
455
+ if (!newId) {
456
+ throw new AbloValidationError('Capability rotate response did not include an id.', { code: 'capability_id_missing' });
457
+ }
458
+ return {
459
+ id: newId,
460
+ token: body.token,
461
+ expiresAt: body.expiresAt,
462
+ organizationId: body.organizationId,
463
+ scope: body.scope,
464
+ rotatedFrom: {
465
+ id: body.rotatedFrom.capabilityId ?? body.rotatedFrom.id ?? id,
466
+ expiresAt: body.rotatedFrom.expiresAt,
467
+ },
468
+ client: () => childClient(body.token),
469
+ };
470
+ },
442
471
  mint(options) {
443
472
  return capabilities.create(options);
444
473
  },
@@ -548,6 +577,50 @@ export function createProtocolClient(options) {
548
577
  claims: query.claims ?? [],
549
578
  };
550
579
  }
580
+ /**
581
+ * Single-op mutation over the model-scoped routes — the canonical surface
582
+ * that mirrors `ablo.<model>.create/update/delete`:
583
+ *
584
+ * POST /v1/models/:model create
585
+ * PATCH /v1/models/:model/:id update
586
+ * DELETE /v1/models/:model/:id delete
587
+ *
588
+ * This replaces the previous indirection through `POST /v1/commits`. The raw
589
+ * `commits.create(...)` resource is still the path for ATOMIC MULTI-OP
590
+ * envelopes — this helper is the one-op, one-record path only.
591
+ */
592
+ async function mutateModel(action, modelName, id, data, options) {
593
+ const clientTxId = createClientTxId(options?.idempotencyKey);
594
+ const encModel = encodeURIComponent(modelName);
595
+ const path = action === 'create'
596
+ ? `/v1/models/${encModel}`
597
+ : `/v1/models/${encModel}/${encodeURIComponent(id)}`;
598
+ const method = action === 'create' ? 'POST' : action === 'update' ? 'PATCH' : 'DELETE';
599
+ const requestBody = {
600
+ idempotencyKey: clientTxId,
601
+ intent: normalizeIntentId(options?.intent),
602
+ onStale: options?.onStale,
603
+ readAt: options?.readAt,
604
+ };
605
+ if (action === 'create')
606
+ requestBody.id = id;
607
+ if (data !== undefined)
608
+ requestBody.data = data;
609
+ const body = await requestJson(path, {
610
+ method,
611
+ idempotencyKey: clientTxId,
612
+ body: JSON.stringify(requestBody),
613
+ });
614
+ // `requestJson` throws via `translateHttpError` on any non-2xx, so reaching
615
+ // here implies success. Narrow `status` to the `CommitWait`-compatible
616
+ // subset; `'rejected'` only appears on a thrown rejection body.
617
+ const status = body.status === 'queued' ? 'queued' : 'confirmed';
618
+ return {
619
+ id: body.serverTxId ?? body.id ?? body.clientTxId ?? clientTxId,
620
+ status,
621
+ lastSyncId: body.lastSyncId,
622
+ };
623
+ }
551
624
  function model(name) {
552
625
  return {
553
626
  retrieve(id, options) {
@@ -556,56 +629,15 @@ export function createProtocolClient(options) {
556
629
  async create(data, mutationOptions) {
557
630
  const id = mutationOptions?.id ?? createModelId();
558
631
  await applyClaimedPolicy({ model: name, id }, mutationOptions);
559
- return commits.create({
560
- intent: mutationOptions?.intent,
561
- idempotencyKey: mutationOptions?.idempotencyKey,
562
- readAt: mutationOptions?.readAt,
563
- onStale: mutationOptions?.onStale,
564
- wait: mutationOptions?.wait,
565
- operations: [
566
- {
567
- action: 'create',
568
- model: name,
569
- id,
570
- data,
571
- },
572
- ],
573
- });
632
+ return mutateModel('create', name, id, data, mutationOptions);
574
633
  },
575
634
  async update(id, data, mutationOptions) {
576
635
  await applyClaimedPolicy({ model: name, id }, mutationOptions);
577
- return commits.create({
578
- intent: mutationOptions?.intent,
579
- idempotencyKey: mutationOptions?.idempotencyKey,
580
- readAt: mutationOptions?.readAt,
581
- onStale: mutationOptions?.onStale,
582
- wait: mutationOptions?.wait,
583
- operations: [
584
- {
585
- action: 'update',
586
- model: name,
587
- id,
588
- data,
589
- },
590
- ],
591
- });
636
+ return mutateModel('update', name, id, data, mutationOptions);
592
637
  },
593
638
  async delete(id, mutationOptions) {
594
639
  await applyClaimedPolicy({ model: name, id }, mutationOptions);
595
- return commits.create({
596
- intent: mutationOptions?.intent,
597
- idempotencyKey: mutationOptions?.idempotencyKey,
598
- readAt: mutationOptions?.readAt,
599
- onStale: mutationOptions?.onStale,
600
- wait: mutationOptions?.wait,
601
- operations: [
602
- {
603
- action: 'delete',
604
- model: name,
605
- id,
606
- },
607
- ],
608
- });
640
+ return mutateModel('delete', name, id, undefined, mutationOptions);
609
641
  },
610
642
  };
611
643
  }
@@ -29,6 +29,7 @@ export interface AuthResolveInput {
29
29
  readonly apiKey?: string | ApiKeySetter | null;
30
30
  readonly authToken?: string | null;
31
31
  readonly baseURL?: string | null;
32
+ readonly databaseUrl?: string | null;
32
33
  readonly dangerouslyAllowBrowser?: boolean;
33
34
  };
34
35
  readonly env: Record<string, string | undefined>;
@@ -41,16 +42,25 @@ export interface AuthResolveInput {
41
42
  export declare function readProcessEnv(): Record<string, string | undefined>;
42
43
  export declare function resolveApiKey(input: AuthResolveInput): string | ApiKeySetter | null;
43
44
  export declare function resolveAuthToken(input: AuthResolveInput): string | null;
45
+ /**
46
+ * Resolve the customer's own-Postgres connection string for write-back
47
+ * (dedicated/BYO tenant). Falls back to `DATABASE_URL` — the Prisma-style
48
+ * convention — so a server-side app that already exports it needs no extra
49
+ * config. Returns null for Ablo-managed storage (the hosted default).
50
+ */
51
+ export declare function resolveDatabaseUrl(input: AuthResolveInput): string | null;
44
52
  export declare const ABLO_DEFAULT_BASE_URL = "wss://mesh.ablo.finance";
45
53
  export declare function resolveBaseURL(input: AuthResolveInput): string;
46
54
  /**
47
55
  * Browser guard — apiKey is server-side-only by default. Same check
48
56
  * Anthropic, OpenAI, and Stripe ship: shipping `sk_live_...` to a
49
57
  * browser exposes it in every visitor's network tab. Consumers opt
50
- * in explicitly when they have a publishable key or a server proxy.
58
+ * in explicitly when the browser holds a minted session token
59
+ * (`ek_`/`rk_`) or routes through a server proxy.
51
60
  */
52
61
  export declare function assertBrowserSafety(input: {
53
62
  apiKey: string | ApiKeySetter | null;
63
+ databaseUrl?: string | null;
54
64
  dangerouslyAllowBrowser: boolean | undefined;
55
65
  }): void;
56
66
  /**