@abloatai/ablo 0.11.0 → 0.11.2

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 (75) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +72 -25
  3. package/dist/Model.d.ts +39 -0
  4. package/dist/Model.js +68 -0
  5. package/dist/auth/credentialPolicy.d.ts +145 -0
  6. package/dist/auth/credentialPolicy.js +130 -0
  7. package/dist/cli.cjs +154 -25
  8. package/dist/client/Ablo.d.ts +39 -88
  9. package/dist/client/Ablo.js +54 -99
  10. package/dist/client/ApiClient.d.ts +10 -1
  11. package/dist/client/ApiClient.js +23 -12
  12. package/dist/client/auth.d.ts +21 -9
  13. package/dist/client/auth.js +42 -6
  14. package/dist/client/createModelProxy.d.ts +74 -10
  15. package/dist/client/createModelProxy.js +85 -4
  16. package/dist/client/httpClient.d.ts +17 -3
  17. package/dist/client/httpClient.js +1 -0
  18. package/dist/client/identity.js +134 -122
  19. package/dist/client/index.d.ts +1 -1
  20. package/dist/client/sessionMint.d.ts +15 -0
  21. package/dist/client/sessionMint.js +86 -0
  22. package/dist/errorCodes.d.ts +2 -0
  23. package/dist/errorCodes.js +3 -1
  24. package/dist/errors.d.ts +3 -2
  25. package/dist/errors.js +3 -2
  26. package/dist/index.d.ts +4 -4
  27. package/dist/index.js +4 -7
  28. package/dist/mutators/RecordingTransaction.js +14 -42
  29. package/dist/react/AbloProvider.d.ts +1 -6
  30. package/dist/react/AbloProvider.js +1 -5
  31. package/dist/react/context.d.ts +1 -31
  32. package/dist/react/context.js +2 -2
  33. package/dist/react/index.d.ts +0 -6
  34. package/dist/react/index.js +0 -7
  35. package/dist/react/useSyncStatus.d.ts +1 -1
  36. package/dist/realtime/index.d.ts +1 -1
  37. package/dist/schema/generate.js +1 -2
  38. package/dist/schema/schema.d.ts +16 -5
  39. package/dist/schema/schema.js +26 -0
  40. package/dist/surface.d.ts +29 -0
  41. package/dist/surface.js +60 -0
  42. package/dist/sync/ConnectionManager.d.ts +16 -5
  43. package/dist/sync/ConnectionManager.js +42 -7
  44. package/dist/transactions/TransactionQueue.js +22 -10
  45. package/dist/types/global.d.ts +11 -3
  46. package/dist/types/global.js +8 -3
  47. package/dist/types/streams.d.ts +0 -22
  48. package/dist/utils/mobx-setup.js +1 -0
  49. package/docs/api-keys.md +49 -0
  50. package/docs/api.md +6 -5
  51. package/docs/client-behavior.md +7 -3
  52. package/docs/coordination.md +88 -24
  53. package/docs/data-sources.md +29 -9
  54. package/docs/examples/existing-python-backend.md +9 -5
  55. package/docs/examples/scoped-agent.md +1 -1
  56. package/docs/guarantees.md +4 -3
  57. package/docs/identity.md +89 -82
  58. package/docs/integration-guide.md +19 -10
  59. package/docs/migration.md +49 -2
  60. package/docs/quickstart.md +65 -33
  61. package/docs/react.md +49 -3
  62. package/docs/schema-contract.md +23 -5
  63. package/llms-full.txt +43 -24
  64. package/llms.txt +17 -15
  65. package/package.json +1 -1
  66. package/dist/api/index.d.ts +0 -10
  67. package/dist/api/index.js +0 -9
  68. package/dist/principal.d.ts +0 -44
  69. package/dist/principal.js +0 -49
  70. package/dist/react/SyncGroupProvider.d.ts +0 -19
  71. package/dist/react/SyncGroupProvider.js +0 -44
  72. package/dist/react/useClaim.d.ts +0 -29
  73. package/dist/react/useClaim.js +0 -42
  74. package/dist/react/usePresence.d.ts +0 -32
  75. package/dist/react/usePresence.js +0 -41
@@ -27,7 +27,7 @@ import { initSyncEngine } from '../context.js';
27
27
  import { noopObservability, browserOnlineStatus, defaultSessionErrorDetector, noopAnalytics, } from '../SyncEngineContext.js';
28
28
  import { alwaysOnline } from '../adapters/alwaysOnline.js';
29
29
  import { validateAbloOptions } from './validateAbloOptions.js';
30
- import { exchangeApiKey, mintUserSessionKey } from '../auth/index.js';
30
+ import { mintSession } from './sessionMint.js';
31
31
  import { createAuthCredentialSource } from '../auth/credentialSource.js';
32
32
  import { createInternalComponents } from './createInternalComponents.js';
33
33
  import { resolveParticipantIdentity } from './identity.js';
@@ -42,7 +42,7 @@ import { createProtocolClient, } from './ApiClient.js';
42
42
  // Value import is cycle-safe: httpClient.js only value-imports ApiClient.js,
43
43
  // which imports this module type-only.
44
44
  import { createAbloHttpClient, } from './httpClient.js';
45
- import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, } from './auth.js';
45
+ import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfDatabaseUrlEnvIgnored, } from './auth.js';
46
46
  import { registerDataSource } from './registerDataSource.js';
47
47
  import { shouldUseInMemoryPersistence, } from './persistence.js';
48
48
  import { createModelProxy } from './createModelProxy.js';
@@ -670,32 +670,20 @@ function createDefaultMutationDispatcher(executor) {
670
670
  // ── Auth normalization ─────────────────────────────────────────────────────
671
671
  /**
672
672
  * The one resolver the credential lifecycle needs: an async `() => token | null`,
673
- * or `null` when auth is static (a plain long-lived `apiKey` with no refresh —
674
- * the common case). Only the short-lived per-user path sets this, via `getToken`
675
- * (the primitive) or `authEndpoint` (sugar that POSTs for `{ token }`).
673
+ * or `null` when auth is static (a plain long-lived `apiKey` STRING with no
674
+ * refresh — the common case).
675
+ *
676
+ * The short-lived per-user browser path passes a FUNCTION `apiKey` (an
677
+ * `ApiKeySetter`): the SDK then drives the full credential lifecycle off it —
678
+ * mint-before-connect, the proactive refresh timer + wake/online/focus re-mint,
679
+ * and the reactive `credential_stale` re-mint. The resolver's contract is the
680
+ * `ApiKeySetter` contract end-to-end: resolve a token, resolve `null` when the
681
+ * login is gone (terminal → `session_expired` → sign out), or THROW on a
682
+ * transient failure (→ back off, never sign out).
676
683
  */
677
- function resolveCredentialResolver(options) {
678
- if (options.getToken)
679
- return options.getToken;
680
- if (options.authEndpoint) {
681
- const endpoint = options.authEndpoint;
682
- const fetchImpl = options.fetch ?? globalThis.fetch;
683
- return async () => {
684
- // The endpoint lives on the consumer's OWN backend and is authed by the
685
- // user's session cookie (hence `credentials: 'include'`); it returns the
686
- // `ek_` to carry to the sync-server. A non-OK response is terminal
687
- // (`null` → sign out), matching the `getToken` contract.
688
- const res = await fetchImpl(endpoint, {
689
- method: 'POST',
690
- credentials: 'include',
691
- headers: { 'Content-Type': 'application/json' },
692
- });
693
- if (!res.ok)
694
- return null;
695
- const body = (await res.json());
696
- return body.token ?? null;
697
- };
698
- }
684
+ function resolveCredentialResolver(apiKey) {
685
+ if (typeof apiKey === 'function')
686
+ return apiKey;
699
687
  return null;
700
688
  }
701
689
  export function Ablo(options) {
@@ -716,7 +704,7 @@ export function Ablo(options) {
716
704
  // drives both the reactive re-mint (FSM `credential_stale`) and the proactive
717
705
  // refresh timer + wake/online/focus triggers. Null for the common static
718
706
  // `apiKey` path — no refresh needed.
719
- const credentialResolver = resolveCredentialResolver(options);
707
+ const credentialResolver = resolveCredentialResolver(configuredApiKey);
720
708
  const authCredentials = createAuthCredentialSource(internalOptions.capabilityToken ?? configuredAuthToken);
721
709
  const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
722
710
  assertBrowserSafety({
@@ -725,6 +713,9 @@ export function Ablo(options) {
725
713
  dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
726
714
  });
727
715
  const { logger = consoleLogger } = internalOptions;
716
+ // Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
717
+ // passed — the env value is no longer auto-adopted (see resolveDatabaseUrl).
718
+ warnIfDatabaseUrlEnvIgnored(authInput, (m) => logger.warn(m));
728
719
  const schema = options.schema;
729
720
  const url = resolveBaseURL(authInput);
730
721
  // 1. Derive config from schema
@@ -897,7 +888,7 @@ export function Ablo(options) {
897
888
  // WebSocket upgrade + bootstrap carry a valid bearer (no tokenless first
898
889
  // connect that has to self-heal). Only when a refreshing resolver is
899
890
  // wired AND no static credential is already present. Contract mirrors
900
- // `getToken`: `null` ⇒ the login is gone (terminal — fail ready so the
891
+ // the `apiKey` resolver: `null` ⇒ the login is gone (terminal — fail ready so the
901
892
  // app shows sign-in); a THROW ⇒ transient (rethrown; autoStart swallows
902
893
  // and the lifecycle's online/wake triggers retry).
903
894
  if (credentialResolver && !authCredentials.getAuthToken()) {
@@ -930,8 +921,9 @@ export function Ablo(options) {
930
921
  kind,
931
922
  configuredApiKey,
932
923
  // Resolve identity against the LIVE token, not the construction-time
933
- // `configuredAuthToken`. Consumers using `getToken` (apps/web) never
934
- // pass `authToken` at construction — they call `setAuthToken()` before
924
+ // `configuredAuthToken`. Consumers using a function `apiKey` (apps/web)
925
+ // never pass `authToken` at construction — the lifecycle mints the
926
+ // first `ek_`/`rk_` and calls `setAuthToken()` before
935
927
  // `ready()`, which updates the shared credential source. Reading the frozen
936
928
  // `configuredAuthToken` here made `/auth/identity` fire with no Bearer
937
929
  // (→ `no_matching_provider` / `session_expired`) even though the JWT
@@ -1244,14 +1236,8 @@ export function Ablo(options) {
1244
1236
  const current = listModelClaims(target);
1245
1237
  if (current.length === 0)
1246
1238
  return;
1247
- if (policy === 'fail')
1248
- throw claimedError(target, current, 'model_claimed');
1249
- const queue = listModelClaimQueue(target);
1250
- if (options?.maxQueueDepth !== undefined &&
1251
- queue.length >= options.maxQueueDepth) {
1252
- throw claimedError(target, current, 'queue_too_deep');
1253
- }
1254
- await waitForModelUnclaimed(target, { timeout: options?.claimedTimeout });
1239
+ // policy === 'fail' — gate the read only when the caller opts in.
1240
+ throw claimedError(target, current, 'model_claimed');
1255
1241
  }
1256
1242
  function wrapClaimHandle(claim, waited = false) {
1257
1243
  const release = async () => {
@@ -1370,6 +1356,26 @@ export function Ablo(options) {
1370
1356
  },
1371
1357
  waitFor: (target, waitOptions) => publicClaims.waitFor({ model: target.model, id: target.id }, waitOptions),
1372
1358
  selfParticipantId: participantId,
1359
+ selfParticipantKind: kind,
1360
+ // Read-interest / write-intent enrolment for the typed surface.
1361
+ // `enterScope`/`pinScope` resolve the `{ [schemaKey]: id }` scope
1362
+ // through the SAME resolver the claim path uses, landing this client in
1363
+ // the entity-scoped group the holder's claim presence fans out on.
1364
+ // Return the store promise so the claim write path can AWAIT pinScope
1365
+ // BEFORE acquiring the lease (closing the subscribe-vs-broadcast race);
1366
+ // read-interest callers (`retrieve`/`claim.state`) still `void` it and
1367
+ // stay fire-and-forget. SOFT either way — the store swallows reconcile
1368
+ // errors so read interest never makes a read reject or stall.
1369
+ enterScope: (scope) => store.enterScope(scope),
1370
+ pinScope: (scope) => store.pinScope(scope),
1371
+ // `ablo.<model>.watch(ids, { ttl })` → a scoped participant join on
1372
+ // this model's sync group(s). The model-scoped relocation of the old
1373
+ // `ablo.participants.join({ scope: { <model>: ids } })`. WebSocket
1374
+ // only — `join` throws AbloConnectionError if the socket isn't ready.
1375
+ createWatch: (modelKey, ids, options) => participantManager.join({
1376
+ scope: { [modelKey]: ids },
1377
+ ...(options?.ttl !== undefined ? { ttlSeconds: options.ttl } : {}),
1378
+ }),
1373
1379
  });
1374
1380
  }
1375
1381
  const commits = {
@@ -1529,6 +1535,9 @@ export function Ablo(options) {
1529
1535
  * (a wide-scope `rk_` on the hosted path), which control-plane routes
1530
1536
  * rightly refuse (e.g. the user-session mint is sk_-gated). Counterpart to
1531
1537
  * `getAuthToken()`, which resolves the sync-plane token.
1538
+ *
1539
+ * The sk_-only rule is enforced server-side; the credential KIND taxonomy
1540
+ * (secret/restricted/ephemeral/publishable) lives in `auth/credentialPolicy`.
1532
1541
  */
1533
1542
  async function controlPlaneApiKey() {
1534
1543
  return resolveApiKeyValue(configuredApiKey);
@@ -1549,7 +1558,7 @@ export function Ablo(options) {
1549
1558
  store.nudgeReconnect();
1550
1559
  },
1551
1560
  async getAuthToken() {
1552
- // The live short-lived bearer (set via `setAuthToken`/`getToken` refresh)
1561
+ // The live short-lived bearer (set via `setAuthToken` / `apiKey`-resolver refresh)
1553
1562
  // is the canonical credential; fall back to a configured API key.
1554
1563
  //
1555
1564
  // This is the SYNC-PLANE token (bootstrap, WS, query HTTP). Control-plane
@@ -1593,66 +1602,15 @@ export function Ablo(options) {
1593
1602
  url,
1594
1603
  bootstrapBaseUrl: internalOptions.bootstrapBaseUrl,
1595
1604
  });
1596
- // Discriminate the union onto the server's TWO mint doors:
1597
- // `{ user }` POST /auth/ephemeral-keys → `ek_` (sk_-gated; the
1598
- // user-session door). Routing this arm through
1599
- // /auth/capability is structurally impossible that
1600
- // route rejects participantKind 'user' outright
1601
- // (`invalid_participant_kind`, the 2026-06-11 Pulse
1602
- // cascade: the SDK's own blessed pattern 403'd and
1603
- // integrators fell back to minting humans as agents).
1604
- // `{ agent }` → POST /auth/capability → scoped `rk_`.
1605
- // `can: { tasks: ['update'] }` serializes to the wire
1606
- // allowlist (`tasks.update`); the Hub matches it
1607
- // against every registered alias of the model.
1608
- if (params.user) {
1609
- const res = await mintUserSessionKey({
1610
- apiKey,
1611
- baseUrl,
1612
- userId: params.user.id,
1613
- ...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
1614
- ttlSeconds: params.ttlSeconds ?? 900,
1615
- ...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
1616
- });
1617
- return {
1618
- object: 'session',
1619
- id: res.id,
1620
- token: res.token,
1621
- expiresAt: res.expiresAt,
1622
- organizationId: res.organizationId,
1623
- // The ephemeral mint stores scope on the key row; reshape its flat
1624
- // response into the session resource's scope block.
1625
- scope: {
1626
- organizationId: res.organizationId,
1627
- syncGroups: res.syncGroups,
1628
- operations: [],
1629
- participantKind: 'user',
1630
- participantId: res.participantId,
1631
- },
1632
- userMeta: params.userMeta ?? { id: res.participantId },
1633
- };
1634
- }
1635
- const operations = Object.entries(params.can).flatMap(([model, ops]) => (ops ?? []).map((op) => `${model.toLowerCase()}.${op}`));
1636
- const res = await exchangeApiKey({
1605
+ // The two mint doors (`{ user }` /auth/ephemeral-keys → `ek_`,
1606
+ // `{ agent, can }` → /auth/capabilityscoped `rk_`) live in the shared
1607
+ // `mintSession` so this stateful client and the stateless HTTP client can
1608
+ // never drift on how a token is minted.
1609
+ return mintSession(params, {
1637
1610
  apiKey,
1638
1611
  baseUrl,
1639
- participantKind: 'agent',
1640
- participantId: params.agent.id,
1641
- ...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
1642
- operations,
1643
- ttlSeconds: params.ttlSeconds ?? 900,
1644
- ...(params.userMeta ? { userMeta: params.userMeta } : {}),
1645
1612
  ...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
1646
1613
  });
1647
- return {
1648
- object: 'session',
1649
- id: res.capabilityId,
1650
- token: res.token,
1651
- expiresAt: res.expiresAt,
1652
- organizationId: res.organizationId,
1653
- scope: res.scope,
1654
- userMeta: res.userMeta,
1655
- };
1656
1614
  },
1657
1615
  },
1658
1616
  async dispose() {
@@ -1721,9 +1679,6 @@ export function Ablo(options) {
1721
1679
  claims: publicClaims,
1722
1680
  commits,
1723
1681
  model,
1724
- /** Structured multiplayer participation — target-first, no
1725
- * sync-group strings in the common path. */
1726
- participants: participantManager,
1727
1682
  /** Context-staleness snapshot — see `engine.snapshot(...)` JSDoc. */
1728
1683
  snapshot(entities) {
1729
1684
  return createSnapshot({
@@ -5,7 +5,8 @@
5
5
  * IndexedDB, no WebSocket. It maps the public Model / Claim / Commit
6
6
  * nouns directly to HTTP routes on sync-server.
7
7
  */
8
- import type { AbloOptions, CommitResource, ClaimCreateOptions, ClaimWaitOptions, ModelClient, ModelClaim, ModelTarget } from './Ablo.js';
8
+ import type { AbloOptions, CommitResource, ClaimCreateOptions, ClaimWaitOptions, ModelClient, ModelClaim, ModelTarget, CreateSessionParams, AbloSession } from './Ablo.js';
9
+ import type { SchemaRecord } from '../schema/schema.js';
9
10
  import type { ClaimHandle } from './createModelProxy.js';
10
11
  import type { Duration } from '../utils/duration.js';
11
12
  export type AbloApiClientOptions = Omit<AbloOptions, 'schema'> & {
@@ -133,5 +134,13 @@ export interface AbloApi {
133
134
  * server with the credential this client already holds — no re-mint.
134
135
  */
135
136
  getAuthToken(): Promise<string | null>;
137
+ /**
138
+ * Mint a short-lived scoped session — the Stripe `ephemeralKeys.create` shape.
139
+ * Minting is a control-plane HTTP call (no socket), so it lives on this stateless
140
+ * client too, not only the realtime one. `{ user }` → `ek_`, `{ agent, can }` → `rk_`.
141
+ */
142
+ readonly sessions: {
143
+ create(params: CreateSessionParams<SchemaRecord>): Promise<AbloSession>;
144
+ };
136
145
  }
137
146
  export declare function createProtocolClient(options: AbloApiClientOptions): AbloApi;
@@ -6,9 +6,10 @@
6
6
  * nouns directly to HTTP routes on sync-server.
7
7
  */
8
8
  import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, claimedError, translateHttpError, } from '../errors.js';
9
- import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, } from './auth.js';
9
+ import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfDatabaseUrlEnvIgnored, } from './auth.js';
10
10
  import { registerDataSource } from './registerDataSource.js';
11
11
  import { toSeconds } from '../utils/duration.js';
12
+ import { mintSession } from './sessionMint.js';
12
13
  import { assertWriteOptions } from './writeOptionsSchema.js';
13
14
  const DEFAULT_AGENT_LEASE = '10m';
14
15
  export function createProtocolClient(options) {
@@ -17,6 +18,9 @@ export function createProtocolClient(options) {
17
18
  const configuredApiKey = resolveApiKey(authInput);
18
19
  const configuredAuthToken = resolveAuthToken(authInput);
19
20
  const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
21
+ // Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
22
+ // passed — no logger on this path, so the helper falls back to console.warn.
23
+ warnIfDatabaseUrlEnvIgnored(authInput);
20
24
  assertBrowserSafety({
21
25
  apiKey: configuredApiKey,
22
26
  databaseUrl: configuredDatabaseUrl,
@@ -225,20 +229,11 @@ export function createProtocolClient(options) {
225
229
  const policy = options?.ifClaimed ?? defaultPolicy;
226
230
  if (policy === 'return')
227
231
  return;
232
+ // policy === 'fail' — gate the read only when the caller opts in.
228
233
  const state = await listClaimState(target);
229
234
  if (state.active.length === 0)
230
235
  return;
231
- if (policy === 'fail') {
232
- throw claimedError(target, state.active, 'model_claimed');
233
- }
234
- if (options?.maxQueueDepth !== undefined &&
235
- state.queue.length >= options.maxQueueDepth) {
236
- throw claimedError(target, state.active, 'queue_too_deep');
237
- }
238
- await waitForNoClaims(target, {
239
- timeout: options?.claimedTimeout,
240
- pollInterval: options?.claimedPollInterval,
241
- });
236
+ throw claimedError(target, state.active, 'model_claimed');
242
237
  }
243
238
  const commits = {
244
239
  async create(commitOptions) {
@@ -653,6 +648,22 @@ export function createProtocolClient(options) {
653
648
  claims,
654
649
  commits,
655
650
  model,
651
+ sessions: {
652
+ async create(params) {
653
+ // Stateless mint: the configured key IS the control-plane credential here
654
+ // (no startup `rk_` exchange runs on this client). Reuse the resolved base
655
+ // URL + fetch; the shared `mintSession` owns the two server doors.
656
+ const apiKey = await resolveApiKeyValue(configuredApiKey);
657
+ if (!apiKey) {
658
+ throw new AbloAuthenticationError('sessions.create requires a secret (sk_) API key — call it from your backend, not the browser.', { code: 'apikey_missing' });
659
+ }
660
+ return mintSession(params, {
661
+ apiKey,
662
+ baseUrl: apiBaseUrl,
663
+ ...(options.fetch ? { fetch: options.fetch } : {}),
664
+ });
665
+ },
666
+ },
656
667
  async getAuthToken() {
657
668
  // Mirror `authHeaders()`: a configured API key wins, else the
658
669
  // construction-time auth token. Resolve the (possibly async) key setter.
@@ -12,13 +12,20 @@
12
12
  * explicit options so generated apps do not accrete hidden env knobs.
13
13
  */
14
14
  /**
15
- * Async callable that resolves to a fresh API key. Mirrors the shape
15
+ * Async callable that resolves the current credential. Mirrors the shape
16
16
  * Anthropic / OpenAI / Stripe ship — used for credential rotation
17
- * (e.g. AWS STS, GCP IAM, Vault). Re-exported from `./Ablo` so
18
- * existing import paths work; defined here so this module has no
19
- * circular dependency back to `Ablo.ts`.
17
+ * (e.g. AWS STS, GCP IAM, Vault) AND the short-lived per-user browser
18
+ * path (mint a fresh `ek_`/`rk_` from the signed-in session). Re-exported
19
+ * from `./Ablo` so existing import paths work; defined here so this module
20
+ * has no circular dependency back to `Ablo.ts`.
21
+ *
22
+ * Contract: resolve a token; resolve `null` when the login itself is gone
23
+ * (terminal → the credential lifecycle treats this as `session_expired` and
24
+ * signs out); or THROW on a transient failure (→ back off and retry, never
25
+ * sign out). A long-lived static `apiKey` string needs none of this — it is
26
+ * used as-is. This is the single credential resolver the SDK supports.
20
27
  */
21
- export type ApiKeySetter = () => Promise<string>;
28
+ export type ApiKeySetter = () => Promise<string | null>;
22
29
  export interface AuthResolveInput {
23
30
  /**
24
31
  * The full options bag the caller passed to `Ablo()`. Resolvers
@@ -45,12 +52,17 @@ export declare function resolveAuthToken(input: AuthResolveInput): string | null
45
52
  /**
46
53
  * Resolve the direct-URL connector's Postgres connection string.
47
54
  *
48
- * The default Data Source path should not call this: the customer keeps
49
- * `DATABASE_URL` in their app and exposes `dataSource(...)`. This helper exists
50
- * only for the opt-in direct connector where Ablo registers a dedicated tenant
51
- * database. Returns null for Ablo-managed storage.
55
+ * `databaseUrl` is an EXPLICIT, opt-in option: Ablo registers a dedicated
56
+ * tenant database only when the caller passes it to `Ablo(...)`. It is NOT
57
+ * read from `process.env.DATABASE_URL` per this module's invariant
58
+ * (`ABLO_API_KEY` is the only environment fallback), an app's `DATABASE_URL`
59
+ * (commonly set for Prisma/Drizzle/docker) must never silently flip the client
60
+ * into connection-string mode. The default Data Source path keeps `DATABASE_URL`
61
+ * in the app and exposes `dataSource(...)`; that path leaves this null.
62
+ * `warnIfDatabaseUrlEnvIgnored` nudges callers who set the env but omitted the option.
52
63
  */
53
64
  export declare function resolveDatabaseUrl(input: AuthResolveInput): string | null;
65
+ export declare function warnIfDatabaseUrlEnvIgnored(input: AuthResolveInput, warn?: (message: string) => void): void;
54
66
  export declare const ABLO_HOSTED_API_DOMAIN = "api.abloatai.com";
55
67
  export declare const ABLO_HOSTED_HTTP_BASE_URL = "https://api.abloatai.com";
56
68
  export declare const ABLO_DEFAULT_BASE_URL = "https://api.abloatai.com";
@@ -12,6 +12,7 @@
12
12
  * explicit options so generated apps do not accrete hidden env knobs.
13
13
  */
14
14
  import { AbloAuthenticationError } from '../errors.js';
15
+ import { classifyCredentialKind } from '../auth/credentialPolicy.js';
15
16
  /**
16
17
  * Read `process.env` defensively. Works in browser (where `process`
17
18
  * is undefined), Node, and edge runtimes that expose a partial
@@ -30,13 +31,48 @@ export function resolveAuthToken(input) {
30
31
  /**
31
32
  * Resolve the direct-URL connector's Postgres connection string.
32
33
  *
33
- * The default Data Source path should not call this: the customer keeps
34
- * `DATABASE_URL` in their app and exposes `dataSource(...)`. This helper exists
35
- * only for the opt-in direct connector where Ablo registers a dedicated tenant
36
- * database. Returns null for Ablo-managed storage.
34
+ * `databaseUrl` is an EXPLICIT, opt-in option: Ablo registers a dedicated
35
+ * tenant database only when the caller passes it to `Ablo(...)`. It is NOT
36
+ * read from `process.env.DATABASE_URL` per this module's invariant
37
+ * (`ABLO_API_KEY` is the only environment fallback), an app's `DATABASE_URL`
38
+ * (commonly set for Prisma/Drizzle/docker) must never silently flip the client
39
+ * into connection-string mode. The default Data Source path keeps `DATABASE_URL`
40
+ * in the app and exposes `dataSource(...)`; that path leaves this null.
41
+ * `warnIfDatabaseUrlEnvIgnored` nudges callers who set the env but omitted the option.
37
42
  */
38
43
  export function resolveDatabaseUrl(input) {
39
- return input.options.databaseUrl ?? input.env.DATABASE_URL ?? null;
44
+ return input.options.databaseUrl ?? null;
45
+ }
46
+ /**
47
+ * One-time migration nudge for the dropped `DATABASE_URL` env fallback.
48
+ *
49
+ * Earlier versions silently adopted `process.env.DATABASE_URL` when `databaseUrl`
50
+ * was not passed, registering a direct connector behind the caller's back — which
51
+ * surprised any app that keeps `DATABASE_URL` for another tool (Prisma, Drizzle,
52
+ * docker-compose) and, on localhost, tried to register a database Ablo's cloud
53
+ * cannot reach. The env value is now ignored; this points the developer at the
54
+ * explicit option instead of flipping their mode for them. Warns once per process
55
+ * so it never spams, and falls back to `console.warn` when no logger is supplied
56
+ * (the `transport: 'api'` client has none).
57
+ */
58
+ let warnedDatabaseUrlEnvIgnored = false;
59
+ export function warnIfDatabaseUrlEnvIgnored(input, warn) {
60
+ if (warnedDatabaseUrlEnvIgnored)
61
+ return;
62
+ if (input.options.databaseUrl != null)
63
+ return;
64
+ const envUrl = input.env.DATABASE_URL;
65
+ if (typeof envUrl !== 'string' || envUrl.length === 0)
66
+ return;
67
+ warnedDatabaseUrlEnvIgnored = true;
68
+ const message = 'Found DATABASE_URL in the environment but `databaseUrl` was not passed to Ablo(...). ' +
69
+ 'Ablo no longer auto-adopts DATABASE_URL — the environment value is ignored. ' +
70
+ 'To register your Postgres directly, pass `databaseUrl: process.env.DATABASE_URL` explicitly; ' +
71
+ 'otherwise ignore this (the hosted sandbox and signed Data Source endpoints need no databaseUrl).';
72
+ if (warn)
73
+ warn(message);
74
+ else if (typeof console !== 'undefined')
75
+ console.warn('[sync]', message);
40
76
  }
41
77
  export const ABLO_HOSTED_API_DOMAIN = 'api.abloatai.com';
42
78
  export const ABLO_HOSTED_HTTP_BASE_URL = `https://${ABLO_HOSTED_API_DOMAIN}`;
@@ -102,7 +138,7 @@ export function assertBrowserSafety(input) {
102
138
  if (!input.dangerouslyAllowBrowser &&
103
139
  inBrowser &&
104
140
  typeof input.apiKey === 'string' &&
105
- input.apiKey.startsWith('sk_')) {
141
+ classifyCredentialKind(input.apiKey) === 'secret') {
106
142
  throw new AbloAuthenticationError("It looks like you're running in a browser-like environment.\n\n" +
107
143
  'This is disabled by default — your secret API key would be ' +
108
144
  "exposed to every visitor's network tab. If you understand the risks " +
@@ -19,6 +19,7 @@ import type { ModelRegistry } from '../ModelRegistry.js';
19
19
  import type { ObjectPool } from '../ObjectPool.js';
20
20
  import type { SyncClient } from '../SyncClient.js';
21
21
  import type { HydrationCoordinator } from '../sync/HydrationCoordinator.js';
22
+ import type { JoinedParticipant } from '../sync/participants.js';
22
23
  import type { LoadWhere } from '../query/types.js';
23
24
  import { ModelScope } from '../types/index.js';
24
25
  import type { Duration, Claim, ClaimHandle, ClaimWaitOptions, Snapshot, TargetRange } from '../types/streams.js';
@@ -28,7 +29,10 @@ export interface ModelClientMeta {
28
29
  }
29
30
  export declare function getModelClientMeta(modelClient: unknown): ModelClientMeta | undefined;
30
31
  export type ModelListScope = ModelScope | 'live' | 'archived' | 'all';
31
- export interface ModelListOptions<T> {
32
+ /** Options for the sync LOCAL-pool reads `get`/`getAll`/`onChange` (JS
33
+ * `filter`, equality `where`, lifecycle `state`). The local-reactive axis;
34
+ * contrast {@link ServerReadOptions} (the async server axis). */
35
+ export interface LocalReadOptions<T> {
32
36
  where?: Partial<T>;
33
37
  /** Arbitrary local predicate. Applied after `where`. */
34
38
  filter?: (entity: T) => boolean;
@@ -42,8 +46,11 @@ export interface ModelListOptions<T> {
42
46
  * sync-group `scope`. */
43
47
  state?: ModelListScope;
44
48
  }
45
- export type ModelCountOptions<T> = Pick<ModelListOptions<T>, 'where' | 'filter' | 'state'>;
46
- export interface ModelLoadOptions<T> {
49
+ export type LocalCountOptions<T> = Pick<LocalReadOptions<T>, 'where' | 'filter' | 'state'>;
50
+ /** Options for the async SERVER reads `retrieve`/`list` (operator `where` DSL,
51
+ * `type`, `expand`). The server-async axis; contrast {@link LocalReadOptions}
52
+ * (the local-reactive axis). */
53
+ export interface ServerReadOptions<T> {
47
54
  /**
48
55
  * Filter for the lookup. Accepts:
49
56
  * - object form: `{ name: 'foo' }` (equality, array values → `IN`)
@@ -71,8 +78,8 @@ export interface ModelLoadOptions<T> {
71
78
  expand?: readonly string[];
72
79
  }
73
80
  /** Options for the single-row async server read `retrieve({ id })`. A subset of
74
- * {@link ModelLoadOptions} — `where`/`limit`/`orderBy` are fixed by the id. */
75
- export type ModelRetrieveOptions = Pick<ModelLoadOptions<unknown>, 'type' | 'expand'>;
81
+ * {@link ServerReadOptions} — `where`/`limit`/`orderBy` are fixed by the id. */
82
+ export type ServerRetrieveOptions = Pick<ServerReadOptions<unknown>, 'type' | 'expand'>;
76
83
  export interface ModelCollaboration<T> {
77
84
  createClaim(options: {
78
85
  target: {
@@ -136,6 +143,39 @@ export interface ModelCollaboration<T> {
136
143
  * from "someone else holds it" in `claimOrWait`.
137
144
  */
138
145
  readonly selfParticipantId: string;
146
+ /**
147
+ * The local participant's kind (`'user' | 'agent' | 'system'`). Used to
148
+ * stamp the synthesized self-claim returned from `claim.state` when the
149
+ * LOCAL proxy holds the lease (server presence frames exclude one's own
150
+ * claims, so the holder must build its own view).
151
+ */
152
+ readonly selfParticipantKind?: 'user' | 'agent' | 'system';
153
+ /**
154
+ * Subscribe the connection to a scope's sync group(s) (read-interest).
155
+ * The typed surface calls this on single-entity reads/claim observation so
156
+ * a Node/agent client lands in the SAME entity-scoped group the holder's
157
+ * claim presence fans out on — otherwise a peer subscribed only to
158
+ * `org:`/`user:` groups never sees claim broadcasts. Fire-and-forget and
159
+ * SOFT: read interest is best-effort and must never make a read reject or
160
+ * stall (see `AreaOfInterestManager.reconcile`). Optional so minimal test
161
+ * doubles can omit it. Forwards to `BaseSyncedStore.enterScope`.
162
+ */
163
+ enterScope?(scope: Record<string, string>): void | Promise<void>;
164
+ /**
165
+ * Pin a scope's sync group(s) (write-intent / prominence): a row this
166
+ * client holds an active claim on stays subscribed regardless of
167
+ * navigation. Same fire-and-forget, soft semantics as `enterScope`.
168
+ * Forwards to `BaseSyncedStore.pinScope`.
169
+ */
170
+ pinScope?(scope: Record<string, string>): void | Promise<void>;
171
+ /**
172
+ * Open a presence/claim subscription on this model's sync group(s) and
173
+ * return the live participant handle. Backs `ablo.<model>.watch(ids)` —
174
+ * the relocation of the old `ablo.participants.join({ scope })`. WebSocket
175
+ * only (presence needs a socket); absent on non-ws constructions, in which
176
+ * case the proxy throws a clear error. Forwards to `participantManager.join`.
177
+ */
178
+ createWatch?(modelKey: string, ids: string | readonly string[], options?: WatchOptions): Promise<JoinedParticipant>;
139
179
  }
140
180
  export interface ClaimTargetOptions<T = Record<string, unknown>> {
141
181
  /** Phase shown to observers while held. Defaults to `'editing'`. */
@@ -251,7 +291,7 @@ export interface ClaimApi<T> {
251
291
  /** Release a manual claim handle early. Single-write claims auto-release. */
252
292
  release(params: ClaimLookupParams<T> | ClaimHandle<T>): Promise<void>;
253
293
  }
254
- export interface ModelRetrieveParams extends ModelRetrieveOptions {
294
+ export interface ModelRetrieveParams extends ServerRetrieveOptions {
255
295
  readonly id: string;
256
296
  }
257
297
  export interface ModelCreateParams<T, CreateInput> extends MutationOptions {
@@ -268,6 +308,15 @@ export interface ModelDeleteParams<T> extends MutationOptions {
268
308
  readonly id: string;
269
309
  readonly claim?: ClaimHandle<T> | ClaimTargetOptions<T> | null;
270
310
  }
311
+ /** Options for the WebSocket-only `ablo.<model>.watch(ids, options?)`. */
312
+ export interface WatchOptions {
313
+ /**
314
+ * Lease TTL for the underlying presence claim — the participant
315
+ * auto-releases after this if the holder dies. Compact duration string
316
+ * (`'5m'`) or ms number, mirroring the claim `ttl`.
317
+ */
318
+ ttl?: Duration;
319
+ }
271
320
  export interface ModelOperations<T, CreateInput> {
272
321
  /**
273
322
  * Read a single entity by id from the **server** — async. Resolves through
@@ -291,7 +340,7 @@ export interface ModelOperations<T, CreateInput> {
291
340
  * Mirrors `stripe.customers.list({...})` — network-backed. For a synchronous
292
341
  * read of the local graph use `getAll(...)`.
293
342
  */
294
- list(options?: ModelLoadOptions<T>): Promise<T[]>;
343
+ list(options?: ServerReadOptions<T>): Promise<T[]>;
295
344
  /**
296
345
  * Synchronous snapshot of a single entity from the **local graph** — no
297
346
  * network. Returns `undefined` when the row isn't resident (cold hosted
@@ -304,9 +353,9 @@ export interface ModelOperations<T, CreateInput> {
304
353
  * no network round-trip. Empty until `retrieve`/`list`/bootstrap has warmed
305
354
  * the graph.
306
355
  */
307
- getAll(options?: ModelListOptions<T>): T[];
356
+ getAll(options?: LocalReadOptions<T>): T[];
308
357
  /** Count entities in the **local graph** (synchronous, no network). */
309
- getCount(options?: ModelCountOptions<T>): number;
358
+ getCount(options?: LocalCountOptions<T>): number;
310
359
  /**
311
360
  * Create a new entity — **optimistic, offline-first**. Resolves once
312
361
  * the mutation is queued locally, not when the server confirms.
@@ -345,7 +394,22 @@ export interface ModelOperations<T, CreateInput> {
345
394
  * ```
346
395
  */
347
396
  claim: ClaimApi<T>;
397
+ /**
398
+ * Subscribe this client to the sync group(s) for one or more rows of this
399
+ * model and get a live participant handle back — presence (`.peers`), the
400
+ * scoped claim stream (`.claims`), and `.leave()` / `await using` disposal.
401
+ *
402
+ * The model-scoped relocation of the former `ablo.participants.join({
403
+ * scope: { <model>: ids } })`. WebSocket only — presence needs a socket, so
404
+ * this is absent on HTTP clients and throws on any non-ws construction.
405
+ *
406
+ * ```ts
407
+ * await using participant = await ablo.slides.watch(slideIds, { ttl: '5m' });
408
+ * participant.peers; // who else is here
409
+ * ```
410
+ */
411
+ watch(ids: string | readonly string[], options?: WatchOptions): Promise<JoinedParticipant>;
348
412
  /** Listen for changes (callback called on every change). */
349
- onChange(callback: (entities: T[]) => void, options?: ModelListOptions<T>): () => void;
413
+ onChange(callback: (entities: T[]) => void, options?: LocalReadOptions<T>): () => void;
350
414
  }
351
415
  export declare function createModelProxy<T, C>(schemaKey: string, registeredModelName: string, objectPool: ObjectPool, syncClient: SyncClient, registry: ModelRegistry, hydration: HydrationCoordinator, collaboration?: ModelCollaboration<T>): ModelOperations<T, C>;