@abloatai/ablo 0.11.1 → 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 (74) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +10 -2
  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 +39 -6
  8. package/dist/client/Ablo.d.ts +39 -88
  9. package/dist/client/Ablo.js +38 -98
  10. package/dist/client/ApiClient.d.ts +10 -1
  11. package/dist/client/ApiClient.js +19 -11
  12. package/dist/client/auth.d.ts +12 -5
  13. package/dist/client/auth.js +2 -1
  14. package/dist/client/createModelProxy.d.ts +49 -10
  15. package/dist/client/createModelProxy.js +6 -0
  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 +2 -0
  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 +13 -2
  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.d.ts +0 -11
  45. package/dist/transactions/TransactionQueue.js +12 -56
  46. package/dist/types/global.d.ts +3 -0
  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 +3 -2
  51. package/docs/client-behavior.md +1 -0
  52. package/docs/coordination.md +75 -21
  53. package/docs/examples/existing-python-backend.md +9 -5
  54. package/docs/examples/scoped-agent.md +1 -1
  55. package/docs/guarantees.md +4 -3
  56. package/docs/identity.md +89 -82
  57. package/docs/integration-guide.md +19 -10
  58. package/docs/migration.md +9 -2
  59. package/docs/quickstart.md +6 -2
  60. package/docs/react.md +3 -3
  61. package/docs/schema-contract.md +23 -5
  62. package/llms-full.txt +18 -16
  63. package/llms.txt +6 -6
  64. package/package.json +1 -1
  65. package/dist/api/index.d.ts +0 -10
  66. package/dist/api/index.js +0 -9
  67. package/dist/principal.d.ts +0 -44
  68. package/dist/principal.js +0 -49
  69. package/dist/react/SyncGroupProvider.d.ts +0 -19
  70. package/dist/react/SyncGroupProvider.js +0 -44
  71. package/dist/react/useClaim.d.ts +0 -29
  72. package/dist/react/useClaim.js +0 -42
  73. package/dist/react/usePresence.d.ts +0 -32
  74. package/dist/react/usePresence.js +0 -41
@@ -28,7 +28,6 @@ import type { SyncWebSocket } from '../sync/SyncWebSocket.js';
28
28
  import type { SyncGroupInput } from '../schema/roles.js';
29
29
  import { type SyncStatus } from '../BaseSyncedStore.js';
30
30
  import type { ClaimStream, ClaimWaitOptions, PresenceStream, Snapshot } from '../types/streams.js';
31
- import type { ParticipantManager } from '../sync/participants.js';
32
31
  import type { ClaimHandle, Duration, Claim } from '../types/streams.js';
33
32
  import { type AbloApi, type AbloApiClientOptions, type AbloApiClaims } from './ApiClient.js';
34
33
  import { type AbloHttpClient, type AbloHttpClientOptions } from './httpClient.js';
@@ -77,35 +76,24 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
77
76
  * usually pass nothing). A long-lived key needs no refresh; the client uses
78
77
  * it as-is.
79
78
  *
80
- * Accepts a static string or an async `() => Promise<string>` resolver if you
81
- * rotate keys out-of-band (resolved at bootstrap).
79
+ * Accepts a static string OR an async `() => Promise<string | null>` resolver
80
+ * the single credential path. Use the resolver form for two cases:
82
81
  *
83
- * Browser apps that mint a SHORT-LIVED per-user key (`ek_`) from a login can't
84
- * ship a secret those use {@link getToken} (or {@link authEndpoint}) instead,
85
- * which the client refreshes for you. That's the only case that isn't "just
86
- * `apiKey`".
87
- */
88
- apiKey?: string | ApiKeySetter | null | undefined;
89
- /**
90
- * Opt-in for the SHORT-LIVED per-user browser case: an async resolver for a
91
- * fresh bearer (`ek_`/`rk_`) your backend minted for the signed-in user. The
92
- * client calls it once before connect and then keeps the key fresh for you —
93
- * a refresh timer ahead of expiry plus re-mint on OS-wake / network-online /
94
- * tab-focus, and a reactive re-mint when a probe finds the key stale. You
95
- * never call a refresh method (Supabase `autoRefreshToken` model).
82
+ * - **Key rotation** (server): pull a fresh `sk_`/`pk_` from a vault on each
83
+ * bootstrap (AWS STS, GCP IAM, Vault).
84
+ * - **Short-lived per-user browser** auth: return the fresh `ek_`/`rk_` bearer
85
+ * your backend minted for the signed-in user. The client mints once before
86
+ * connect, then keeps it fresh for you — a refresh timer ahead of expiry
87
+ * plus re-mint on OS-wake / network-online / tab-focus, and a reactive
88
+ * re-mint when a probe finds the key stale. You never call a refresh method
89
+ * (Supabase `autoRefreshToken` model).
96
90
  *
97
- * Contract: resolve a token, resolve `null` when the login itself is gone
98
- * (terminal → sign out), or THROW on a transient failure ( back off, never
99
- * sign out). Leave unset for the static-`apiKey` path.
100
- */
101
- getToken?: (() => Promise<string | null>) | undefined;
102
- /**
103
- * Convenience over {@link getToken}: a URL on YOUR backend that returns
104
- * `{ token }`. The client POSTs to it (with cookies, so it's authed by the
105
- * user's session) to mint + refresh the bearer. Ignored when `getToken` is
106
- * set. Pure sugar — `getToken: () => fetch(url).then(r => r.json()).then(b => b.token)`.
91
+ * Resolver contract: resolve a token; resolve `null` when the login itself is
92
+ * gone (terminal → the client signs out / fails `ready()` with `session_expired`);
93
+ * or THROW on a transient failure (→ back off and retry, never sign out). A
94
+ * static string never refreshes — it is used as-is.
107
95
  */
108
- authEndpoint?: string | undefined;
96
+ apiKey?: string | ApiKeySetter | null | undefined;
109
97
  /**
110
98
  * Direct-URL convenience connector: a connection string to your own Postgres
111
99
  * that Ablo can register for a dedicated tenant.
@@ -138,6 +126,9 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
138
126
  * `AbloHttpClient<S>`, so stateful-only capabilities (`get`/`getAll`,
139
127
  * `onChange`) are compile errors rather than latent runtime gaps.
140
128
  *
129
+ * Note: session/credential minting (`sessions.create`) currently runs on the
130
+ * stateful (default) client, not the http client.
131
+ *
141
132
  * @default 'websocket'
142
133
  */
143
134
  transport?: 'websocket' | 'http' | undefined;
@@ -225,18 +216,6 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
225
216
  * only for the advanced Model / Claim / Commit client.
226
217
  */
227
218
  schema: Schema<S>;
228
- /**
229
- * Short-lived-bearer resolver for the per-user browser path (mirrors the
230
- * public {@link AbloOptions.getToken}). The client mints the first token
231
- * before connect and refreshes it (timer + wake/online/focus) — see
232
- * {@link resolveCredentialResolver}.
233
- */
234
- getToken?: (() => Promise<string | null>) | undefined;
235
- /**
236
- * Backend URL returning `{ token }`; sugar over {@link getToken}. Mirrors the
237
- * public {@link AbloOptions.authEndpoint}.
238
- */
239
- authEndpoint?: string | undefined;
240
219
  /**
241
220
  * @deprecated Server derives participant kind from the apiKey's
242
221
  * scope. Pass apiKey only; this option will be removed once the
@@ -385,8 +364,8 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
385
364
  * `create({ data })` / `update({ id, data })` / `delete({ id })` — writes
386
365
  * `claim({ id })` — durable claim handle for coordinated writes
387
366
  */
388
- export type { ModelCountOptions, ModelListOptions, ModelListScope, ModelLoadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ClaimHandle, ModelOperations, } from './createModelProxy.js';
389
- import type { ModelOperations, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ModelLoadOptions } from './createModelProxy.js';
367
+ export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ClaimHandle, ModelOperations, } from './createModelProxy.js';
368
+ import type { ModelOperations, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ServerReadOptions } from './createModelProxy.js';
390
369
  export type ModelOperationAction = 'create' | 'update' | 'delete' | 'archive' | 'unarchive';
391
370
  export type CommitWait = 'queued' | 'confirmed';
392
371
  export interface ModelRead<T = Record<string, unknown>> {
@@ -394,23 +373,15 @@ export interface ModelRead<T = Record<string, unknown>> {
394
373
  readonly stamp: number;
395
374
  readonly claims: readonly ModelClaim[];
396
375
  }
397
- export type IfClaimedPolicy = 'return' | 'wait' | 'fail';
376
+ export type IfClaimedPolicy = 'return' | 'fail';
398
377
  export interface ClaimedOptions {
399
378
  /**
400
- * What to do when another participant has claimed the target. `return`
401
- * includes active claim metadata in the response, `wait` resolves after the
402
- * claim clears, and `fail` throws `AbloClaimedError`.
379
+ * What to do when another participant has claimed the target: `return`
380
+ * includes active claim metadata in the response; `fail` throws
381
+ * `AbloClaimedError`. Waiting for a claim to clear is a claim-side concern —
382
+ * take `ablo.<model>.claim({ id })` (it queues fairly); reads never block.
403
383
  */
404
384
  readonly ifClaimed?: IfClaimedPolicy;
405
- /** Max time to wait for peer claims to clear, in milliseconds. */
406
- readonly claimedTimeout?: number;
407
- /** HTTP API polling interval while waiting. WebSocket clients ignore it. */
408
- readonly claimedPollInterval?: number;
409
- /**
410
- * Backpressure for `ifClaimed: 'wait'`: reject instead of waiting if the
411
- * row's FIFO line is already `>= maxQueueDepth` deep.
412
- */
413
- readonly maxQueueDepth?: number;
414
385
  }
415
386
  export type { ClaimWaitOptions } from '../types/streams.js';
416
387
  export interface ModelReadOptions extends ClaimedOptions {
@@ -527,7 +498,7 @@ export interface ModelClient<T = Record<string, unknown>> {
527
498
  * `limit`. Present on the stateless protocol client; the store-backed
528
499
  * `.model(name)` accessor omits it (use the typed `ablo.<model>.list` there).
529
500
  */
530
- list?(options?: ModelLoadOptions<T>): Promise<T[]>;
501
+ list?(options?: ServerReadOptions<T>): Promise<T[]>;
531
502
  create(params: ModelMutationOptions & {
532
503
  readonly data: Record<string, unknown>;
533
504
  readonly id?: string | null;
@@ -560,7 +531,7 @@ export interface CreateUserSessionParams {
560
531
  id: string;
561
532
  };
562
533
  /** Sync groups this session may subscribe to — typed (`'default'` or
563
- * `<namespace>:<id>`; build with `syncGroup.org()/user()/of()` from
534
+ * `<namespace>:<id>`; build with `syncGroup(kind, id)` from
564
535
  * `@abloatai/ablo/schema`). Omit for the server default:
565
536
  * `[org:<your org>, user:<user.id>]`. */
566
537
  syncGroups?: readonly SyncGroupInput[];
@@ -585,7 +556,7 @@ export interface CreateAgentSessionParams<S extends SchemaRecord> {
585
556
  [M in keyof S & string]?: readonly SessionOperation[];
586
557
  };
587
558
  /** Sync groups this session may subscribe to — typed (`'default'` or
588
- * `<namespace>:<id>`; build with `syncGroup.org()/user()/of()` from
559
+ * `<namespace>:<id>`; build with `syncGroup(kind, id)` from
589
560
  * `@abloatai/ablo/schema`). Omit for the server default: the org
590
561
  * anchor (`org:<your org>`) + the agent's own anchor. */
591
562
  syncGroups?: readonly SyncGroupInput[];
@@ -667,7 +638,7 @@ export type Ablo<S extends SchemaRecord> = {
667
638
  * Replace the bearer auth token used for the WebSocket upgrade and HTTP
668
639
  * requests, WITHOUT tearing down the engine. Use to push a refreshed
669
640
  * short-lived access key (the Stripe-style `ek_`/`rk_`) before it expires —
670
- * `<AbloProvider>`'s `getToken` refresh loop calls this. Reuses the same
641
+ * the client's `apiKey`-resolver refresh loop calls this. Reuses the same
671
642
  * rotation path as the internal capability-token refresh; safe to call before
672
643
  * `ready()`. Also nudges a parked connection to re-probe with the new token.
673
644
  */
@@ -675,8 +646,8 @@ export type Ablo<S extends SchemaRecord> = {
675
646
  /**
676
647
  * Resolve the active bearer credential this engine authenticates with — the
677
648
  * live `ek_`/`rk_` the WebSocket and HTTP transports currently carry (kept
678
- * fresh by the `getToken` refresh loop), falling back to a configured API
679
- * key. Returns `null` when no credential is set yet. Use it to authenticate
649
+ * fresh by the `apiKey`-resolver refresh loop), falling back to a configured
650
+ * API key. Returns `null` when no credential is set yet. Use it to authenticate
680
651
  * a side-band request to the same server with the very token this client
681
652
  * already holds — no extra mint round-trip.
682
653
  */
@@ -685,10 +656,10 @@ export type Ablo<S extends SchemaRecord> = {
685
656
  * Register a re-mint hook for the short-lived access key. The connection
686
657
  * layer calls it WHEN it finds the key stale (a `credential_stale` probe) or
687
658
  * on an external nudge; the hook mints a fresh `ek_`/`rk_` from the still-valid
688
- * login. Mirrors the `getToken` contract: resolve a token, resolve `null` when
689
- * the login itself is gone (→ sign out), or THROW on a transient failure (→
690
- * back off, never sign out). `<AbloProvider>` wires this from its
691
- * `getToken`/`authEndpoint`. Safe to call before `ready()`.
659
+ * login. Mirrors the `apiKey`-resolver contract: resolve a token, resolve
660
+ * `null` when the login itself is gone (→ sign out), or THROW on a transient
661
+ * failure (→ back off, never sign out). The client wires this automatically
662
+ * from a function `apiKey`. Safe to call before `ready()`.
692
663
  */
693
664
  setCredentialRefresher(refresher: (() => Promise<string | null>) | null): void;
694
665
  /**
@@ -703,14 +674,15 @@ export type Ablo<S extends SchemaRecord> = {
703
674
  * Mint a short-lived, scoped **session token** for one end user — the
704
675
  * Stripe `ephemeralKeys.create` / Supabase session shape. Call this on YOUR
705
676
  * BACKEND (where the `sk_` secret key lives), then hand the returned
706
- * `token` to that user's browser (typically via an authEndpoint the client
707
- * fetches). The browser presents it as the bearer; the sync-server verifies
677
+ * `token` to that user's browser (typically via a token route the browser's
678
+ * `apiKey` resolver fetches). The browser presents it as the bearer; the sync-server verifies
708
679
  * it via `apiKeyProvider`.
709
680
  *
710
681
  * The browser must NEVER see the `sk_` key — only the per-user session token.
711
682
  *
712
683
  * Pass `{ user: { id } }` for a full-authority end-user session (mints `ek_`,
713
- * `actor_kind: 'user'` attribution), or `{ agent: { id }, can: { tasks:
684
+ * `participantKind: 'user'` attribution, stored as `actor_kind` on the delta
685
+ * row), or `{ agent: { id }, can: { tasks:
714
686
  * ['update'] } }` for a scoped agent session (mints `rk_`); `can` is typed
715
687
  * against your schema's model names. Always authenticates with the original
716
688
  * `sk_` — never the client's exchanged sync credential.
@@ -822,24 +794,6 @@ export type Ablo<S extends SchemaRecord> = {
822
794
  * are schema-powered sugar over the same model write/read path.
823
795
  */
824
796
  model<T = Record<string, unknown>>(name: string): ModelClient<T>;
825
- /**
826
- * Canonical multiplayer participant surface. Joins a structured app
827
- * target, derives the transport scope internally, opens a scoped
828
- * claim on the existing WebSocket, and returns target-bound presence
829
- * + claim helpers.
830
- *
831
- * ```ts
832
- * const participant = await ablo.participants.join({
833
- * type: 'File',
834
- * id: 'src/foo.ts',
835
- * path: 'src/foo.ts',
836
- * range: { startLine: 10, endLine: 40 },
837
- * });
838
- * participant.presence.editing();
839
- * const claim = participant.claims.claim('rewrite imports');
840
- * ```
841
- */
842
- readonly participants: ParticipantManager;
843
797
  /**
844
798
  * Capture a context-staleness watermark over a set of entities.
845
799
  * Returns a flat snapshot with `stamp` (thread into writes as
@@ -991,9 +945,6 @@ export declare namespace Ablo {
991
945
  type ClaimLost = _Streams.ClaimLost;
992
946
  type Snapshot<TSchema extends _SchemaTypes.Schema = _SchemaTypes.Schema, K extends keyof TSchema['models'] = keyof TSchema['models']> = _Streams.Snapshot<TSchema, K>;
993
947
  namespace Auth {
994
- type Principal = _Streams.Principal;
995
- type Session = _Streams.SessionRef;
996
- type Agent = _Streams.AgentRef;
997
948
  type Actor = _Streams.ParticipantRef;
998
949
  }
999
950
  namespace Participant {
@@ -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';
@@ -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({
@@ -900,7 +888,7 @@ export function Ablo(options) {
900
888
  // WebSocket upgrade + bootstrap carry a valid bearer (no tokenless first
901
889
  // connect that has to self-heal). Only when a refreshing resolver is
902
890
  // wired AND no static credential is already present. Contract mirrors
903
- // `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
904
892
  // app shows sign-in); a THROW ⇒ transient (rethrown; autoStart swallows
905
893
  // and the lifecycle's online/wake triggers retry).
906
894
  if (credentialResolver && !authCredentials.getAuthToken()) {
@@ -933,8 +921,9 @@ export function Ablo(options) {
933
921
  kind,
934
922
  configuredApiKey,
935
923
  // Resolve identity against the LIVE token, not the construction-time
936
- // `configuredAuthToken`. Consumers using `getToken` (apps/web) never
937
- // 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
938
927
  // `ready()`, which updates the shared credential source. Reading the frozen
939
928
  // `configuredAuthToken` here made `/auth/identity` fire with no Bearer
940
929
  // (→ `no_matching_provider` / `session_expired`) even though the JWT
@@ -1247,14 +1236,8 @@ export function Ablo(options) {
1247
1236
  const current = listModelClaims(target);
1248
1237
  if (current.length === 0)
1249
1238
  return;
1250
- if (policy === 'fail')
1251
- throw claimedError(target, current, 'model_claimed');
1252
- const queue = listModelClaimQueue(target);
1253
- if (options?.maxQueueDepth !== undefined &&
1254
- queue.length >= options.maxQueueDepth) {
1255
- throw claimedError(target, current, 'queue_too_deep');
1256
- }
1257
- 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');
1258
1241
  }
1259
1242
  function wrapClaimHandle(claim, waited = false) {
1260
1243
  const release = async () => {
@@ -1385,6 +1368,14 @@ export function Ablo(options) {
1385
1368
  // errors so read interest never makes a read reject or stall.
1386
1369
  enterScope: (scope) => store.enterScope(scope),
1387
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
+ }),
1388
1379
  });
1389
1380
  }
1390
1381
  const commits = {
@@ -1544,6 +1535,9 @@ export function Ablo(options) {
1544
1535
  * (a wide-scope `rk_` on the hosted path), which control-plane routes
1545
1536
  * rightly refuse (e.g. the user-session mint is sk_-gated). Counterpart to
1546
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`.
1547
1541
  */
1548
1542
  async function controlPlaneApiKey() {
1549
1543
  return resolveApiKeyValue(configuredApiKey);
@@ -1564,7 +1558,7 @@ export function Ablo(options) {
1564
1558
  store.nudgeReconnect();
1565
1559
  },
1566
1560
  async getAuthToken() {
1567
- // The live short-lived bearer (set via `setAuthToken`/`getToken` refresh)
1561
+ // The live short-lived bearer (set via `setAuthToken` / `apiKey`-resolver refresh)
1568
1562
  // is the canonical credential; fall back to a configured API key.
1569
1563
  //
1570
1564
  // This is the SYNC-PLANE token (bootstrap, WS, query HTTP). Control-plane
@@ -1608,66 +1602,15 @@ export function Ablo(options) {
1608
1602
  url,
1609
1603
  bootstrapBaseUrl: internalOptions.bootstrapBaseUrl,
1610
1604
  });
1611
- // Discriminate the union onto the server's TWO mint doors:
1612
- // `{ user }` POST /auth/ephemeral-keys → `ek_` (sk_-gated; the
1613
- // user-session door). Routing this arm through
1614
- // /auth/capability is structurally impossible that
1615
- // route rejects participantKind 'user' outright
1616
- // (`invalid_participant_kind`, the 2026-06-11 Pulse
1617
- // cascade: the SDK's own blessed pattern 403'd and
1618
- // integrators fell back to minting humans as agents).
1619
- // `{ agent }` → POST /auth/capability → scoped `rk_`.
1620
- // `can: { tasks: ['update'] }` serializes to the wire
1621
- // allowlist (`tasks.update`); the Hub matches it
1622
- // against every registered alias of the model.
1623
- if (params.user) {
1624
- const res = await mintUserSessionKey({
1625
- apiKey,
1626
- baseUrl,
1627
- userId: params.user.id,
1628
- ...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
1629
- ttlSeconds: params.ttlSeconds ?? 900,
1630
- ...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
1631
- });
1632
- return {
1633
- object: 'session',
1634
- id: res.id,
1635
- token: res.token,
1636
- expiresAt: res.expiresAt,
1637
- organizationId: res.organizationId,
1638
- // The ephemeral mint stores scope on the key row; reshape its flat
1639
- // response into the session resource's scope block.
1640
- scope: {
1641
- organizationId: res.organizationId,
1642
- syncGroups: res.syncGroups,
1643
- operations: [],
1644
- participantKind: 'user',
1645
- participantId: res.participantId,
1646
- },
1647
- userMeta: params.userMeta ?? { id: res.participantId },
1648
- };
1649
- }
1650
- const operations = Object.entries(params.can).flatMap(([model, ops]) => (ops ?? []).map((op) => `${model.toLowerCase()}.${op}`));
1651
- 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, {
1652
1610
  apiKey,
1653
1611
  baseUrl,
1654
- participantKind: 'agent',
1655
- participantId: params.agent.id,
1656
- ...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
1657
- operations,
1658
- ttlSeconds: params.ttlSeconds ?? 900,
1659
- ...(params.userMeta ? { userMeta: params.userMeta } : {}),
1660
1612
  ...(internalOptions.fetch ? { fetch: internalOptions.fetch } : {}),
1661
1613
  });
1662
- return {
1663
- object: 'session',
1664
- id: res.capabilityId,
1665
- token: res.token,
1666
- expiresAt: res.expiresAt,
1667
- organizationId: res.organizationId,
1668
- scope: res.scope,
1669
- userMeta: res.userMeta,
1670
- };
1671
1614
  },
1672
1615
  },
1673
1616
  async dispose() {
@@ -1736,9 +1679,6 @@ export function Ablo(options) {
1736
1679
  claims: publicClaims,
1737
1680
  commits,
1738
1681
  model,
1739
- /** Structured multiplayer participation — target-first, no
1740
- * sync-group strings in the common path. */
1741
- participants: participantManager,
1742
1682
  /** Context-staleness snapshot — see `engine.snapshot(...)` JSDoc. */
1743
1683
  snapshot(entities) {
1744
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;
@@ -9,6 +9,7 @@ import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloVal
9
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) {
@@ -228,20 +229,11 @@ export function createProtocolClient(options) {
228
229
  const policy = options?.ifClaimed ?? defaultPolicy;
229
230
  if (policy === 'return')
230
231
  return;
232
+ // policy === 'fail' — gate the read only when the caller opts in.
231
233
  const state = await listClaimState(target);
232
234
  if (state.active.length === 0)
233
235
  return;
234
- if (policy === 'fail') {
235
- throw claimedError(target, state.active, 'model_claimed');
236
- }
237
- if (options?.maxQueueDepth !== undefined &&
238
- state.queue.length >= options.maxQueueDepth) {
239
- throw claimedError(target, state.active, 'queue_too_deep');
240
- }
241
- await waitForNoClaims(target, {
242
- timeout: options?.claimedTimeout,
243
- pollInterval: options?.claimedPollInterval,
244
- });
236
+ throw claimedError(target, state.active, 'model_claimed');
245
237
  }
246
238
  const commits = {
247
239
  async create(commitOptions) {
@@ -656,6 +648,22 @@ export function createProtocolClient(options) {
656
648
  claims,
657
649
  commits,
658
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
+ },
659
667
  async getAuthToken() {
660
668
  // Mirror `authHeaders()`: a configured API key wins, else the
661
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
@@ -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
@@ -137,7 +138,7 @@ export function assertBrowserSafety(input) {
137
138
  if (!input.dangerouslyAllowBrowser &&
138
139
  inBrowser &&
139
140
  typeof input.apiKey === 'string' &&
140
- input.apiKey.startsWith('sk_')) {
141
+ classifyCredentialKind(input.apiKey) === 'secret') {
141
142
  throw new AbloAuthenticationError("It looks like you're running in a browser-like environment.\n\n" +
142
143
  'This is disabled by default — your secret API key would be ' +
143
144
  "exposed to every visitor's network tab. If you understand the risks " +