@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
@@ -18,6 +18,7 @@ import { autorun } from 'mobx';
18
18
  import { AbloClaimedError, AbloValidationError, formatClaimedErrorMessage, toAbloError, } from '../errors.js';
19
19
  import { descriptionFromMeta } from '../coordination/schema.js';
20
20
  import { Model, modelAsRow } from '../Model.js';
21
+ import { toMs } from '../utils/duration.js';
21
22
  import { assertWriteOptions } from './writeOptionsSchema.js';
22
23
  import { ModelScope } from '../types/index.js';
23
24
  const modelClientMeta = new WeakMap();
@@ -75,7 +76,17 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
75
76
  // Claims this proxy currently holds, keyed by entity id. Lets the flat
76
77
  // `release({ id })` and `update({ id, data })` find the lease + snapshot a `claim({ id })`
77
78
  // took — no per-call handle. Released on dispose, explicit release, or TTL.
79
+ //
80
+ // `target` / `action` / `expiresAt` are kept alongside the lease so
81
+ // `claim.state` can synthesize a self-claim: the server excludes a holder's
82
+ // own presence frames, so the local proxy is the ONLY place that knows "I
83
+ // hold this." `expiresAt` is the client's best estimate from the requested
84
+ // TTL (a genuine epoch-ms expiry, not a fabricated watermark), defaulting to
85
+ // the server's keepalive lease window when no TTL was requested.
78
86
  const activeClaims = new Map();
87
+ // Server keepalive lease window (Hub `LEASE_RENEW_TTL_MS`). The fallback
88
+ // expiry estimate when a claim is taken without an explicit TTL.
89
+ const DEFAULT_LEASE_TTL_MS = 90_000;
79
90
  const isClaimHandle = (value) => typeof value === 'object' &&
80
91
  value !== null &&
81
92
  value.object === 'claim' &&
@@ -124,7 +135,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
124
135
  };
125
136
  const takeClaim = async (params) => {
126
137
  if (!collaboration) {
127
- throw new AbloValidationError(`Model "${schemaKey}" cannot claim a row without collaboration wiring.`, { code: 'model_claim_not_configured' });
138
+ throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
128
139
  }
129
140
  const { id, ...options } = params;
130
141
  // Is someone ELSE already on this target? Read the local coordination
@@ -157,6 +168,14 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
157
168
  if (!model) {
158
169
  throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
159
170
  }
171
+ // Write-intent: enter the entity scope BEFORE acquiring the lease so the
172
+ // holder's claim presence broadcasts to whoever is in this entity group —
173
+ // including a peer that subscribed just before us. Pinning before the
174
+ // lease (rather than after) closes the subscribe-vs-broadcast race: the
175
+ // server fans `broadcastPresenceChange` out at claim time, so we must be
176
+ // in the group when `createClaim` lands. Awaited because the broadcast
177
+ // ordering depends on it; still soft (the store swallows reconcile errors).
178
+ await collaboration.pinScope?.({ [schemaKey]: id });
160
179
  // Acquire the lease. Default (`wait` !== false) goes through the server's
161
180
  // fair FIFO queue — `queue: true` resolves only once the lease is genuinely
162
181
  // ours, blocking behind any current holder, with no TOCTOU gap (the server
@@ -194,7 +213,27 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
194
213
  model = objectPool.get(id) ?? model;
195
214
  }
196
215
  const snapshot = collaboration.createSnapshot(schemaKey, id);
197
- activeClaims.set(id, { lease, snapshot });
216
+ const action = options?.action ?? 'editing';
217
+ // The self-claim's `EntityRef` mirrors what a peer's `claim.state` would
218
+ // report (`observe` maps `held.target.model` → `type`), so a holder and a
219
+ // peer see the SAME target.type for one row — the wire model token.
220
+ const selfTarget = {
221
+ type: wireModel,
222
+ id,
223
+ ...(options?.field ? { field: options.field } : {}),
224
+ ...(options?.path ? { path: options.path } : {}),
225
+ ...(options?.range ? { range: options.range } : {}),
226
+ ...(claimMeta(options) ? { meta: claimMeta(options) } : {}),
227
+ };
228
+ const expiresAt = Date.now() +
229
+ (options?.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS);
230
+ activeClaims.set(id, {
231
+ lease,
232
+ snapshot,
233
+ target: selfTarget,
234
+ action,
235
+ expiresAt,
236
+ });
198
237
  const target = {
199
238
  model: schemaKey,
200
239
  id,
@@ -209,7 +248,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
209
248
  claimId: lease.claimId,
210
249
  readAt: snapshot.stamp,
211
250
  target,
212
- action: options?.action ?? 'editing',
251
+ action,
213
252
  ...(options?.description ? { description: options.description } : {}),
214
253
  data: modelAsRow(model),
215
254
  release,
@@ -226,6 +265,28 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
226
265
  // are the same object.
227
266
  const claimApi = Object.assign(guard(claim), {
228
267
  state(params) {
268
+ // Read-interest: a passive observer subscribing to a row's claim state
269
+ // must enter that row's entity scope, or it sits on `org:`/`user:`
270
+ // groups only and never receives the holder's entity-scoped claim
271
+ // presence. Soft + fire-and-forget — never blocks or rejects the read.
272
+ void collaboration?.enterScope?.({ [schemaKey]: params.id });
273
+ // Self-awareness: the server excludes a holder's OWN presence frames and
274
+ // the client skips them, so `observe` returns null for a row WE hold.
275
+ // Synthesize the active claim for self from the stored lease so the
276
+ // holder sees its own claim (the JSDoc contract on `claim.state`).
277
+ const own = activeClaims.get(params.id);
278
+ if (own) {
279
+ return {
280
+ object: 'claim',
281
+ id: own.lease.claimId,
282
+ status: 'active',
283
+ target: own.target,
284
+ action: own.action,
285
+ heldBy: collaboration?.selfParticipantId ?? '',
286
+ participantKind: collaboration?.selfParticipantKind ?? 'user',
287
+ expiresAt: own.expiresAt,
288
+ };
289
+ }
229
290
  return collaboration?.observe({ model: wireModel, id: params.id }) ?? null;
230
291
  },
231
292
  queue(params) {
@@ -241,6 +302,11 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
241
302
  });
242
303
  const operations = {
243
304
  retrieve: guard(async (params) => {
305
+ // Read-interest enrolment: READ a row → enter its entity scope, so a
306
+ // Node/agent client lands in the same group the holder's claim presence
307
+ // fans out on and `claim.state`/`claim.queue` report peers. Soft +
308
+ // fire-and-forget — never make the read reject or slower.
309
+ void collaboration?.enterScope?.({ [schemaKey]: params.id });
244
310
  const rows = await load({
245
311
  ...params,
246
312
  where: [['id', params.id]],
@@ -248,6 +314,9 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
248
314
  });
249
315
  return rows[0];
250
316
  }),
317
+ // NB: no auto scope enrolment on bulk `list`/`getAll` — that would
318
+ // subscribe to an unbounded set of rows' entity groups. Bulk-list scope
319
+ // enrolment is a deliberate follow-up (a bounded, opt-in policy).
251
320
  list: guard(load),
252
321
  get(id) {
253
322
  return objectPool.get(id);
@@ -295,8 +364,14 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
295
364
  let autoLease;
296
365
  if (claim && !isClaimHandle(claim)) {
297
366
  if (!collaboration) {
298
- throw new AbloValidationError(`Model "${schemaKey}" cannot claim a row without collaboration wiring.`, { code: 'model_claim_not_configured' });
367
+ throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
299
368
  }
369
+ // Write-intent: enter the new row's entity scope BEFORE acquiring the
370
+ // create-claim so the holder's claim presence broadcasts to whoever is
371
+ // already in this entity group (closing the subscribe-vs-broadcast
372
+ // race — see `takeClaim`). Released with the lease in the `finally`
373
+ // below. Awaited for broadcast ordering; still soft.
374
+ await collaboration.pinScope?.({ [schemaKey]: id });
300
375
  autoLease = await collaboration.createClaim({
301
376
  target: {
302
377
  model: wireModel,
@@ -434,6 +509,12 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
434
509
  // `claim` is a callable namespace (take a claim) carrying the coordination
435
510
  // readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
436
511
  claim: claimApi,
512
+ watch: guard((ids, options) => {
513
+ if (!collaboration?.createWatch) {
514
+ throw new AbloValidationError(`Model "${schemaKey}" was built without a WebSocket runtime, so watch() is unavailable here. Presence needs a live socket — use the standard Ablo({ schema, apiKey }) client (not the HTTP transport).`, { code: 'model_watch_not_configured' });
515
+ }
516
+ return collaboration.createWatch(schemaKey, ids, options);
517
+ }),
437
518
  onChange(callback, options) {
438
519
  return autorun(() => {
439
520
  const entities = this.getAll(options);
@@ -23,8 +23,8 @@
23
23
  * surface as the browser client — typed proxies, stateless transport.
24
24
  */
25
25
  import { type AbloApiClientOptions } from './ApiClient.js';
26
- import type { CommitReceipt, CommitResource, HttpClaimApi, ModelRead, ModelReadOptions } from './Ablo.js';
27
- import type { ModelCreateParams, ModelDeleteParams, ModelLoadOptions, ModelRetrieveParams, ModelUpdateParams } from './createModelProxy.js';
26
+ import type { CommitReceipt, CommitResource, HttpClaimApi, ModelRead, ModelReadOptions, CreateSessionParams, AbloSession } from './Ablo.js';
27
+ import type { ModelCreateParams, ModelDeleteParams, ServerReadOptions, ModelRetrieveParams, ModelUpdateParams } from './createModelProxy.js';
28
28
  import type { Schema, SchemaRecord, InferModel, InferCreate } from '../schema/schema.js';
29
29
  export interface AbloHttpClientOptions<S extends SchemaRecord> extends Omit<AbloApiClientOptions, 'schema'> {
30
30
  /** The schema — used for TYPING only (typed model proxies); never sent or used at runtime. */
@@ -36,10 +36,16 @@ export interface AbloHttpClientOptions<S extends SchemaRecord> extends Omit<Ablo
36
36
  * and the durable-lease claim plane (`claim` — acquire/hold/release). It does NOT
37
37
  * include `get`/`getAll`/`getCount` (local synced-pool reads) or `onChange` (live
38
38
  * subscription); those need the stateful plane and are absent BY TYPE here.
39
+ *
40
+ * Read-shape asymmetry (by design, not a gap): `retrieve(...)` returns a
41
+ * `ModelRead<T>` envelope `{ data, stamp, claims }` — the stateless client has no
42
+ * local graph, so the watermark/claims the stateful client reads from its pool
43
+ * must ride inline on the read (an agent needs the `stamp` to do a stale-guarded
44
+ * write; there is no `snapshot()` to fetch it from). `list(...)` returns a bare `T[]`.
39
45
  */
40
46
  export interface HttpModelClient<T, C = T> {
41
47
  retrieve(params: ModelRetrieveParams & ModelReadOptions): Promise<ModelRead<T>>;
42
- list(options?: ModelLoadOptions<T>): Promise<T[]>;
48
+ list(options?: ServerReadOptions<T>): Promise<T[]>;
43
49
  create(params: ModelCreateParams<T, C>): Promise<CommitReceipt>;
44
50
  update(params: ModelUpdateParams<C>): Promise<CommitReceipt>;
45
51
  delete(params: ModelDeleteParams<T>): Promise<CommitReceipt>;
@@ -61,6 +67,14 @@ export type AbloHttpClient<S extends SchemaRecord> = {
61
67
  dispose(): Promise<void>;
62
68
  /** Resolve the bearer credential this client authenticates with (see `AbloApi.getAuthToken`). */
63
69
  getAuthToken(): Promise<string | null>;
70
+ /**
71
+ * Mint a short-lived scoped session (Stripe ephemeral-key shape). Minting is a
72
+ * stateless control-plane call, so — unlike `get`/`getAll`/`onChange` — it IS
73
+ * available on the HTTP client. `{ user }` → `ek_`, `{ agent, can }` → `rk_`.
74
+ */
75
+ readonly sessions: {
76
+ create(params: CreateSessionParams<S>): Promise<AbloSession>;
77
+ };
64
78
  /** String-keyed model accessor (for dynamic model names). */
65
79
  model<T = Record<string, unknown>>(name: string): HttpModelClient<T>;
66
80
  };
@@ -38,6 +38,7 @@ const PROTOCOL_MEMBERS = new Set([
38
38
  'commits',
39
39
  'model',
40
40
  'getAuthToken',
41
+ 'sessions',
41
42
  ]);
42
43
  /**
43
44
  * Stateless, typed HTTP client. Each `client.<model>` resolves to the protocol
@@ -21,137 +21,153 @@
21
21
  */
22
22
  import { AbloAuthenticationError } from '../errors.js';
23
23
  import { exchangeApiKey } from '../auth/index.js';
24
+ import { mintUserSessionKey } from '../auth/index.js';
24
25
  import { resolveIdentity } from '../auth/index.js';
25
26
  import { createRefreshScheduler, } from '../auth/index.js';
27
+ import { resolveCredential, } from '../auth/credentialPolicy.js';
26
28
  import { resolveApiKeyValue, resolveBootstrapBaseUrl } from './auth.js';
27
29
  export async function resolveParticipantIdentity(input) {
28
30
  const { options, internalOptions, url, kind, configuredApiKey, configuredAuthToken, bootstrapHelper, auth, logger, } = input;
29
31
  const apiKeyValue = await resolveApiKeyValue(configuredApiKey);
30
- const initialCapToken = options.capabilityToken ?? configuredAuthToken ?? undefined;
31
- // Branch 0: publishable key (`pk_`) a long-lived, browser-safe, READ-ONLY
32
- // project key. Unlike a secret `sk_` (Branch 1), it is used DIRECTLY as the
33
- // bearer and is NEVER exchanged for a short-lived capability — so it never
34
- // expires and there is nothing to refresh (no `credential_stale`, no
35
- // wake-from-sleep re-mint). The sync-server's `apiKeyProvider` resolves the
36
- // org + read-only scope from the key itself; we still call `/auth/identity`
37
- // (authenticated by the `pk_` bearer) to learn the account scope + syncGroups
38
- // for the bootstrap cache. Plain `startsWith` check because the `keys` module
39
- // is node-only (`node:crypto`) and must not enter the browser bundle.
40
- if (apiKeyValue && apiKeyValue.startsWith('pk_') && !options.capabilityToken) {
41
- const baseUrl = resolveBootstrapBaseUrl({
42
- url,
43
- bootstrapBaseUrl: options.bootstrapBaseUrl,
44
- });
45
- const identity = await resolveIdentity({ baseUrl, authToken: apiKeyValue });
46
- const callerGroups = options.syncGroups ?? [];
47
- const mergedSyncGroups = callerGroups.length > 0
48
- ? [...new Set([...callerGroups, ...identity.syncGroups])]
49
- : identity.syncGroups;
50
- bootstrapHelper.setCacheScope(identity.accountScope);
51
- bootstrapHelper.setSyncGroups(mergedSyncGroups);
52
- auth.setAuthToken(apiKeyValue);
53
- return {
54
- userId: identity.participantId,
55
- accountScope: identity.accountScope,
56
- teamIds: undefined,
57
- capabilityToken: apiKeyValue,
58
- syncGroups: mergedSyncGroups,
59
- participantKind: identity.participantKind,
60
- refreshScheduler: null,
61
- };
62
- }
63
- // Branch 1: hosted-cloud (apiKey only, no caller-supplied capability token)
64
- if (apiKeyValue && !options.capabilityToken) {
65
- return resolveHosted({
66
- apiKeyValue,
67
- configuredApiKey,
68
- url,
69
- kind,
70
- options,
71
- bootstrapHelper,
72
- auth,
73
- logger,
74
- });
75
- }
76
- // Branch 2: self-derived (capability token present, identity unknown)
77
- if (!internalOptions.organizationId ||
78
- (kind === 'agent' ? !options.agentId : !options.user?.id)) {
79
- // Fail fast on the missing-credential case. We're here because there's no
80
- // apiKey (Branch 1) and the identity isn't caller-supplied (Branch 3), so
81
- // `initialCapToken` is the only thing that can authenticate the
82
- // `/auth/identity` call. When it's absent — the common cause being
83
- // `getToken()` resolving to `null` (no/expired session, see
84
- // `getSyncCapabilityToken`) — the request can only come back as the server's
85
- // opaque `identity_resolve_failed: no_matching_provider`. Surface the real
86
- // condition locally instead: `session_expired` is the registered,
87
- // re-authenticate-able code, and we never make a doomed round-trip.
88
- if (!initialCapToken) {
89
- throw new AbloAuthenticationError('No auth token available to resolve identity — the session token is ' +
90
- 'missing or expired. Ensure `getToken()` returns a valid token, or ' +
91
- 'pass `apiKey` / `capabilityToken`.', { code: 'session_expired' });
92
- }
93
- // Single source of truth for the http(s) base — coerces ws/wss → http/https
94
- // even when `bootstrapBaseUrl` is an explicit override (see auth.ts).
95
- const baseUrl = resolveBootstrapBaseUrl({
96
- url,
97
- bootstrapBaseUrl: options.bootstrapBaseUrl,
98
- });
99
- const identity = await resolveIdentity({
32
+ // Single source of truth for the http(s) base — coerces ws/wss → http/https
33
+ // even when `bootstrapBaseUrl` is an explicit override (see auth.ts).
34
+ const baseUrl = resolveBootstrapBaseUrl({
35
+ url,
36
+ bootstrapBaseUrl: options.bootstrapBaseUrl,
37
+ });
38
+ // `internalOptions.organizationId` + a caller-supplied participant id is the
39
+ // legacy explicit path: the caller already knows its own identity, so no
40
+ // server round-trip is needed.
41
+ const hasExplicitIdentity = internalOptions.organizationId != null &&
42
+ (kind === 'agent' ? options.agentId != null : options.user?.id != null);
43
+ // The connect-time credential ROUTING decision lives in `credentialPolicy`:
44
+ // classify the apiKey (sk_/ek_/rk_/pk_) and route. The hosted exchange is the
45
+ // one mint the policy performs (delegating to the injected `exchangeApiKey`);
46
+ // every other route just hands back the bearer to use. We then switch on the
47
+ // resolved `kind` below to wire up scope + the refresh scheduler.
48
+ const cred = await resolveCredential({
49
+ apiKeyValue,
50
+ configuredApiKey,
51
+ capabilityToken: options.capabilityToken,
52
+ authToken: configuredAuthToken,
53
+ hasExplicitIdentity,
54
+ }, {
55
+ primitives: {
56
+ exchangeApiKey,
57
+ mintUserSessionKey,
58
+ resolveIdentity,
59
+ resolveApiKeyValue,
60
+ },
61
+ exchangeArgs: {
100
62
  baseUrl,
101
- authToken: initialCapToken,
102
- });
103
- // Merge caller-passed syncGroups with server-resolved ones rather
104
- // than letting the server's response silently overwrite. Browser
105
- // consumers (apps/web's SyncEngineProvider) compose
106
- // `['default', 'org:${orgId}', 'user:${userId}', ...team:]` from
107
- // the resolved session and pass it via `<AbloProvider syncGroups>`;
108
- // before this merge, Branch 2 dropped that set on the floor in
109
- // favor of `/auth/identity`'s response, which is empty for
110
- // cookie-auth users today (apps/sync-server/src/routes/auth.ts only
111
- // populates from `effectiveSyncGroups`, the cap-narrowed list).
112
- // Empty syncGroups server bootstrap falls back to `['default']`
113
- // no deltas fan out live updates appear only on hard reload.
114
- const callerGroups = options.syncGroups ?? [];
115
- const mergedSyncGroups = callerGroups.length > 0
116
- ? [...new Set([...callerGroups, ...identity.syncGroups])]
117
- : identity.syncGroups;
118
- bootstrapHelper.setCacheScope(identity.accountScope);
119
- bootstrapHelper.setSyncGroups(mergedSyncGroups);
120
- auth.setAuthToken(initialCapToken);
121
- return {
122
- userId: identity.participantId,
123
- accountScope: identity.accountScope,
124
- teamIds: undefined,
125
- capabilityToken: initialCapToken,
126
- syncGroups: mergedSyncGroups,
127
- participantKind: identity.participantKind,
128
- refreshScheduler: null,
129
- };
63
+ participantKind: (kind === 'agent' ? 'agent' : 'system'),
64
+ participantId: options.agentId ?? options.user?.id,
65
+ wideScope: true,
66
+ ttlSeconds: 3600,
67
+ },
68
+ });
69
+ switch (cred.kind) {
70
+ case 'publishable':
71
+ // `pk_` a long-lived, browser-safe, READ-ONLY project key. Used DIRECTLY
72
+ // as the bearer and NEVER exchanged for a short-lived capability — so it
73
+ // never expires and there is nothing to refresh. The sync-server's
74
+ // `apiKeyProvider` resolves the org + read-only scope from the key itself;
75
+ // we still call `/auth/identity` (authenticated by the `pk_` bearer) to
76
+ // learn the account scope + syncGroups for the bootstrap cache.
77
+ return resolveViaIdentity({
78
+ bearer: cred.getBearer,
79
+ baseUrl,
80
+ options,
81
+ bootstrapHelper,
82
+ auth,
83
+ });
84
+ case 'exchange':
85
+ // Hosted-cloud (`sk_`): the policy exchanged the apiKey for a capability
86
+ // token; here we apply the returned scope and set up the refresh scheduler.
87
+ return resolveHosted({
88
+ cred,
89
+ configuredApiKey,
90
+ baseUrl,
91
+ kind,
92
+ options,
93
+ bootstrapHelper,
94
+ auth,
95
+ logger,
96
+ });
97
+ case 'pre-minted':
98
+ // Self-derived: a pre-minted `ek_`/`rk_` bearer or an explicit capability
99
+ // token authenticates `/auth/identity` directly (no exchange, no refresh).
100
+ return resolveViaIdentity({
101
+ bearer: cred.getBearer,
102
+ baseUrl,
103
+ options,
104
+ bootstrapHelper,
105
+ auth,
106
+ });
107
+ case 'explicit': {
108
+ // Legacy explicit (self-hosted, pre-Phase-3 — caller knows its own
109
+ // organizationId + user/agentId).
110
+ const userId = kind === 'agent' ? options.agentId : options.user.id;
111
+ const accountScope = internalOptions.organizationId;
112
+ bootstrapHelper.setCacheScope(accountScope);
113
+ bootstrapHelper.setSyncGroups(options.syncGroups);
114
+ auth.setAuthToken(cred.getBearer);
115
+ return {
116
+ userId,
117
+ accountScope,
118
+ teamIds: kind === 'user' ? options.user?.teamIds : undefined,
119
+ capabilityToken: cred.getBearer,
120
+ syncGroups: options.syncGroups,
121
+ participantKind: kind,
122
+ refreshScheduler: null,
123
+ };
124
+ }
130
125
  }
131
- // Branch 3: legacy explicit (self-hosted, pre-Phase-3 — caller knows
132
- // its own organizationId + user/agentId).
133
- const userId = kind === 'agent' ? options.agentId : options.user.id;
134
- const accountScope = internalOptions.organizationId;
135
- bootstrapHelper.setCacheScope(accountScope);
136
- bootstrapHelper.setSyncGroups(options.syncGroups);
137
- auth.setAuthToken(initialCapToken);
126
+ }
127
+ /**
128
+ * Shared `/auth/identity` resolution for the `pk_` (publishable) and pre-minted
129
+ * (`ek_`/`rk_` or explicit cap token) routes: the bearer is used as-is, the
130
+ * server resolves the identity, and caller-passed syncGroups are MERGED with the
131
+ * server-resolved set.
132
+ */
133
+ async function resolveViaIdentity(input) {
134
+ const { bearer, baseUrl, options, bootstrapHelper, auth } = input;
135
+ const identity = await resolveIdentity({ baseUrl, authToken: bearer });
136
+ // Merge caller-passed syncGroups with server-resolved ones rather than letting
137
+ // the server's response silently overwrite. Browser consumers (apps/web's
138
+ // SyncEngineProvider) compose `['default', 'org:${orgId}', 'user:${userId}',
139
+ // ...team:]` from the resolved session and pass it via `<AbloProvider
140
+ // syncGroups>`; before this merge, the self-derived path dropped that set on
141
+ // the floor in favor of `/auth/identity`'s response, which is empty for
142
+ // cookie-auth users today (apps/sync-server/src/routes/auth.ts only populates
143
+ // from `effectiveSyncGroups`, the cap-narrowed list). Empty syncGroups →
144
+ // server bootstrap falls back to `['default']` → no deltas fan out → live
145
+ // updates appear only on hard reload.
146
+ const callerGroups = options.syncGroups ?? [];
147
+ const mergedSyncGroups = callerGroups.length > 0
148
+ ? [...new Set([...callerGroups, ...identity.syncGroups])]
149
+ : identity.syncGroups;
150
+ bootstrapHelper.setCacheScope(identity.accountScope);
151
+ bootstrapHelper.setSyncGroups(mergedSyncGroups);
152
+ auth.setAuthToken(bearer);
138
153
  return {
139
- userId,
140
- accountScope,
141
- teamIds: kind === 'user' ? options.user?.teamIds : undefined,
142
- capabilityToken: initialCapToken,
143
- syncGroups: options.syncGroups,
144
- participantKind: kind,
154
+ userId: identity.participantId,
155
+ accountScope: identity.accountScope,
156
+ teamIds: undefined,
157
+ capabilityToken: bearer,
158
+ syncGroups: mergedSyncGroups,
159
+ participantKind: identity.participantKind,
145
160
  refreshScheduler: null,
146
161
  };
147
162
  }
148
163
  async function resolveHosted(input) {
149
- // Pure managed-cloud shape: `Ablo({schema, apiKey})`. Server returns
150
- // scope + userMeta; SDK populates internals.
151
- const baseUrl = resolveBootstrapBaseUrl({
152
- url: input.url,
153
- bootstrapBaseUrl: input.options.bootstrapBaseUrl,
154
- });
164
+ // Pure managed-cloud shape: `Ablo({schema, apiKey})`. The credential policy
165
+ // already exchanged the apiKey (delegating to `exchangeApiKey`); here we apply
166
+ // the returned scope + userMeta and stand up the refresh scheduler.
167
+ const { exchange } = input.cred;
168
+ const baseUrl = input.baseUrl;
169
+ // The refresh path re-runs `exchangeApiKey` with a freshly-resolved apiKey, so
170
+ // it needs the same argument bag the policy used for the initial exchange.
155
171
  const exchangeArgs = {
156
172
  baseUrl,
157
173
  participantKind: (input.kind === 'agent' ? 'agent' : 'system'),
@@ -159,10 +175,6 @@ async function resolveHosted(input) {
159
175
  wideScope: true,
160
176
  ttlSeconds: 3600,
161
177
  };
162
- const exchange = await exchangeApiKey({
163
- ...exchangeArgs,
164
- apiKey: input.apiKeyValue,
165
- });
166
178
  input.bootstrapHelper.setCacheScope(exchange.scope.organizationId);
167
179
  input.bootstrapHelper.setSyncGroups(exchange.scope.syncGroups);
168
180
  input.auth.setAuthToken(exchange.token);
@@ -29,7 +29,7 @@
29
29
  * });
30
30
  * ```
31
31
  */
32
- export { Ablo, computeFKDepthPriority, type AbloOptions, type InternalAbloOptions, type ClaimedOptions, type IfClaimedPolicy, type ClaimWaitOptions, type ModelCountOptions, type ModelListOptions, type ModelListScope, type ModelLoadOptions, type ModelOperations, type ModelReadOptions, } from './Ablo.js';
32
+ export { Ablo, computeFKDepthPriority, type AbloOptions, type InternalAbloOptions, type ClaimedOptions, type IfClaimedPolicy, type ClaimWaitOptions, type LocalCountOptions, type LocalReadOptions, type ModelListScope, type ServerReadOptions, type ModelOperations, type ModelReadOptions, } from './Ablo.js';
33
33
  export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './auth.js';
34
34
  export type { AbloPersistence } from './persistence.js';
35
35
  export type { AbloApi, AbloApiClientOptions, AbloApiClaims, Capability, CapabilityCreateOptions, CapabilityParticipantKind, CapabilityRecord, CapabilityResource, CapabilityRevocation, CapabilityScope, } from './ApiClient.js';
@@ -0,0 +1,15 @@
1
+ import type { SchemaRecord } from '../schema/schema.js';
2
+ import type { AbloSession, CreateSessionParams } from './Ablo.js';
3
+ /** The resolved control-plane context a mint needs. `fetch` is optional — the
4
+ * auth helpers fall back to the runtime global when omitted. */
5
+ export interface MintSessionContext {
6
+ readonly apiKey: string;
7
+ readonly baseUrl: string;
8
+ readonly fetch?: typeof fetch;
9
+ }
10
+ /**
11
+ * Mint a session token from an already-resolved `sk_` credential + base URL.
12
+ * Discriminates the `{ user }` / `{ agent }` union onto the server's two mint
13
+ * doors and reshapes each flat response into the `AbloSession` resource.
14
+ */
15
+ export declare function mintSession<S extends SchemaRecord>(params: CreateSessionParams<S>, ctx: MintSessionContext): Promise<AbloSession>;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `mintSession` — the ONE implementation behind `sessions.create`, shared by the
3
+ * stateful `Ablo` client and the stateless protocol / HTTP client so the two can
4
+ * never drift on HOW a token is minted.
5
+ *
6
+ * Minting is a pure control-plane HTTP call (no socket, no synced pool): a backend
7
+ * holding a secret `sk_` exchanges it for a short-lived scoped token — `ek_` for an
8
+ * `{ user }` session (full end-user authority) or `rk_` for an `{ agent }` session
9
+ * (scoped to exactly the operations named in `can`). The two arms map to the
10
+ * server's two mint doors:
11
+ *
12
+ * `{ user }` → POST /auth/ephemeral-keys → `ek_`. The user-session door;
13
+ * routing this arm through /auth/capability is structurally
14
+ * impossible — that route rejects participantKind 'user' outright
15
+ * (`invalid_participant_kind`, the 2026-06-11 Pulse cascade where
16
+ * the SDK's own blessed pattern 403'd and integrators fell back to
17
+ * minting humans as agents).
18
+ * `{ agent }` → POST /auth/capability → scoped `rk_`. `can: { tasks: ['update'] }`
19
+ * serializes to the wire allowlist (`tasks.update`); the Hub matches
20
+ * it against every registered alias of the model.
21
+ *
22
+ * The caller supplies the resolved control-plane credential + base URL in `ctx`;
23
+ * WHICH key to use (the original `sk_`, never a derived `rk_` the startup exchange
24
+ * may have installed) is the caller's concern — see the two call sites.
25
+ *
26
+ * Type-only imports of `CreateSessionParams` / `AbloSession` keep this module a
27
+ * leaf (no runtime cycle back to `Ablo.ts`): at runtime it depends on `auth` +
28
+ * `schema` only.
29
+ */
30
+ import { exchangeApiKey, mintUserSessionKey } from '../auth/index.js';
31
+ /**
32
+ * Mint a session token from an already-resolved `sk_` credential + base URL.
33
+ * Discriminates the `{ user }` / `{ agent }` union onto the server's two mint
34
+ * doors and reshapes each flat response into the `AbloSession` resource.
35
+ */
36
+ export async function mintSession(params, ctx) {
37
+ const { apiKey, baseUrl } = ctx;
38
+ if (params.user) {
39
+ const res = await mintUserSessionKey({
40
+ apiKey,
41
+ baseUrl,
42
+ userId: params.user.id,
43
+ ...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
44
+ ttlSeconds: params.ttlSeconds ?? 900,
45
+ ...(ctx.fetch ? { fetch: ctx.fetch } : {}),
46
+ });
47
+ return {
48
+ object: 'session',
49
+ id: res.id,
50
+ token: res.token,
51
+ expiresAt: res.expiresAt,
52
+ organizationId: res.organizationId,
53
+ // The ephemeral mint stores scope on the key row; reshape its flat
54
+ // response into the session resource's scope block.
55
+ scope: {
56
+ organizationId: res.organizationId,
57
+ syncGroups: res.syncGroups,
58
+ operations: [],
59
+ participantKind: 'user',
60
+ participantId: res.participantId,
61
+ },
62
+ userMeta: params.userMeta ?? { id: res.participantId },
63
+ };
64
+ }
65
+ const operations = Object.entries(params.can).flatMap(([model, ops]) => (ops ?? []).map((op) => `${model.toLowerCase()}.${op}`));
66
+ const res = await exchangeApiKey({
67
+ apiKey,
68
+ baseUrl,
69
+ participantKind: 'agent',
70
+ participantId: params.agent.id,
71
+ ...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
72
+ operations,
73
+ ttlSeconds: params.ttlSeconds ?? 900,
74
+ ...(params.userMeta ? { userMeta: params.userMeta } : {}),
75
+ ...(ctx.fetch ? { fetch: ctx.fetch } : {}),
76
+ });
77
+ return {
78
+ object: 'session',
79
+ id: res.capabilityId,
80
+ token: res.token,
81
+ expiresAt: res.expiresAt,
82
+ organizationId: res.organizationId,
83
+ scope: res.scope,
84
+ userMeta: res.userMeta,
85
+ };
86
+ }
@@ -156,6 +156,7 @@ export declare const ERROR_CODES: {
156
156
  readonly model_claimed: ErrorCodeSpec;
157
157
  readonly model_claimed_timeout: ErrorCodeSpec;
158
158
  readonly model_claim_not_configured: ErrorCodeSpec;
159
+ readonly model_watch_not_configured: ErrorCodeSpec;
159
160
  readonly stale_context: ErrorCodeSpec;
160
161
  readonly idempotency_conflict: ErrorCodeSpec;
161
162
  readonly idempotency_key_too_long: ErrorCodeSpec;
@@ -195,6 +196,7 @@ export declare const ERROR_CODES: {
195
196
  readonly schema_scope_kind_invalid: ErrorCodeSpec;
196
197
  readonly schema_field_not_camelcase: ErrorCodeSpec;
197
198
  readonly schema_field_consecutive_caps: ErrorCodeSpec;
199
+ readonly schema_reserved_field: ErrorCodeSpec;
198
200
  readonly schema_grants_shape_invalid: ErrorCodeSpec;
199
201
  readonly schema_grants_identifier_unsafe: ErrorCodeSpec;
200
202
  readonly schema_grants_relation_kind: ErrorCodeSpec;