@abloatai/ablo 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +49 -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/ai-sdk/claim-broadcast.d.ts +4 -3
  6. package/dist/ai-sdk/claim-broadcast.js +2 -2
  7. package/dist/ai-sdk/wrap.d.ts +5 -4
  8. package/dist/ai-sdk/wrap.js +3 -3
  9. package/dist/auth/credentialPolicy.d.ts +145 -0
  10. package/dist/auth/credentialPolicy.js +130 -0
  11. package/dist/cli.cjs +42 -7
  12. package/dist/client/Ablo.d.ts +64 -91
  13. package/dist/client/Ablo.js +43 -103
  14. package/dist/client/ApiClient.d.ts +10 -1
  15. package/dist/client/ApiClient.js +45 -22
  16. package/dist/client/auth.d.ts +12 -5
  17. package/dist/client/auth.js +2 -1
  18. package/dist/client/createModelProxy.d.ts +64 -17
  19. package/dist/client/createModelProxy.js +18 -12
  20. package/dist/client/httpClient.d.ts +17 -3
  21. package/dist/client/httpClient.js +1 -0
  22. package/dist/client/identity.js +134 -122
  23. package/dist/client/index.d.ts +1 -1
  24. package/dist/client/sessionMint.d.ts +15 -0
  25. package/dist/client/sessionMint.js +86 -0
  26. package/dist/coordination/schema.d.ts +1 -1
  27. package/dist/coordination/schema.js +3 -1
  28. package/dist/errorCodes.d.ts +2 -0
  29. package/dist/errorCodes.js +2 -0
  30. package/dist/errors.d.ts +6 -3
  31. package/dist/errors.js +9 -3
  32. package/dist/index.d.ts +4 -4
  33. package/dist/index.js +4 -7
  34. package/dist/mutators/RecordingTransaction.js +14 -42
  35. package/dist/react/AbloProvider.d.ts +12 -13
  36. package/dist/react/AbloProvider.js +10 -10
  37. package/dist/react/context.d.ts +10 -45
  38. package/dist/react/context.js +12 -17
  39. package/dist/react/index.d.ts +8 -10
  40. package/dist/react/index.js +8 -11
  41. package/dist/react/useMutators.js +3 -2
  42. package/dist/react/useSyncStatus.d.ts +1 -1
  43. package/dist/react/useUndoScope.js +3 -2
  44. package/dist/realtime/index.d.ts +1 -1
  45. package/dist/schema/generate.js +1 -2
  46. package/dist/schema/model.d.ts +10 -3
  47. package/dist/schema/schema.d.ts +13 -2
  48. package/dist/schema/schema.js +26 -0
  49. package/dist/surface.d.ts +29 -0
  50. package/dist/surface.js +60 -0
  51. package/dist/sync/ConnectionManager.d.ts +16 -5
  52. package/dist/sync/ConnectionManager.js +42 -7
  53. package/dist/sync/createClaimStream.js +5 -4
  54. package/dist/sync/participants.js +1 -1
  55. package/dist/transactions/TransactionQueue.d.ts +0 -11
  56. package/dist/transactions/TransactionQueue.js +12 -56
  57. package/dist/types/global.d.ts +3 -0
  58. package/dist/types/streams.d.ts +17 -29
  59. package/dist/utils/mobx-setup.js +1 -0
  60. package/docs/api-keys.md +49 -0
  61. package/docs/api.md +3 -2
  62. package/docs/client-behavior.md +1 -0
  63. package/docs/coordination.md +75 -21
  64. package/docs/examples/existing-python-backend.md +9 -5
  65. package/docs/examples/scoped-agent.md +1 -1
  66. package/docs/guarantees.md +4 -3
  67. package/docs/identity.md +89 -82
  68. package/docs/integration-guide.md +19 -10
  69. package/docs/migration.md +11 -3
  70. package/docs/quickstart.md +6 -2
  71. package/docs/react.md +3 -3
  72. package/docs/schema-contract.md +23 -5
  73. package/llms-full.txt +18 -16
  74. package/llms.txt +6 -6
  75. package/package.json +1 -1
  76. package/dist/api/index.d.ts +0 -10
  77. package/dist/api/index.js +0 -9
  78. package/dist/principal.d.ts +0 -44
  79. package/dist/principal.js +0 -49
  80. package/dist/react/SyncGroupProvider.d.ts +0 -19
  81. package/dist/react/SyncGroupProvider.js +0 -44
  82. package/dist/react/useClaim.d.ts +0 -29
  83. package/dist/react/useClaim.js +0 -42
  84. package/dist/react/usePresence.d.ts +0 -32
  85. 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';
@@ -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
@@ -1135,7 +1124,7 @@ export function Ablo(options) {
1135
1124
  id: claim.id,
1136
1125
  actor: claim.heldBy,
1137
1126
  participantKind: claim.participantKind,
1138
- action: claim.reason,
1127
+ reason: claim.reason,
1139
1128
  ...(description ? { description } : {}),
1140
1129
  field: claim.target.field,
1141
1130
  status: 'active',
@@ -1155,7 +1144,7 @@ export function Ablo(options) {
1155
1144
  id: claim.id,
1156
1145
  actor: claim.heldBy,
1157
1146
  participantKind: claim.participantKind,
1158
- action: claim.action,
1147
+ reason: claim.reason,
1159
1148
  ...(claim.description ? { description: claim.description } : {}),
1160
1149
  field: claim.target.field,
1161
1150
  status: 'queued',
@@ -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 () => {
@@ -1263,7 +1246,7 @@ export function Ablo(options) {
1263
1246
  return {
1264
1247
  object: 'claim',
1265
1248
  claimId: claim.claimId,
1266
- action: claim.action,
1249
+ reason: claim.reason,
1267
1250
  target: claim.target,
1268
1251
  waited,
1269
1252
  release,
@@ -1282,7 +1265,7 @@ export function Ablo(options) {
1282
1265
  field: claimOptions.target.field,
1283
1266
  meta: claimOptions.target.meta,
1284
1267
  }, {
1285
- reason: claimOptions.action,
1268
+ reason: claimOptions.reason,
1286
1269
  ttl: claimOptions.ttl,
1287
1270
  queue: claimOptions.queue,
1288
1271
  });
@@ -1365,7 +1348,7 @@ export function Ablo(options) {
1365
1348
  ...(held.target.field ? { field: held.target.field } : {}),
1366
1349
  ...(held.target.meta ? { meta: held.target.meta } : {}),
1367
1350
  },
1368
- action: held.action,
1351
+ reason: held.reason,
1369
1352
  heldBy: held.actor,
1370
1353
  participantKind: held.participantKind,
1371
1354
  expiresAt: held.expiresAt,
@@ -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,7 +9,20 @@ 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';
14
+ /**
15
+ * The `/v1/claims` and model-query routes still emit the wire field `action`
16
+ * for the claim phase; the public `Claim` / `ModelClaim` expose it as `reason`.
17
+ * Heal on read so the SDK shape is consistent without a coordinated server
18
+ * deploy — `reason ?? action`. When the server adopts `reason`, this is a no-op.
19
+ */
20
+ function healClaimPhase(claim) {
21
+ const raw = claim;
22
+ if (raw.reason !== undefined)
23
+ return claim;
24
+ return { ...claim, reason: raw.action ?? 'editing' };
25
+ }
13
26
  const DEFAULT_AGENT_LEASE = '10m';
14
27
  export function createProtocolClient(options) {
15
28
  const env = readProcessEnv();
@@ -172,8 +185,8 @@ export function createProtocolClient(options) {
172
185
  const suffix = params.toString();
173
186
  const body = await requestJson(`/v1/claims${suffix ? `?${suffix}` : ''}`, { method: 'GET' });
174
187
  return {
175
- active: body.claims ?? [],
176
- queue: body.queue ?? [],
188
+ active: (body.claims ?? []).map(healClaimPhase),
189
+ queue: (body.queue ?? []).map(healClaimPhase),
177
190
  };
178
191
  }
179
192
  function delay(ms, signal) {
@@ -228,20 +241,11 @@ export function createProtocolClient(options) {
228
241
  const policy = options?.ifClaimed ?? defaultPolicy;
229
242
  if (policy === 'return')
230
243
  return;
244
+ // policy === 'fail' — gate the read only when the caller opts in.
231
245
  const state = await listClaimState(target);
232
246
  if (state.active.length === 0)
233
247
  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
- });
248
+ throw claimedError(target, state.active, 'model_claimed');
245
249
  }
246
250
  const commits = {
247
251
  async create(commitOptions) {
@@ -382,7 +386,8 @@ export function createProtocolClient(options) {
382
386
  body: JSON.stringify({
383
387
  claimId,
384
388
  target: claimOptions.target,
385
- action: claimOptions.action,
389
+ // Wire field stays `action`; public option is `reason`.
390
+ action: claimOptions.reason,
386
391
  ttl: claimOptions.ttl,
387
392
  queue: claimOptions.queue,
388
393
  }),
@@ -408,7 +413,7 @@ export function createProtocolClient(options) {
408
413
  return {
409
414
  object: 'claim',
410
415
  claimId: id,
411
- action: claimOptions.action,
416
+ reason: claimOptions.reason,
412
417
  target: claimOptions.target,
413
418
  release,
414
419
  revoke: () => {
@@ -459,7 +464,7 @@ export function createProtocolClient(options) {
459
464
  return {
460
465
  data,
461
466
  stamp: query.stamp ?? 0,
462
- claims: query.claims ?? [],
467
+ claims: (query.claims ?? []).map(healClaimPhase),
463
468
  };
464
469
  }
465
470
  /**
@@ -541,13 +546,14 @@ export function createProtocolClient(options) {
541
546
  const body = await requestJson(claimPath(params.id), {
542
547
  method: 'POST',
543
548
  body: JSON.stringify({
544
- action: params.action ?? 'editing',
549
+ // Wire field stays `action`; public option is `reason`.
550
+ action: params.reason ?? 'editing',
545
551
  ...(params.ttl !== undefined ? { ttl: params.ttl } : {}),
546
552
  ...(params.description !== undefined ? { description: params.description } : {}),
547
553
  ...(claimMeta(params) ? { meta: claimMeta(params) } : {}),
548
- // `wait` (default true) → queue behind the holder; false → fail-fast
554
+ // `queue` (default true) → queue behind the holder; false → fail-fast
549
555
  // with AbloClaimedError (work-distribution dedup).
550
- queue: params.wait ?? true,
556
+ queue: params.queue ?? true,
551
557
  }),
552
558
  });
553
559
  if (body.status === 'queued') {
@@ -573,7 +579,7 @@ export function createProtocolClient(options) {
573
579
  ...(params.range ? { range: params.range } : {}),
574
580
  ...(claimMeta(params) ? { meta: claimMeta(params) } : {}),
575
581
  },
576
- action: params.action ?? 'editing',
582
+ reason: params.reason ?? 'editing',
577
583
  ...(params.description ? { description: params.description } : {}),
578
584
  data,
579
585
  release,
@@ -588,11 +594,12 @@ export function createProtocolClient(options) {
588
594
  release: releaseClaim,
589
595
  state: async (params) => {
590
596
  const res = await claimsForEntity(params);
591
- return res.claims?.[0] ?? null;
597
+ const first = res.claims?.[0];
598
+ return first ? healClaimPhase(first) : null;
592
599
  },
593
600
  queue: async (params) => {
594
601
  const res = await claimsForEntity(params);
595
- return { object: 'list', data: res.queue ?? [] };
602
+ return { object: 'list', data: (res.queue ?? []).map(healClaimPhase) };
596
603
  },
597
604
  reorder: async (params) => {
598
605
  await requestJson(`${claimPath(params.id)}/reorder`, {
@@ -656,6 +663,22 @@ export function createProtocolClient(options) {
656
663
  claims,
657
664
  commits,
658
665
  model,
666
+ sessions: {
667
+ async create(params) {
668
+ // Stateless mint: the configured key IS the control-plane credential here
669
+ // (no startup `rk_` exchange runs on this client). Reuse the resolved base
670
+ // URL + fetch; the shared `mintSession` owns the two server doors.
671
+ const apiKey = await resolveApiKeyValue(configuredApiKey);
672
+ if (!apiKey) {
673
+ throw new AbloAuthenticationError('sessions.create requires a secret (sk_) API key — call it from your backend, not the browser.', { code: 'apikey_missing' });
674
+ }
675
+ return mintSession(params, {
676
+ apiKey,
677
+ baseUrl: apiBaseUrl,
678
+ ...(options.fetch ? { fetch: options.fetch } : {}),
679
+ });
680
+ },
681
+ },
659
682
  async getAuthToken() {
660
683
  // Mirror `authHeaders()`: a configured API key wins, else the
661
684
  // 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 " +