@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.2
4
+
5
+ ### Patch Changes
6
+
7
+ - a35d935: Fix stream-recorded undo capturing the wrong "before" value for updates. A second
8
+ update to the same field before the first sync-ack re-captured the original
9
+ pre-session value (first-old-wins + clear-only-on-ack), so undo of a quick second
10
+ edit jumped all the way back instead of one step. The queue now re-baselines a
11
+ field's tracked `.old` once its before-image is frozen into the committed
12
+ transaction.
13
+
14
+ Also close the create/update undo asymmetry: an update whose written key had no
15
+ in-place mutation produced an empty `previousData`, which made the inverse
16
+ un-revertible (a create's `delete` inverse never is). Before-image capture now
17
+ falls back to the last loaded/acked snapshot.
18
+
19
+ Internally, the two undo paths (stream-recorded and manual `RecordingTransaction`)
20
+ now share one before-image implementation via `Model.capturePreviousValues` /
21
+ `Model.consumeModifiedFields`, so they can no longer drift.
22
+
23
+ - One-correct-way consolidation (breaking; no external consumers yet, so released as a patch):
24
+ - Credentials collapse to a single `apiKey` — a string, or a `() => Promise<string | null>` that
25
+ fetches a per-user token. Removed `getToken` / `authEndpoint` / public `authToken`.
26
+ - `ablo.<model>.watch(ids, { ttl })` replaces the top-level `ablo.participants.join({ scope })` —
27
+ model-scoped read-interest + presence (WebSocket only).
28
+ - Read claim-gating is `ifClaimed: 'return' | 'fail'` (removed `'wait'`); waiting is the claim
29
+ primitive's job (`ablo.<model>.claim`).
30
+ - The stateless client is `Ablo({ transport: 'http' })`; `createAbloHttpClient` is no longer a
31
+ public export (the factory uses it internally).
32
+ - Read-option types renamed: `ServerReadOptions` (server `retrieve`/`list`) and `LocalReadOptions`
33
+ (local `get`/`getAll`).
34
+ - `defineSchema` throws a clear error on a reserved-field collision; the MCP/docs API surface is
35
+ now compile-time bound to the real exported types (can't drift).
36
+
3
37
  ## 0.11.1
4
38
 
5
39
  ### Patch Changes
package/README.md CHANGED
@@ -163,6 +163,8 @@ loses your model types. There is no bespoke client-type generic to import —
163
163
 
164
164
  ```ts
165
165
  const schema = defineSchema({
166
+ // Reserved fields (id, createdAt, updatedAt, organizationId, createdBy) are
167
+ // provided automatically — don't declare them.
166
168
  weatherReports: model({
167
169
  location: z.string(),
168
170
  status: z.enum(['pending', 'ready']),
@@ -318,7 +320,10 @@ import { AbloProvider, useAblo } from '@abloatai/ablo/react';
318
320
  import { schema } from './ablo/schema';
319
321
 
320
322
  // Build the client once — it authenticates via your session route, no key in the browser.
321
- const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
323
+ const ablo = Ablo({
324
+ schema,
325
+ apiKey: () => fetch('/api/ablo-session').then((r) => r.text()),
326
+ });
322
327
 
323
328
  function App() {
324
329
  return (
@@ -371,7 +376,10 @@ to sync-group strings.
371
376
 
372
377
  ```tsx
373
378
  // team membership is asserted server-side when the session route mints the token.
374
- const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
379
+ const ablo = Ablo({
380
+ schema,
381
+ apiKey: () => fetch('/api/ablo-session').then((r) => r.text()),
382
+ });
375
383
 
376
384
  <AbloProvider client={ablo} userId={user.id}>
377
385
  <App />
package/dist/Model.d.ts CHANGED
@@ -183,6 +183,45 @@ export declare abstract class Model {
183
183
  * Clear tracked changes
184
184
  */
185
185
  clearChanges(): void;
186
+ /**
187
+ * Capture a before-image for `keys` — the SINGLE source of truth for the
188
+ * "previous value" that undo inverses are built from. Both undo paths call
189
+ * this so they can never drift: the stream path
190
+ * (`TransactionQueue.extractPreviousData`) and the manual-record path
191
+ * (`RecordingTransaction.snapshotFields`).
192
+ *
193
+ * Resolution order per key:
194
+ * 1. `modifiedProperties.get(key).old` — first-old-wins pre-session
195
+ * baseline, set whenever the field was mutated in place before commit.
196
+ * 2. `getOriginalSnapshot()[key]` — the last loaded/acked row, the correct
197
+ * before-image for a key written WITHOUT a prior in-place mutation
198
+ * (e.g. a `precomputedChanges` write).
199
+ * 3. `fallbackToLive` only — the current live value. The manual-record path
200
+ * wants this last resort; the stream path deliberately OMITS unresolved
201
+ * keys so `buildUndoOps` drops an un-revertible inverse rather than
202
+ * inventing one. The flag is the one intentional difference between the
203
+ * two callers — do not collapse it.
204
+ *
205
+ * `id` is always skipped. Values are read out per-key, so the
206
+ * `getOriginalSnapshot()` "callers must not mutate" contract is preserved.
207
+ *
208
+ * Invariant this relies on: a given undo scope is EITHER stream-recorded
209
+ * (`recordFromStream: true`) OR manual (`useMutators({ undoScope })`), never
210
+ * both — otherwise a write would be captured twice. No surface sets both.
211
+ */
212
+ capturePreviousValues(keys: Iterable<string>, opts?: {
213
+ fallbackToLive?: boolean;
214
+ }): ModelData;
215
+ /**
216
+ * Drop the `modifiedProperties` entries for `keys` — re-baselines a field
217
+ * after its `.old` has been frozen into a committed transaction, so the NEXT
218
+ * write to the same field starts from this commit's result rather than the
219
+ * stale pre-session `.old` that {@link propertyChanged}'s first-old-wins
220
+ * policy preserves. Safe because the committed transaction owns its own
221
+ * frozen `data`/`previousData`; neither re-reads `modifiedProperties`. `id`
222
+ * is never consumed. With no `keys`, consumes every tracked field.
223
+ */
224
+ consumeModifiedFields(keys?: Iterable<string>): void;
186
225
  /**
187
226
  * Validate model
188
227
  */
package/dist/Model.js CHANGED
@@ -223,6 +223,74 @@ export class Model {
223
223
  this._originalData = this.captureSnapshot();
224
224
  });
225
225
  }
226
+ /**
227
+ * Capture a before-image for `keys` — the SINGLE source of truth for the
228
+ * "previous value" that undo inverses are built from. Both undo paths call
229
+ * this so they can never drift: the stream path
230
+ * (`TransactionQueue.extractPreviousData`) and the manual-record path
231
+ * (`RecordingTransaction.snapshotFields`).
232
+ *
233
+ * Resolution order per key:
234
+ * 1. `modifiedProperties.get(key).old` — first-old-wins pre-session
235
+ * baseline, set whenever the field was mutated in place before commit.
236
+ * 2. `getOriginalSnapshot()[key]` — the last loaded/acked row, the correct
237
+ * before-image for a key written WITHOUT a prior in-place mutation
238
+ * (e.g. a `precomputedChanges` write).
239
+ * 3. `fallbackToLive` only — the current live value. The manual-record path
240
+ * wants this last resort; the stream path deliberately OMITS unresolved
241
+ * keys so `buildUndoOps` drops an un-revertible inverse rather than
242
+ * inventing one. The flag is the one intentional difference between the
243
+ * two callers — do not collapse it.
244
+ *
245
+ * `id` is always skipped. Values are read out per-key, so the
246
+ * `getOriginalSnapshot()` "callers must not mutate" contract is preserved.
247
+ *
248
+ * Invariant this relies on: a given undo scope is EITHER stream-recorded
249
+ * (`recordFromStream: true`) OR manual (`useMutators({ undoScope })`), never
250
+ * both — otherwise a write would be captured twice. No surface sets both.
251
+ */
252
+ capturePreviousValues(keys, opts) {
253
+ const out = {};
254
+ const modified = this.modifiedProperties instanceof Map ? this.modifiedProperties : null;
255
+ const original = this.getOriginalSnapshot();
256
+ for (const key of keys) {
257
+ if (key === 'id')
258
+ continue;
259
+ const mod = modified?.get(key);
260
+ if (mod) {
261
+ out[key] = mod.old;
262
+ }
263
+ else if (original && key in original) {
264
+ out[key] = original[key];
265
+ }
266
+ else if (opts?.fallbackToLive) {
267
+ out[key] = Reflect.get(this, key);
268
+ }
269
+ }
270
+ return out;
271
+ }
272
+ /**
273
+ * Drop the `modifiedProperties` entries for `keys` — re-baselines a field
274
+ * after its `.old` has been frozen into a committed transaction, so the NEXT
275
+ * write to the same field starts from this commit's result rather than the
276
+ * stale pre-session `.old` that {@link propertyChanged}'s first-old-wins
277
+ * policy preserves. Safe because the committed transaction owns its own
278
+ * frozen `data`/`previousData`; neither re-reads `modifiedProperties`. `id`
279
+ * is never consumed. With no `keys`, consumes every tracked field.
280
+ */
281
+ consumeModifiedFields(keys) {
282
+ if (!(this.modifiedProperties instanceof Map) || this.modifiedProperties.size === 0) {
283
+ return;
284
+ }
285
+ const only = keys ? new Set(keys) : null;
286
+ for (const key of [...this.modifiedProperties.keys()]) {
287
+ if (key === 'id')
288
+ continue;
289
+ if (only && !only.has(key))
290
+ continue;
291
+ this.modifiedProperties.delete(key);
292
+ }
293
+ }
226
294
  /**
227
295
  * Validate model
228
296
  */
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Credential POLICY — the single source of truth for "what KIND of credential
3
+ * did the caller hand us, and what do we DO with it at connect time".
4
+ *
5
+ * Before this module the prefix-dispatch decision (`sk_`/`ek_`/`rk_`/`pk_`) was
6
+ * re-implemented with raw `startsWith()` sniffs in ~5 places (identity.ts ×3,
7
+ * auth.ts browser guard, cli/dev.ts, cli/push.ts) and the connect-time routing
8
+ * lived as a 4-branch if/elif tree inside `resolveParticipantIdentity`. Folding
9
+ * the policy here keeps the kind-taxonomy and the connect decision in ONE place;
10
+ * the consumers below just call into it.
11
+ *
12
+ * This module is deliberately POLICY-ONLY. It does NOT own the auth primitives
13
+ * (`exchangeApiKey` / `mintUserSessionKey` / `resolveIdentity`), the credential
14
+ * lifecycle (`startCredentialLifecycle` / refresh scheduler), or the connection
15
+ * FSM — those are correctly distributed consumers. `resolveCredential` DELEGATES
16
+ * to injected primitives rather than reimplementing any HTTP mint call.
17
+ *
18
+ * Browser-safe: `classifyCredentialKind` is a pure-string helper and MUST NOT
19
+ * import the Node-only `keys` module (`node:crypto`). The key-prefix contract it
20
+ * encodes mirrors `keys/index.ts`'s `KIND_BY_PREFIX` (the Stripe-style model:
21
+ * sk_=secret, rk_=restricted, ek_=ephemeral, pk_=publishable) but stays a plain
22
+ * prefix lookup so it can ship in the client bundle.
23
+ */
24
+ import type { exchangeApiKey, mintUserSessionKey, resolveIdentity } from './index.js';
25
+ import type { resolveApiKeyValue } from '../client/auth.js';
26
+ /**
27
+ * The four Ablo API-key kinds (Stripe-style). Prefix contract — kept in lockstep
28
+ * with `keys/index.ts` `API_KEY_KINDS` / `KIND_BY_PREFIX`, but declared locally
29
+ * so this browser-safe module never pulls in `node:crypto`.
30
+ */
31
+ export type CredentialKind = 'secret' | 'ephemeral' | 'restricted' | 'publishable';
32
+ /**
33
+ * Lightweight, browser-safe prefix → kind classifier. The SINGLE source of truth
34
+ * for prefix dispatch across the SDK (connect routing, the browser guard, the
35
+ * CLI key-gating). Returns `null` for a value that carries no recognized Ablo
36
+ * key prefix (a caller-supplied capability/auth token, an empty/garbage value).
37
+ *
38
+ * Pure string check — does NOT validate the checksum or environment segment
39
+ * (that's `keys/index.ts` `parseApiKey`, which is Node-only). This is only the
40
+ * "which of the four buckets" decision.
41
+ */
42
+ export declare function classifyCredentialKind(value: string): CredentialKind | null;
43
+ /**
44
+ * Auth primitives injected into {@link resolveCredential}. Each is the canonical
45
+ * implementation from `auth/index.ts` / `client/auth.ts`; the policy DELEGATES to
46
+ * them so the HTTP mint logic stays in ONE place and only the routing decision
47
+ * lives here. `mintUserSessionKey` is carried for completeness of the primitive
48
+ * surface (the browser/session path mints it before connect); `resolveCredential`
49
+ * never re-mints it — a pre-minted `ek_` arrives ready to use.
50
+ */
51
+ export interface CredentialPrimitives {
52
+ readonly exchangeApiKey: typeof exchangeApiKey;
53
+ readonly mintUserSessionKey: typeof mintUserSessionKey;
54
+ readonly resolveIdentity: typeof resolveIdentity;
55
+ readonly resolveApiKeyValue: typeof resolveApiKeyValue;
56
+ }
57
+ export interface ResolveCredentialContext {
58
+ readonly primitives: CredentialPrimitives;
59
+ /**
60
+ * Build the argument bag for the hosted exchange. identity.ts owns the baseUrl
61
+ * derivation + participant scope, so it supplies the args; the policy invokes
62
+ * `primitives.exchangeApiKey` with them. The `apiKey` is filled in by the policy
63
+ * from the resolved value.
64
+ */
65
+ readonly exchangeArgs: Omit<Parameters<typeof exchangeApiKey>[0], 'apiKey'>;
66
+ }
67
+ export interface ResolveCredentialInput {
68
+ /** Resolved string value of the configured `apiKey` (callable already invoked), or null. */
69
+ readonly apiKeyValue: string | null;
70
+ /** The configured `apiKey` (string or setter) — threaded onto the refresh path. */
71
+ readonly configuredApiKey: string | (() => Promise<string | null>) | null;
72
+ /** Explicit caller-supplied capability token (`options.capabilityToken`). */
73
+ readonly capabilityToken: string | undefined;
74
+ /** Configured static `authToken`. */
75
+ readonly authToken: string | null;
76
+ /** True once the caller knows its own identity (legacy explicit path). */
77
+ readonly hasExplicitIdentity: boolean;
78
+ }
79
+ /**
80
+ * The connect-time decision, expressed as a discriminated union over the routing
81
+ * kind (NOT the raw key kind — `ek_` and `rk_` collapse into the same
82
+ * `pre-minted` route, and a bare capability token routes the same way). The
83
+ * caller (`identity.ts`) switches on `kind` and performs the scope/side-effect
84
+ * wiring each route needs.
85
+ *
86
+ * Fields carry exactly what each branch in the old if/elif tree produced:
87
+ * - `getBearer` — the token to authenticate the bootstrap/`/auth/*` HTTP
88
+ * and to seed the credential source with.
89
+ * - `expiresAtMs` — exchange expiry (drives the refresh scheduler) or null
90
+ * when the credential never expires / nothing to refresh.
91
+ * - `controlPlaneKey` — the ORIGINAL configured apiKey when the route minted
92
+ * via exchange (so a refresh can re-mint), else null.
93
+ */
94
+ export type ResolvedCredential =
95
+ /** `pk_` — long-lived browser-safe read-only project key. Used directly as the
96
+ * bearer; never exchanged, never refreshed. Identity resolved via `/auth/identity`. */
97
+ {
98
+ readonly kind: 'publishable';
99
+ readonly getBearer: string;
100
+ readonly expiresAtMs: null;
101
+ readonly controlPlaneKey: null;
102
+ }
103
+ /** `sk_` (no explicit cap token) — hosted-cloud. Exchanged for a capability
104
+ * token via `exchangeApiKey`; the refresh scheduler re-mints before expiry. */
105
+ | {
106
+ readonly kind: 'exchange';
107
+ /** Result of the initial `exchangeApiKey` call. */
108
+ readonly exchange: Awaited<ReturnType<typeof exchangeApiKey>>;
109
+ readonly getBearer: string;
110
+ readonly expiresAtMs: number;
111
+ /** The configured apiKey (string or setter) — read fresh on each refresh. */
112
+ readonly controlPlaneKey: string | (() => Promise<string | null>);
113
+ }
114
+ /** Pre-minted `ek_`/`rk_` OR an explicit capability/auth token — used AS-IS as
115
+ * the bearer (never exchanged). Identity resolved via `/auth/identity`. */
116
+ | {
117
+ readonly kind: 'pre-minted';
118
+ readonly getBearer: string;
119
+ readonly expiresAtMs: null;
120
+ readonly controlPlaneKey: null;
121
+ }
122
+ /** Legacy explicit — caller knows its own organizationId + user/agentId. No
123
+ * server round-trip; the (optional) bearer is the initial cap token. */
124
+ | {
125
+ readonly kind: 'explicit';
126
+ readonly getBearer: string | undefined;
127
+ readonly expiresAtMs: null;
128
+ readonly controlPlaneKey: null;
129
+ };
130
+ /**
131
+ * Connect-time credential routing — absorbs the decision tree that used to live
132
+ * inline in `resolveParticipantIdentity`. Classifies the configured apiKey, then
133
+ * routes to one of four outcomes, DELEGATING the actual HTTP exchange to the
134
+ * injected `exchangeApiKey` primitive. The caller switches on
135
+ * `ResolvedCredential.kind` to perform scope wiring + scheduler setup.
136
+ *
137
+ * Routing (preserves the old branch order exactly):
138
+ * 0. `pk_` + no explicit cap token → `publishable` (direct bearer, no refresh).
139
+ * 1. exchangeable apiKey (any prefix that ISN'T a pre-minted `ek_`/`rk_`) +
140
+ * no explicit cap token → `exchange` (hosted-cloud round-trip + scheduler).
141
+ * 2. otherwise, identity unknown → `pre-minted` (use the cap token as-is). Throws
142
+ * `session_expired` when there is no token to authenticate `/auth/identity`.
143
+ * 3. otherwise (identity known) → `explicit` (legacy self-hosted, no round-trip).
144
+ */
145
+ export declare function resolveCredential(input: ResolveCredentialInput, ctx: ResolveCredentialContext): Promise<ResolvedCredential>;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Credential POLICY — the single source of truth for "what KIND of credential
3
+ * did the caller hand us, and what do we DO with it at connect time".
4
+ *
5
+ * Before this module the prefix-dispatch decision (`sk_`/`ek_`/`rk_`/`pk_`) was
6
+ * re-implemented with raw `startsWith()` sniffs in ~5 places (identity.ts ×3,
7
+ * auth.ts browser guard, cli/dev.ts, cli/push.ts) and the connect-time routing
8
+ * lived as a 4-branch if/elif tree inside `resolveParticipantIdentity`. Folding
9
+ * the policy here keeps the kind-taxonomy and the connect decision in ONE place;
10
+ * the consumers below just call into it.
11
+ *
12
+ * This module is deliberately POLICY-ONLY. It does NOT own the auth primitives
13
+ * (`exchangeApiKey` / `mintUserSessionKey` / `resolveIdentity`), the credential
14
+ * lifecycle (`startCredentialLifecycle` / refresh scheduler), or the connection
15
+ * FSM — those are correctly distributed consumers. `resolveCredential` DELEGATES
16
+ * to injected primitives rather than reimplementing any HTTP mint call.
17
+ *
18
+ * Browser-safe: `classifyCredentialKind` is a pure-string helper and MUST NOT
19
+ * import the Node-only `keys` module (`node:crypto`). The key-prefix contract it
20
+ * encodes mirrors `keys/index.ts`'s `KIND_BY_PREFIX` (the Stripe-style model:
21
+ * sk_=secret, rk_=restricted, ek_=ephemeral, pk_=publishable) but stays a plain
22
+ * prefix lookup so it can ship in the client bundle.
23
+ */
24
+ import { AbloAuthenticationError } from '../errors.js';
25
+ const KIND_BY_PREFIX = [
26
+ ['sk_', 'secret'],
27
+ ['ek_', 'ephemeral'],
28
+ ['rk_', 'restricted'],
29
+ ['pk_', 'publishable'],
30
+ ];
31
+ /**
32
+ * Lightweight, browser-safe prefix → kind classifier. The SINGLE source of truth
33
+ * for prefix dispatch across the SDK (connect routing, the browser guard, the
34
+ * CLI key-gating). Returns `null` for a value that carries no recognized Ablo
35
+ * key prefix (a caller-supplied capability/auth token, an empty/garbage value).
36
+ *
37
+ * Pure string check — does NOT validate the checksum or environment segment
38
+ * (that's `keys/index.ts` `parseApiKey`, which is Node-only). This is only the
39
+ * "which of the four buckets" decision.
40
+ */
41
+ export function classifyCredentialKind(value) {
42
+ for (const [prefix, kind] of KIND_BY_PREFIX) {
43
+ if (value.startsWith(prefix))
44
+ return kind;
45
+ }
46
+ return null;
47
+ }
48
+ /**
49
+ * Connect-time credential routing — absorbs the decision tree that used to live
50
+ * inline in `resolveParticipantIdentity`. Classifies the configured apiKey, then
51
+ * routes to one of four outcomes, DELEGATING the actual HTTP exchange to the
52
+ * injected `exchangeApiKey` primitive. The caller switches on
53
+ * `ResolvedCredential.kind` to perform scope wiring + scheduler setup.
54
+ *
55
+ * Routing (preserves the old branch order exactly):
56
+ * 0. `pk_` + no explicit cap token → `publishable` (direct bearer, no refresh).
57
+ * 1. exchangeable apiKey (any prefix that ISN'T a pre-minted `ek_`/`rk_`) +
58
+ * no explicit cap token → `exchange` (hosted-cloud round-trip + scheduler).
59
+ * 2. otherwise, identity unknown → `pre-minted` (use the cap token as-is). Throws
60
+ * `session_expired` when there is no token to authenticate `/auth/identity`.
61
+ * 3. otherwise (identity known) → `explicit` (legacy self-hosted, no round-trip).
62
+ */
63
+ export async function resolveCredential(input, ctx) {
64
+ const { apiKeyValue, capabilityToken, authToken, hasExplicitIdentity } = input;
65
+ const kind = apiKeyValue != null ? classifyCredentialKind(apiKeyValue) : null;
66
+ // A pre-minted capability bearer (`ek_` ephemeral / `rk_` restricted) is NOT
67
+ // exchangeable — it was already minted into the credential source before
68
+ // connect and must be USED DIRECTLY as the bearer (Route 2), never sent through
69
+ // `exchangeApiKey` (Route 1, which expects an `sk_`).
70
+ const isPreMintedCapabilityBearer = kind === 'ephemeral' || kind === 'restricted';
71
+ const initialCapToken = capabilityToken ??
72
+ (isPreMintedCapabilityBearer ? apiKeyValue ?? undefined : undefined) ??
73
+ authToken ??
74
+ undefined;
75
+ // Route 0: publishable key (`pk_`) — long-lived, browser-safe, READ-ONLY. Used
76
+ // DIRECTLY as the bearer; never exchanged → never expires → nothing to refresh.
77
+ if (apiKeyValue != null && kind === 'publishable' && capabilityToken == null) {
78
+ return {
79
+ kind: 'publishable',
80
+ getBearer: apiKeyValue,
81
+ expiresAtMs: null,
82
+ controlPlaneKey: null,
83
+ };
84
+ }
85
+ // Route 1: hosted-cloud (secret/exchangeable apiKey, no caller-supplied cap
86
+ // token). A pre-minted `ek_`/`rk_` is NOT exchangeable → falls through.
87
+ if (apiKeyValue != null &&
88
+ capabilityToken == null &&
89
+ !isPreMintedCapabilityBearer) {
90
+ const exchange = await ctx.primitives.exchangeApiKey({
91
+ ...ctx.exchangeArgs,
92
+ apiKey: apiKeyValue,
93
+ });
94
+ return {
95
+ kind: 'exchange',
96
+ exchange,
97
+ getBearer: exchange.token,
98
+ expiresAtMs: Date.parse(exchange.expiresAt),
99
+ controlPlaneKey: input.configuredApiKey ?? apiKeyValue,
100
+ };
101
+ }
102
+ // Route 2: self-derived / pre-minted (use the cap token as-is). Reached when
103
+ // identity is NOT caller-supplied.
104
+ if (!hasExplicitIdentity) {
105
+ if (initialCapToken == null) {
106
+ // No apiKey to exchange (Route 1) and no caller-supplied identity (Route 3),
107
+ // so `initialCapToken` is the only thing that could authenticate
108
+ // `/auth/identity`. Absent — commonly the function `apiKey` resolver
109
+ // returning `null` (no/expired session) — surface the real, re-auth-able
110
+ // condition locally instead of making a doomed round-trip.
111
+ throw new AbloAuthenticationError('No auth token available to resolve identity — the session token is ' +
112
+ 'missing or expired. Ensure your `apiKey` resolver returns a valid token, or ' +
113
+ 'pass a static `apiKey` / `capabilityToken`.', { code: 'session_expired' });
114
+ }
115
+ return {
116
+ kind: 'pre-minted',
117
+ getBearer: initialCapToken,
118
+ expiresAtMs: null,
119
+ controlPlaneKey: null,
120
+ };
121
+ }
122
+ // Route 3: legacy explicit (self-hosted — caller knows its own
123
+ // organizationId + user/agentId).
124
+ return {
125
+ kind: 'explicit',
126
+ getBearer: initialCapToken,
127
+ expiresAtMs: null,
128
+ controlPlaneKey: null,
129
+ };
130
+ }
package/dist/cli.cjs CHANGED
@@ -276804,6 +276804,7 @@ var ERROR_CODES = {
276804
276804
  model_claimed: wire("claim", 409, false, "The model instance is claimed by another participant."),
276805
276805
  model_claimed_timeout: wire("claim", 409, false, "Timed out waiting for a model claim to clear."),
276806
276806
  model_claim_not_configured: client("claim", "Claiming requires the collaboration runtime, which the standard Ablo({ schema, apiKey }) client wires up for every model automatically \u2014 there is no per-model claim configuration to add. This appears only when a model proxy is constructed directly without that runtime (an internal/advanced path)."),
276807
+ model_watch_not_configured: client("claim", "watch() opens a presence/claim subscription and needs a live WebSocket, so it is unavailable on the HTTP transport and on model proxies built without a socket. Use the standard Ablo({ schema, apiKey }) client (default WebSocket transport)."),
276807
276808
  // ── stale context / idempotency (409) ──────────────────────────────
276808
276809
  stale_context: wire("conflict", 409, true, "The write carried a readAt watermark that is now stale; re-read and retry."),
276809
276810
  idempotency_conflict: wire("conflict", 409, false, "The same Idempotency-Key was reused with a different request body."),
@@ -276855,6 +276856,7 @@ var ERROR_CODES = {
276855
276856
  schema_scope_kind_invalid: wire("schema", 400, false, "A scope kind in the schema is invalid."),
276856
276857
  schema_field_not_camelcase: wire("schema", 400, false, "A schema field name is not camelCase."),
276857
276858
  schema_field_consecutive_caps: wire("schema", 400, false, "A schema field name has consecutive capital letters."),
276859
+ schema_reserved_field: client("schema", "A model redeclared a reserved base field (id, createdAt, updatedAt, organizationId, createdBy) that the SDK provides automatically."),
276858
276860
  schema_grants_shape_invalid: wire("schema", 400, false, "A grants declaration has an invalid shape."),
276859
276861
  schema_grants_identifier_unsafe: wire("schema", 400, false, "A grants declaration referenced an unsafe identifier."),
276860
276862
  schema_grants_relation_kind: wire("schema", 400, false, "A grants relation referenced an invalid kind."),
@@ -279616,6 +279618,23 @@ var import_source = require("@abloatai/ablo/source");
279616
279618
  // src/cli/push.ts
279617
279619
  init_cjs_shims();
279618
279620
  var import_picocolors3 = __toESM(require_picocolors(), 1);
279621
+
279622
+ // src/auth/credentialPolicy.ts
279623
+ init_cjs_shims();
279624
+ var KIND_BY_PREFIX = [
279625
+ ["sk_", "secret"],
279626
+ ["ek_", "ephemeral"],
279627
+ ["rk_", "restricted"],
279628
+ ["pk_", "publishable"]
279629
+ ];
279630
+ function classifyCredentialKind(value) {
279631
+ for (const [prefix, kind] of KIND_BY_PREFIX) {
279632
+ if (value.startsWith(prefix)) return kind;
279633
+ }
279634
+ return null;
279635
+ }
279636
+
279637
+ // src/cli/push.ts
279619
279638
  var import_fs4 = require("fs");
279620
279639
  var import_path3 = require("path");
279621
279640
  var import_schema2 = require("@abloatai/ablo/schema");
@@ -279928,7 +279947,7 @@ async function push(argv) {
279928
279947
  console.error(import_picocolors3.default.dim(` Re-push with ${import_picocolors3.default.bold("--force")} to override, or use ${import_picocolors3.default.bold("--rename old:new")} if you renamed a model.`));
279929
279948
  } else if (status2 === 403) {
279930
279949
  console.error(import_picocolors3.default.red(` Forbidden: ${body.message ?? body.reason ?? "key lacks schema:push scope"}`));
279931
- if (args.apiKey?.startsWith("rk_")) {
279950
+ if (args.apiKey != null && classifyCredentialKind(args.apiKey) === "restricted") {
279932
279951
  console.error(
279933
279952
  import_picocolors3.default.dim(
279934
279953
  ` Schema pushes need a SECRET key: ${import_picocolors3.default.bold("sk_test_")} (sandbox dev loop) or a dashboard ${import_picocolors3.default.bold("sk_live_")} (production deploy: ${import_picocolors3.default.bold("ABLO_API_KEY=sk_live_\u2026 npx ablo push")}).`
@@ -280223,7 +280242,7 @@ function classifyKey(apiKey, activeMode) {
280223
280242
  reason: `Production schema deploys run one-shot: ${import_picocolors6.default.bold("ABLO_API_KEY=sk_live_\u2026 npx ablo push")} (or ${import_picocolors6.default.bold("ablo mode production")}). ${import_picocolors6.default.bold("--watch")} is sandbox-only.`
280224
280243
  };
280225
280244
  }
280226
- if (apiKey.startsWith("rk_")) {
280245
+ if (classifyCredentialKind(apiKey) === "restricted") {
280227
280246
  return {
280228
280247
  ok: false,
280229
280248
  reason: `Restricted (${import_picocolors6.default.bold("rk_")}) keys can't push schema. Use a secret key: ${import_picocolors6.default.bold("sk_test_")} for the sandbox dev loop, or ${import_picocolors6.default.bold("sk_live_")} with ${import_picocolors6.default.bold("npx ablo push")} for a production deploy.`
@@ -281052,7 +281071,7 @@ function requireKey2(mode2) {
281052
281071
  );
281053
281072
  process.exit(1);
281054
281073
  }
281055
- if (!apiKey.startsWith("sk_")) {
281074
+ if (classifyCredentialKind(apiKey) !== "secret") {
281056
281075
  console.error(import_picocolors12.default.red(" Managing webhooks requires a secret key ") + import_picocolors12.default.dim("(sk_test_ / sk_live_)."));
281057
281076
  process.exit(1);
281058
281077
  }
@@ -282922,9 +282941,23 @@ import Ablo from '@abloatai/ablo';
282922
282941
  import { AbloProvider } from '@abloatai/ablo/react';
282923
282942
  import { schema } from '@/ablo/schema';
282924
282943
 
282925
- // The browser client holds NO secret. \`authEndpoint\` points at the route below,
282926
- // which mints a short-lived session token (already scoped to the org + user).
282927
- const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
282944
+ // The browser client holds NO secret. The \`apiKey\` resolver fetches the route
282945
+ // below, which mints a short-lived session token (already scoped to the org +
282946
+ // user); the client keeps it fresh (refresh timer + wake/online/focus re-mint).
282947
+ // Contract: return the token, return \`null\` when the user is signed out
282948
+ // (\u2192 the client signs out), or throw on a transient failure (\u2192 it retries).
282949
+ const ablo = Ablo({
282950
+ schema,
282951
+ apiKey: async () => {
282952
+ const res = await fetch('/api/ablo-session', {
282953
+ method: 'POST',
282954
+ credentials: 'include',
282955
+ });
282956
+ if (!res.ok) return null;
282957
+ const { token } = (await res.json()) as { token: string | null };
282958
+ return token;
282959
+ },
282960
+ });
282928
282961
 
282929
282962
  export function Providers({ children }: { children: React.ReactNode }) {
282930
282963
  return <AbloProvider client={ablo}>{children}</AbloProvider>;