@abloatai/ablo 0.7.0 → 0.8.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 (83) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +54 -45
  3. package/dist/BaseSyncedStore.js +7 -3
  4. package/dist/SyncEngineContext.d.ts +2 -1
  5. package/dist/SyncEngineContext.js +5 -3
  6. package/dist/agent/session.js +3 -2
  7. package/dist/auth/index.js +39 -11
  8. package/dist/client/Ablo.d.ts +111 -3
  9. package/dist/client/Ablo.js +143 -10
  10. package/dist/client/ApiClient.d.ts +32 -0
  11. package/dist/client/ApiClient.js +76 -44
  12. package/dist/client/auth.d.ts +11 -1
  13. package/dist/client/auth.js +21 -2
  14. package/dist/client/createModelProxy.d.ts +107 -63
  15. package/dist/client/createModelProxy.js +65 -33
  16. package/dist/client/identity.js +14 -0
  17. package/dist/client/registerDataSource.d.ts +19 -0
  18. package/dist/client/registerDataSource.js +57 -0
  19. package/dist/client/validateAbloOptions.d.ts +2 -1
  20. package/dist/client/validateAbloOptions.js +8 -7
  21. package/dist/errorCodes.d.ts +23 -1
  22. package/dist/errorCodes.js +34 -1
  23. package/dist/errors.d.ts +52 -1
  24. package/dist/errors.js +140 -42
  25. package/dist/index.d.ts +9 -5
  26. package/dist/index.js +9 -5
  27. package/dist/keys/index.d.ts +61 -0
  28. package/dist/keys/index.js +151 -0
  29. package/dist/query/client.js +19 -8
  30. package/dist/react/AbloProvider.d.ts +25 -0
  31. package/dist/react/AbloProvider.js +97 -2
  32. package/dist/react/ClientSideSuspense.d.ts +1 -1
  33. package/dist/react/DefaultFallback.d.ts +1 -1
  34. package/dist/react/SyncGroupProvider.d.ts +1 -1
  35. package/dist/react/index.d.ts +3 -2
  36. package/dist/react/index.js +3 -2
  37. package/dist/react/useAblo.d.ts +4 -4
  38. package/dist/react/useAblo.js +10 -5
  39. package/dist/react/useReactive.js +16 -3
  40. package/dist/schema/serialize.d.ts +3 -3
  41. package/dist/schema/serialize.js +2 -2
  42. package/dist/sync/BootstrapHelper.js +46 -27
  43. package/dist/sync/ConnectionManager.d.ts +3 -1
  44. package/dist/sync/ConnectionManager.js +37 -1
  45. package/dist/sync/HydrationCoordinator.js +3 -2
  46. package/dist/sync/NetworkProbe.d.ts +8 -0
  47. package/dist/sync/NetworkProbe.js +24 -2
  48. package/dist/sync/SyncWebSocket.d.ts +1 -1
  49. package/dist/sync/SyncWebSocket.js +43 -53
  50. package/dist/sync/participants.js +5 -2
  51. package/dist/transactions/TransactionQueue.js +13 -1
  52. package/docs/api-keys.md +5 -5
  53. package/docs/api.md +101 -44
  54. package/docs/audit.md +16 -9
  55. package/docs/cli.md +27 -17
  56. package/docs/client-behavior.md +34 -20
  57. package/docs/coordination.md +40 -51
  58. package/docs/data-sources.md +21 -19
  59. package/docs/examples/agent-human.md +72 -28
  60. package/docs/examples/ai-sdk-tool.md +14 -11
  61. package/docs/examples/existing-python-backend.md +27 -16
  62. package/docs/examples/nextjs.md +21 -8
  63. package/docs/examples/scoped-agent.md +42 -27
  64. package/docs/examples/server-agent.md +27 -5
  65. package/docs/guarantees.md +26 -17
  66. package/docs/identity.md +65 -59
  67. package/docs/index.md +30 -19
  68. package/docs/integration-guide.md +52 -52
  69. package/docs/interaction-model.md +38 -26
  70. package/docs/mcp/claude-code.md +9 -17
  71. package/docs/mcp/cursor.md +6 -24
  72. package/docs/mcp/windsurf.md +6 -19
  73. package/docs/mcp.md +103 -26
  74. package/docs/quickstart.md +31 -39
  75. package/docs/react.md +15 -11
  76. package/docs/roadmap.md +13 -13
  77. package/docs/schema-contract.md +109 -0
  78. package/examples/README.md +8 -4
  79. package/examples/data-source/README.md +6 -2
  80. package/examples/data-source/run.ts +4 -3
  81. package/examples/quickstart.ts +1 -1
  82. package/llms.txt +27 -16
  83. package/package.json +6 -1
@@ -36,7 +36,7 @@
36
36
  * code, a changed HTTP status, an envelope field. Emitted in `errors.json`
37
37
  * and on the `Ablo-Version` response header so a consumer can detect drift.
38
38
  */
39
- export const ERROR_CONTRACT_VERSION = '2026-05-28';
39
+ export const ERROR_CONTRACT_VERSION = '2026-05-29';
40
40
  const wire = (category, httpStatus, retryable, message) => ({ category, surface: 'wire', httpStatus, retryable, message });
41
41
  const client = (category, message) => ({ category, surface: 'client', retryable: false, message });
42
42
  /**
@@ -53,11 +53,30 @@ export const ERROR_CODES = {
53
53
  capability_id_missing: wire('auth', 401, false, 'A capability id was expected but not provided.'),
54
54
  exchange_failed: wire('auth', 401, false, 'The API-key credential exchange was rejected.'),
55
55
  identity_resolve_failed: wire('auth', 401, false, 'Identity resolution was rejected.'),
56
+ auth_no_credentials: wire('auth', 401, false, 'No recognized authentication credential was presented — no API key and no bearer JWT. Send `Authorization: Bearer <token>`.'),
57
+ identity_missing_organization: wire('auth', 401, false, 'Authentication succeeded but resolved to no organization context.'),
56
58
  session_expired: wire('auth', 401, false, 'The session is invalid or expired; re-authenticate.'),
59
+ // `jwt_invalid` is the residual fallback; the codes below split out the
60
+ // specific failure modes so an integrating customer can tell "I registered
61
+ // the wrong JWKS" from "my token has no org claim" from "wrong audience"
62
+ // rather than getting one opaque code for all of them.
63
+ jwt_invalid: wire('auth', 401, false, 'The bearer JWT could not be validated (unclassified).'),
64
+ jwt_malformed: wire('auth', 401, false, 'The bearer JWT is not a well-formed JWT and could not be decoded.'),
65
+ jwt_missing_issuer: wire('auth', 401, false, 'The bearer JWT has no `iss` (issuer) claim, so it cannot be routed to a trusted issuer.'),
66
+ jwt_issuer_untrusted: wire('auth', 401, false, "The bearer JWT's `iss` is not a registered trusted issuer. Register it via POST /v1/trusted-issuers, or check the token's issuer claim."),
67
+ jwt_signature_invalid: wire('auth', 401, false, "The bearer JWT's signature could not be verified against the issuer's JWKS (wrong key, rotated key, or forged token)."),
68
+ jwt_audience_mismatch: wire('auth', 401, false, "The bearer JWT's `aud` (audience) claim does not match the audience this issuer is registered with."),
69
+ jwt_missing_subject: wire('auth', 401, false, 'The bearer JWT has no `sub` (subject) claim to identify the user.'),
70
+ jwt_missing_organization: wire('auth', 401, false, 'The bearer JWT carries no organization context — neither a fixed org for the issuer nor the configured organization claim.'),
71
+ jwt_expired: wire('auth', 401, false, 'The bearer JWT has expired; obtain a fresh token.'),
72
+ jwt_org_membership_denied: wire('auth', 403, false, "The bearer JWT's subject is not an active member of the organization in its `org_id` claim (removed, suspended, or the claim does not match a membership)."),
57
73
  file_upload_auth_required: wire('auth', 401, false, 'File upload requires an authenticated session.'),
58
74
  browser_apikey_blocked: client('auth', 'Raw API keys must not be used from a browser context.'),
75
+ browser_database_url_blocked: client('auth', 'A database connection string must not be used from a browser context — it carries DB credentials.'),
76
+ datasource_registration_failed: client('auth', 'Failed to register the provided databaseUrl as this project data source.'),
59
77
  // ── permission / capability (403) ──────────────────────────────────
60
78
  capability_scope_denied: wire('capability', 403, false, "The connection's resolved scope does not cover the attempted action."),
79
+ issuer_register_forbidden: wire('permission', 403, false, 'Registering a trusted issuer requires a secret (sk_) API key.'),
61
80
  capability_invalid: wire('capability', 403, false, 'The capability is unknown, revoked, or expired.'),
62
81
  byo_role_cannot_enforce_rls: wire('permission', 403, false, 'The bring-your-own DB role cannot enforce row-level security.'),
63
82
  byo_role_unreadable: wire('permission', 403, false, 'The bring-your-own DB role could not be introspected.'),
@@ -80,6 +99,7 @@ export const ERROR_CODES = {
80
99
  commit_operation_required: wire('validation', 400, false, 'A commit must carry `operation` or `operations`.'),
81
100
  commit_operation_model_required: wire('validation', 400, false, 'A commit operation is missing its `model`.'),
82
101
  commit_operations_ambiguous: wire('validation', 400, false, 'A commit supplied both `operation` and `operations`.'),
102
+ commit_too_many_operations: wire('validation', 400, false, 'A commit exceeded the per-commit operation limit; split it into smaller batches.'),
83
103
  model_required_field_missing: wire('validation', 400, false, 'A required field was absent from the model payload.'),
84
104
  model_identifier_missing: wire('validation', 400, false, 'The model payload is missing its identifier.'),
85
105
  snapshot_reserved_key: wire('validation', 400, false, 'A snapshot used a reserved key name.'),
@@ -92,6 +112,18 @@ export const ERROR_CODES = {
92
112
  model_not_found: wire('not_found', 404, false, 'The referenced model row does not exist.'),
93
113
  mutate_update_entity_not_found: wire('not_found', 404, false, 'The entity targeted by an update does not exist.'),
94
114
  task_id_missing: wire('server', 502, true, 'The task-create response did not include an id.'),
115
+ // ── data integrity / DB constraints ────────────────────────────────
116
+ // Emitted when a write is rejected by a database integrity constraint
117
+ // (Postgres class-23). All NON-retryable: the same payload re-sent
118
+ // unchanged will fail identically, so the client must roll back, not
119
+ // retry. The server normalizer maps SQLSTATE → these codes and tucks the
120
+ // raw constraint/column/table detail into `details` rather than leaking
121
+ // the driver's message text onto the wire.
122
+ not_null_violation: wire('validation', 400, false, 'A required field was missing (database not-null constraint).'),
123
+ foreign_key_violation: wire('conflict', 409, false, 'A referenced entity does not exist, or is still referenced (database foreign-key constraint).'),
124
+ unique_violation: wire('conflict', 409, false, 'A value violates a uniqueness constraint.'),
125
+ check_violation: wire('validation', 400, false, 'A value violates a database check constraint.'),
126
+ constraint_violation: wire('validation', 400, false, 'A database integrity constraint was violated.'),
95
127
  // ── tenant / unknown model (400) ───────────────────────────────────
96
128
  server_execute_unknown_model: wire('tenant', 400, false, 'The server-execute request named a model not in the tenant schema.'),
97
129
  mutate_create_unknown_model: wire('tenant', 400, false, 'A create targeted a model not in the tenant schema.'),
@@ -152,6 +184,7 @@ export const ERROR_CODES = {
152
184
  turn_open_failed: wire('server', 500, true, 'The agent turn failed to open.'),
153
185
  turn_close_failed: wire('server', 500, true, 'The agent turn failed to close cleanly.'),
154
186
  // ── client-only invariants (never serialized) ──────────────────────
187
+ invalid_options: client('client', 'The Ablo client was constructed with invalid or incomplete options.'),
155
188
  no_ablo_provider: client('client', 'An Ablo hook was used outside of an Ablo provider.'),
156
189
  no_sync_group_provider: client('client', 'A sync-group hook was used outside of its provider.'),
157
190
  sync_context_missing_provider: client('client', 'Sync context was read outside of its provider.'),
package/dist/errors.d.ts CHANGED
@@ -272,11 +272,62 @@ export declare class SyncSessionError extends AbloAuthenticationError {
272
272
  */
273
273
  static isSessionErrorResponse(status: number, body?: string): boolean;
274
274
  }
275
+ /**
276
+ * Coerce ANY thrown value into an {@link AbloError} — the last-line guarantee
277
+ * that an SDK consumer never catches an untagged error. An already-typed
278
+ * AbloError passes through untouched (so `code`/`httpStatus`/subclass survive);
279
+ * a bare `Error` keeps its message and is preserved as `cause` (carrying any
280
+ * `.code` someone attached); a non-Error is stringified.
281
+ *
282
+ * This is the client mirror of the server's `normalizeError` — applied at the
283
+ * SDK's public async boundaries so `instanceof AbloError` / `e.type` always
284
+ * hold for whatever a consumer catches, regardless of which internal layer
285
+ * (transport, IndexedDB, bootstrap, a third-party throw) produced it.
286
+ */
287
+ export declare function toAbloError(err: unknown): AbloError;
288
+ /**
289
+ * Build the appropriate typed {@link AbloError} from a wire error — the
290
+ * single code→class mapping shared by every transport that can reject a
291
+ * request (HTTP responses via {@link translateHttpError}, WebSocket
292
+ * `mutation_result`/`claim_ack` frames, agent-job receipts).
293
+ *
294
+ * Code-first, then status-driven. A known {@link ErrorCode} carries its own
295
+ * canonical `httpStatus` in the registry, so frame transports that don't have
296
+ * an HTTP status (the WebSocket commit path) still produce the right subclass
297
+ * — instead of a hand-rolled `new Error(message)` that drops out of the typed
298
+ * hierarchy and loses `code`/`httpStatus`/retryability.
299
+ */
300
+ export declare function errorFromWire(message: string, opts?: {
301
+ code?: string;
302
+ /** Explicit transport status (HTTP). When omitted, derived from the
303
+ * registry spec for `code` so frame transports map correctly too. */
304
+ httpStatus?: number;
305
+ requestId?: string;
306
+ requiredCapability?: RequiredCapability;
307
+ }): AbloError;
275
308
  /**
276
309
  * Translate an HTTP response into the appropriate typed error.
277
310
  *
278
311
  * Single source of truth for status-code → class mapping — every SDK
279
312
  * fetch path that sees a non-2xx response should route through here
280
- * so the customer-visible error is always the right subclass.
313
+ * so the customer-visible error is always the right subclass. Delegates
314
+ * the actual class selection to {@link errorFromWire} (shared with the
315
+ * frame transports) after extracting code/message from the HTTP body.
281
316
  */
282
317
  export declare function translateHttpError(status: number, body: unknown, requestId?: string): AbloError;
318
+ /**
319
+ * Whether an HTTP error body carries a code {@link translateHttpError} can read
320
+ * — a top-level `code`, a nested `error.code`, or a string `error`. Callers that
321
+ * own a meaningful fallback code (e.g. `turn_open_failed`) use this to decide
322
+ * between routing through `translateHttpError` (structured envelope present) and
323
+ * throwing their own typed error with the fallback (bare/non-Ablo body), instead
324
+ * of emitting a code-less error.
325
+ */
326
+ export declare function hasWireCode(body: unknown): boolean;
327
+ /**
328
+ * Extract the canonical error `code` from a raw HTTP error body STRING — the
329
+ * top-level `code` or a nested `error.code`. Returns undefined for non-JSON or
330
+ * code-less bodies. Used by session-error detection to tell a genuine expiry
331
+ * (`session_expired`/`jwt_expired`) apart from other auth failures.
332
+ */
333
+ export declare function extractWireCode(body?: string): string | undefined;
package/dist/errors.js CHANGED
@@ -18,6 +18,7 @@
18
18
  *
19
19
  * Both work on every subclass.
20
20
  */
21
+ import { errorCodeSpec } from './errorCodes.js';
21
22
  export { ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode } from './errorCodes.js';
22
23
  // ── AbloError hierarchy — the typed error surface ────────────────────
23
24
  /** Common shape for all errors thrown by this SDK. */
@@ -237,29 +238,106 @@ export class SyncSessionError extends AbloAuthenticationError {
237
238
  * Check if an HTTP response status indicates a session error
238
239
  */
239
240
  static isSessionErrorResponse(status, body) {
240
- if (status === 401)
241
- return true;
242
- if (status === 403) {
243
- if (body) {
244
- const lowerBody = body.toLowerCase();
245
- if (lowerBody.includes('session') ||
246
- lowerBody.includes('unauthorized') ||
247
- lowerBody.includes('not authenticated') ||
248
- lowerBody.includes('token')) {
249
- return true;
250
- }
251
- }
252
- return true;
241
+ // Prefer the structured error code: ONLY a genuine session / JWT EXPIRY
242
+ // should drive sign-out + re-auth. A non-expiry auth failure
243
+ // (api_key_required, jwt_issuer_untrusted, a 403 permission denial, …) must
244
+ // NOT — re-authenticating re-mints the same credential and loops, which is
245
+ // exactly the "flash then bounce to /signin" symptom. This mirrors the
246
+ // canonical wire mapping (401 → Authentication, 403 → Permission).
247
+ const code = extractWireCode(body);
248
+ if (code) {
249
+ return code === 'session_expired' || code === 'jwt_expired';
253
250
  }
254
- return false;
251
+ // No structured code (bare body, non-Ablo proxy response): a 401 is taken as
252
+ // expiry — the historical default that drives re-auth — while a 403 is a
253
+ // permission failure, not a session error.
254
+ return status === 401;
255
255
  }
256
256
  }
257
+ /**
258
+ * Coerce ANY thrown value into an {@link AbloError} — the last-line guarantee
259
+ * that an SDK consumer never catches an untagged error. An already-typed
260
+ * AbloError passes through untouched (so `code`/`httpStatus`/subclass survive);
261
+ * a bare `Error` keeps its message and is preserved as `cause` (carrying any
262
+ * `.code` someone attached); a non-Error is stringified.
263
+ *
264
+ * This is the client mirror of the server's `normalizeError` — applied at the
265
+ * SDK's public async boundaries so `instanceof AbloError` / `e.type` always
266
+ * hold for whatever a consumer catches, regardless of which internal layer
267
+ * (transport, IndexedDB, bootstrap, a third-party throw) produced it.
268
+ */
269
+ export function toAbloError(err) {
270
+ if (err instanceof AbloError)
271
+ return err;
272
+ if (err instanceof Error) {
273
+ const rawCode = err.code;
274
+ const code = typeof rawCode === 'string' ? rawCode : undefined;
275
+ return new AbloError(err.message, { code, cause: err });
276
+ }
277
+ return new AbloError(String(err), { cause: err });
278
+ }
279
+ /**
280
+ * Build the appropriate typed {@link AbloError} from a wire error — the
281
+ * single code→class mapping shared by every transport that can reject a
282
+ * request (HTTP responses via {@link translateHttpError}, WebSocket
283
+ * `mutation_result`/`claim_ack` frames, agent-job receipts).
284
+ *
285
+ * Code-first, then status-driven. A known {@link ErrorCode} carries its own
286
+ * canonical `httpStatus` in the registry, so frame transports that don't have
287
+ * an HTTP status (the WebSocket commit path) still produce the right subclass
288
+ * — instead of a hand-rolled `new Error(message)` that drops out of the typed
289
+ * hierarchy and loses `code`/`httpStatus`/retryability.
290
+ */
291
+ export function errorFromWire(message, opts = {}) {
292
+ const { code, requestId, requiredCapability } = opts;
293
+ // Effective status: an explicit HTTP status wins; otherwise fall back to
294
+ // the code's canonical status from the registry (undefined for unknown /
295
+ // forward-compat codes, which then map to the base AbloError).
296
+ const httpStatus = opts.httpStatus ?? (code ? errorCodeSpec(code)?.httpStatus : undefined);
297
+ // Wire boundary: an incoming code is an arbitrary string (a newer server
298
+ // may send a code this SDK predates). Cast to ErrorCode here — the one
299
+ // sanctioned crossing — so internal producers stay statically checked.
300
+ const publicCode = (code === 'intent_conflict' ? 'claim_conflict' : code);
301
+ const baseOpts = { code: publicCode, httpStatus, requestId };
302
+ // ── Code-first specials (transport-independent) ──────────────────────
303
+ // A scoped credential was denied — route through CapabilityError so callers
304
+ // can read `.requiredCapability` to attenuate-and-retry.
305
+ if (code === 'capability_scope_denied' || code === 'capability_invalid') {
306
+ return new CapabilityError(code, message, requiredCapability);
307
+ }
308
+ // Claim enforcement (rides 409): the target entity is held by another
309
+ // participant. Discriminate on code BEFORE the generic 409→idempotency
310
+ // mapping so a claim rejection surfaces as AbloClaimedError.
311
+ if (code === 'intent_conflict' || code === 'claim_conflict' || code === 'entity_claimed') {
312
+ return new AbloClaimedError(message, baseOpts);
313
+ }
314
+ // A write whose `readAt` watermark went stale — callers re-read and retry.
315
+ if (code === 'stale_context') {
316
+ return new AbloStaleContextError(message, baseOpts);
317
+ }
318
+ // ── Status-driven dispatch (HTTP parity) ─────────────────────────────
319
+ if (httpStatus === 401)
320
+ return new AbloAuthenticationError(message, baseOpts);
321
+ if (httpStatus === 403)
322
+ return new AbloPermissionError(message, baseOpts);
323
+ if (httpStatus === 409)
324
+ return new AbloIdempotencyError(message, baseOpts);
325
+ if (httpStatus === 422 || httpStatus === 400)
326
+ return new AbloValidationError(message, baseOpts);
327
+ if (httpStatus === 429)
328
+ return new AbloRateLimitError(message, baseOpts);
329
+ if (httpStatus !== undefined && httpStatus >= 500)
330
+ return new AbloServerError(message, baseOpts);
331
+ return new AbloError(message, baseOpts);
332
+ }
257
333
  /**
258
334
  * Translate an HTTP response into the appropriate typed error.
259
335
  *
260
336
  * Single source of truth for status-code → class mapping — every SDK
261
337
  * fetch path that sees a non-2xx response should route through here
262
- * so the customer-visible error is always the right subclass.
338
+ * so the customer-visible error is always the right subclass. Delegates
339
+ * the actual class selection to {@link errorFromWire} (shared with the
340
+ * frame transports) after extracting code/message from the HTTP body.
263
341
  */
264
342
  export function translateHttpError(status, body, requestId) {
265
343
  const parsed = typeof body === 'object' && body !== null ? body : {};
@@ -274,33 +352,53 @@ export function translateHttpError(status, body, requestId) {
274
352
  flatError ??
275
353
  (typeof body === 'string' ? body : `HTTP ${status}`);
276
354
  const requiredCapability = nested?.requiredCapability ?? parsed.requiredCapability;
277
- // Wire boundary: an incoming code is an arbitrary string (a newer server
278
- // may send a code this SDK predates). Cast to ErrorCode here — the one
279
- // sanctioned crossing — so internal producers stay statically checked.
280
- const publicCode = (code === 'intent_conflict' ? 'claim_conflict' : code);
281
- const baseOpts = { code: publicCode, httpStatus: status, requestId };
282
- if (status === 401)
283
- return new AbloAuthenticationError(message, baseOpts);
284
- if (status === 403 || code === 'capability_scope_denied' || code === 'capability_invalid') {
285
- if (code === 'capability_scope_denied' || code === 'capability_invalid') {
286
- return new CapabilityError(code, message, requiredCapability);
287
- }
288
- return new AbloPermissionError(message, baseOpts);
355
+ return errorFromWire(message, { code, httpStatus: status, requestId, requiredCapability });
356
+ }
357
+ /**
358
+ * Whether an HTTP error body carries a code {@link translateHttpError} can read
359
+ * a top-level `code`, a nested `error.code`, or a string `error`. Callers that
360
+ * own a meaningful fallback code (e.g. `turn_open_failed`) use this to decide
361
+ * between routing through `translateHttpError` (structured envelope present) and
362
+ * throwing their own typed error with the fallback (bare/non-Ablo body), instead
363
+ * of emitting a code-less error.
364
+ */
365
+ export function hasWireCode(body) {
366
+ if (typeof body !== 'object' || body === null)
367
+ return false;
368
+ const b = body;
369
+ if (typeof b.code === 'string')
370
+ return true;
371
+ if (typeof b.error === 'string')
372
+ return true;
373
+ return (typeof b.error === 'object' &&
374
+ b.error !== null &&
375
+ typeof b.error.code === 'string');
376
+ }
377
+ /**
378
+ * Extract the canonical error `code` from a raw HTTP error body STRING — the
379
+ * top-level `code` or a nested `error.code`. Returns undefined for non-JSON or
380
+ * code-less bodies. Used by session-error detection to tell a genuine expiry
381
+ * (`session_expired`/`jwt_expired`) apart from other auth failures.
382
+ */
383
+ export function extractWireCode(body) {
384
+ if (!body)
385
+ return undefined;
386
+ let parsed;
387
+ try {
388
+ parsed = JSON.parse(body);
289
389
  }
290
- // Claim enforcement also rides 409 (a commit blocked by a foreign claim).
291
- // Discriminate on the code BEFORE the generic idempotency mapping so a
292
- // claim rejection surfaces as AbloClaimedError, not AbloIdempotencyError —
293
- // same typed error the WebSocket commit path yields for these codes.
294
- if (code === 'intent_conflict' || code === 'claim_conflict' || code === 'entity_claimed') {
295
- return new AbloClaimedError(message, baseOpts);
390
+ catch {
391
+ return undefined;
296
392
  }
297
- if (status === 409)
298
- return new AbloIdempotencyError(message, baseOpts);
299
- if (status === 422 || status === 400)
300
- return new AbloValidationError(message, baseOpts);
301
- if (status === 429)
302
- return new AbloRateLimitError(message, baseOpts);
303
- if (status >= 500)
304
- return new AbloServerError(message, baseOpts);
305
- return new AbloError(message, baseOpts);
393
+ if (typeof parsed !== 'object' || parsed === null)
394
+ return undefined;
395
+ const b = parsed;
396
+ if (typeof b.code === 'string')
397
+ return b.code;
398
+ if (typeof b.error === 'object' &&
399
+ b.error !== null &&
400
+ typeof b.error.code === 'string') {
401
+ return b.error.code;
402
+ }
403
+ return undefined;
306
404
  }
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * import Ablo from '@abloatai/ablo';
6
6
  *
7
7
  * const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
8
- * await ablo.weatherReports.load({ where: { id: 'report_stockholm' } });
8
+ * const report = await ablo.weatherReports.retrieve('report_stockholm');
9
9
  * await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
10
10
  *
11
11
  * type Entry = Ablo.Peer;
@@ -23,9 +23,13 @@
23
23
  * @abloatai/ablo/react — <AbloProvider>, useQuery, useMutate
24
24
  * @abloatai/ablo/testing — test harnesses + mocks
25
25
  *
26
- * Consumer code should converge on `ablo.<model>.load(...)`, which routes
27
- * through the engine's `HydrationCoordinator` and dedupes single-flight
28
- * hydrations.
26
+ * Reads split by where the data comes from. `ablo.<model>.retrieve(id)` and
27
+ * `.list({ where })` are the async **server** reads (pool → IDB → network via
28
+ * the `HydrationCoordinator`, single-flight deduped); they're the default and
29
+ * what hosted/stateless callers want, since their local graph starts empty.
30
+ * `ablo.<model>.get(id)` / `.getAll(...)` / `.getCount(...)` are synchronous
31
+ * **local-graph** snapshots with no network round-trip — for reactive React
32
+ * selectors (`useAblo((ablo) => ablo.<model>.get(id))`) once the graph is warm.
29
33
  *
30
34
  * ── What to import (read this first) ────────────────────────────────
31
35
  * Default path — this is all most apps and agents ever need:
@@ -49,7 +53,7 @@ import { Ablo } from './client/Ablo.js';
49
53
  export default Ablo;
50
54
  export { dataSource, abloSource, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
51
55
  export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
52
- export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, } from './errors.js';
56
+ export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, } from './errors.js';
53
57
  export type { CommitReceipt, RequiredCapability } from './errors.js';
54
58
  export type { ErrorCode, WireErrorCode, ErrorCategory, ErrorCodeSpec } from './errors.js';
55
59
  export type { Register, DefaultSyncShape } from './types/global.js';
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * import Ablo from '@abloatai/ablo';
6
6
  *
7
7
  * const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
8
- * await ablo.weatherReports.load({ where: { id: 'report_stockholm' } });
8
+ * const report = await ablo.weatherReports.retrieve('report_stockholm');
9
9
  * await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
10
10
  *
11
11
  * type Entry = Ablo.Peer;
@@ -23,9 +23,13 @@
23
23
  * @abloatai/ablo/react — <AbloProvider>, useQuery, useMutate
24
24
  * @abloatai/ablo/testing — test harnesses + mocks
25
25
  *
26
- * Consumer code should converge on `ablo.<model>.load(...)`, which routes
27
- * through the engine's `HydrationCoordinator` and dedupes single-flight
28
- * hydrations.
26
+ * Reads split by where the data comes from. `ablo.<model>.retrieve(id)` and
27
+ * `.list({ where })` are the async **server** reads (pool → IDB → network via
28
+ * the `HydrationCoordinator`, single-flight deduped); they're the default and
29
+ * what hosted/stateless callers want, since their local graph starts empty.
30
+ * `ablo.<model>.get(id)` / `.getAll(...)` / `.getCount(...)` are synchronous
31
+ * **local-graph** snapshots with no network round-trip — for reactive React
32
+ * selectors (`useAblo((ablo) => ablo.<model>.get(id))`) once the graph is warm.
29
33
  *
30
34
  * ── What to import (read this first) ────────────────────────────────
31
35
  * Default path — this is all most apps and agents ever need:
@@ -78,7 +82,7 @@ export { defaultPolicy, capabilityPreemptPolicy } from './policy/index.js';
78
82
  // Typed error hierarchy — Stripe-style. One import gets every class
79
83
  // consumers need to discriminate failures (`e instanceof AbloX` or
80
84
  // `e.type === 'AbloX'`) plus the HTTP-response translator.
81
- export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, } from './errors.js';
85
+ export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, } from './errors.js';
82
86
  // Advanced — most apps never import this. Custom (Zero-style) mutators:
83
87
  // `ablo.<model>.create/update/delete` already covers normal writes. Reach
84
88
  // for `defineMutators` only when you need a named, multi-step mutation with
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Canonical Ablo API-key format — the single source of truth for how keys
3
+ * are minted, hashed, and validated. Both the sync-server (`apiKeyStore`)
4
+ * and the web control-plane (`generate-key.ts`) consume THIS module, so the
5
+ * format can no longer drift between the two mint sites (it used to live as
6
+ * a hand-copied twin kept in sync by a comment).
7
+ *
8
+ * Node-only — uses `node:crypto`. Exposed via the `@abloatai/ablo/keys`
9
+ * subpath and NEVER re-exported from the browser-facing `.` entry, so the
10
+ * client bundle never pulls in `node:crypto`.
11
+ *
12
+ * Format (GitHub-style): `<sk|rk|ek>_<live|test>_<30 base62 body><6-char
13
+ * base62 CRC32 checksum>`. The identifiable prefix + CRC32 checksum let
14
+ * secret scanners detect leaks and let us reject typo'd/forged keys OFFLINE
15
+ * (no DB round-trip). Legacy keys (a ~43-char base64url body, no checksum)
16
+ * still validate by hash — they parse here as `checksummed: false`.
17
+ */
18
+ import { z } from 'zod';
19
+ export declare const API_KEY_KINDS: readonly ["secret", "restricted", "ephemeral"];
20
+ export type ApiKeyKind = (typeof API_KEY_KINDS)[number];
21
+ export declare const API_KEY_ENVS: readonly ["live", "test"];
22
+ export type ApiKeyEnv = (typeof API_KEY_ENVS)[number];
23
+ /** A structurally-valid Ablo API key, parsed into its parts. */
24
+ export interface ParsedApiKey {
25
+ /** The original plaintext. */
26
+ raw: string;
27
+ kind: ApiKeyKind;
28
+ env: ApiKeyEnv;
29
+ /** The chars after `<prefix>_<env>_` (body + checksum for new keys). */
30
+ body: string;
31
+ /** True when this is the new checksummed format (36-char base62 body). */
32
+ checksummed: boolean;
33
+ }
34
+ /**
35
+ * Canonical schema for an Ablo API key. `parse`/`safeParse` returns a typed
36
+ * {@link ParsedApiKey}; a new checksummed-format key with a BAD checksum is
37
+ * rejected (the offline-reject), while a legacy key parses as
38
+ * `checksummed: false` and passes (the server still hash-validates it).
39
+ */
40
+ export declare const apiKeySchema: z.ZodPipe<z.ZodString, z.ZodTransform<ParsedApiKey, string>>;
41
+ /** Parse + fully validate (incl. checksum). Returns null when invalid. */
42
+ export declare function parseApiKey(raw: string): ParsedApiKey | null;
43
+ /** True when the key uses the new checksummed format (regardless of validity). */
44
+ export declare function isChecksummedKey(raw: string): boolean;
45
+ /** Verify the embedded checksum. Meaningful only for checksummed-format keys. */
46
+ export declare function keyChecksumMatches(raw: string): boolean;
47
+ /**
48
+ * Mint a key: `<prefix>_<env>_<body><checksum>`. Returns the plaintext (shown
49
+ * once), its SHA-256 hash (persisted), and the 12-char display prefix.
50
+ */
51
+ export declare function generateApiKey(env?: ApiKeyEnv, kind?: ApiKeyKind): {
52
+ plaintext: string;
53
+ hash: string;
54
+ prefix: string;
55
+ };
56
+ /**
57
+ * Stable SHA-256 hex of a plaintext key. A fast hash is CORRECT here (not
58
+ * bcrypt) — API keys are high-entropy random, so there's no dictionary to
59
+ * defend against. Used at both write (mint) and lookup.
60
+ */
61
+ export declare function hashApiKey(plaintext: string): string;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Canonical Ablo API-key format — the single source of truth for how keys
3
+ * are minted, hashed, and validated. Both the sync-server (`apiKeyStore`)
4
+ * and the web control-plane (`generate-key.ts`) consume THIS module, so the
5
+ * format can no longer drift between the two mint sites (it used to live as
6
+ * a hand-copied twin kept in sync by a comment).
7
+ *
8
+ * Node-only — uses `node:crypto`. Exposed via the `@abloatai/ablo/keys`
9
+ * subpath and NEVER re-exported from the browser-facing `.` entry, so the
10
+ * client bundle never pulls in `node:crypto`.
11
+ *
12
+ * Format (GitHub-style): `<sk|rk|ek>_<live|test>_<30 base62 body><6-char
13
+ * base62 CRC32 checksum>`. The identifiable prefix + CRC32 checksum let
14
+ * secret scanners detect leaks and let us reject typo'd/forged keys OFFLINE
15
+ * (no DB round-trip). Legacy keys (a ~43-char base64url body, no checksum)
16
+ * still validate by hash — they parse here as `checksummed: false`.
17
+ */
18
+ import { createHash, randomBytes } from 'node:crypto';
19
+ import { z } from 'zod';
20
+ // ── Vocabulary ──────────────────────────────────────────────────────────
21
+ // The three-key Stripe model:
22
+ // secret (sk_) — backend / server-to-server / agents. Full authority. Never in a browser.
23
+ // restricted (rk_) — scoped SERVER key (agent session tokens / capabilities).
24
+ // ephemeral (ek_) — short-lived, backend-minted, USER-scoped BROWSER session credential
25
+ // (Stripe ephemeral keys). Carries participantKind:'user' + baked syncGroups.
26
+ // (There is no publishable `pk_` — the minted session token, not a project
27
+ // identifier, is what the browser holds; it already names the org.)
28
+ export const API_KEY_KINDS = ['secret', 'restricted', 'ephemeral'];
29
+ export const API_KEY_ENVS = ['live', 'test'];
30
+ const PREFIX_BY_KIND = {
31
+ secret: 'sk',
32
+ restricted: 'rk',
33
+ ephemeral: 'ek',
34
+ };
35
+ const KIND_BY_PREFIX = {
36
+ sk: 'secret',
37
+ rk: 'restricted',
38
+ ek: 'ephemeral',
39
+ };
40
+ const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
41
+ /** Random base62 chars before the checksum. */
42
+ const KEY_BODY_LEN = 30;
43
+ /** base62(CRC32): 62^6 (~5.7e10) > 2^32, so a CRC32 always fits in 6 chars. */
44
+ const CHECKSUM_LEN = 6;
45
+ /** A new checksummed body is exactly this long and pure base62. */
46
+ const CHECKSUMMED_BODY_LEN = KEY_BODY_LEN + CHECKSUM_LEN;
47
+ /** `<sk|rk|ek>_<live|test>_<body>`; body charset covers base62 AND legacy base64url. */
48
+ const KEY_RE = /^(sk|rk|ek)_(live|test)_([0-9A-Za-z\-_]+)$/;
49
+ const BASE62_RE = /^[0-9A-Za-z]+$/;
50
+ // ── Checksum (standard CRC-32, GitHub-compatible) ───────────────────────
51
+ const CRC32_TABLE = (() => {
52
+ const t = new Uint32Array(256);
53
+ for (let n = 0; n < 256; n++) {
54
+ let c = n;
55
+ for (let k = 0; k < 8; k++)
56
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
57
+ t[n] = c >>> 0;
58
+ }
59
+ return t;
60
+ })();
61
+ function crc32(s) {
62
+ let c = 0xffffffff;
63
+ for (let i = 0; i < s.length; i++) {
64
+ c = (CRC32_TABLE[(c ^ s.charCodeAt(i)) & 0xff] ^ (c >>> 8)) >>> 0;
65
+ }
66
+ return (c ^ 0xffffffff) >>> 0;
67
+ }
68
+ /** 6-char base62 encoding of the CRC32 of `payload`. */
69
+ function checksum6(payload) {
70
+ let n = crc32(payload);
71
+ let out = '';
72
+ for (let i = 0; i < CHECKSUM_LEN; i++) {
73
+ out = BASE62[n % 62] + out;
74
+ n = Math.floor(n / 62);
75
+ }
76
+ return out;
77
+ }
78
+ /** `len` cryptographically-random base62 chars (rejection-sampled, no bias). */
79
+ function randomBase62(len) {
80
+ let out = '';
81
+ while (out.length < len) {
82
+ for (const b of randomBytes(len * 2)) {
83
+ if (b < 248) {
84
+ out += BASE62[b % 62];
85
+ if (out.length === len)
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ function bodyIsChecksummed(body) {
93
+ return body.length === CHECKSUMMED_BODY_LEN && BASE62_RE.test(body);
94
+ }
95
+ /**
96
+ * Canonical schema for an Ablo API key. `parse`/`safeParse` returns a typed
97
+ * {@link ParsedApiKey}; a new checksummed-format key with a BAD checksum is
98
+ * rejected (the offline-reject), while a legacy key parses as
99
+ * `checksummed: false` and passes (the server still hash-validates it).
100
+ */
101
+ export const apiKeySchema = z.string().transform((raw, ctx) => {
102
+ const m = KEY_RE.exec(raw);
103
+ if (!m) {
104
+ ctx.addIssue({ code: 'custom', message: 'not a valid Ablo API key format' });
105
+ return z.NEVER;
106
+ }
107
+ const [, prefix, env, body] = m;
108
+ const checksummed = bodyIsChecksummed(body);
109
+ if (checksummed && checksum6(raw.slice(0, -CHECKSUM_LEN)) !== body.slice(KEY_BODY_LEN)) {
110
+ ctx.addIssue({ code: 'custom', message: 'API key checksum mismatch' });
111
+ return z.NEVER;
112
+ }
113
+ return { raw, kind: KIND_BY_PREFIX[prefix], env: env, body, checksummed };
114
+ });
115
+ // ── Derived validators (thin wrappers over the same spec) ───────────────
116
+ /** Parse + fully validate (incl. checksum). Returns null when invalid. */
117
+ export function parseApiKey(raw) {
118
+ const r = apiKeySchema.safeParse(raw);
119
+ return r.success ? r.data : null;
120
+ }
121
+ /** True when the key uses the new checksummed format (regardless of validity). */
122
+ export function isChecksummedKey(raw) {
123
+ const m = KEY_RE.exec(raw);
124
+ return m !== null && bodyIsChecksummed(m[3]);
125
+ }
126
+ /** Verify the embedded checksum. Meaningful only for checksummed-format keys. */
127
+ export function keyChecksumMatches(raw) {
128
+ const m = KEY_RE.exec(raw);
129
+ if (!m || !bodyIsChecksummed(m[3]))
130
+ return false;
131
+ return checksum6(raw.slice(0, -CHECKSUM_LEN)) === m[3].slice(KEY_BODY_LEN);
132
+ }
133
+ // ── Mint + hash (node:crypto) ───────────────────────────────────────────
134
+ /**
135
+ * Mint a key: `<prefix>_<env>_<body><checksum>`. Returns the plaintext (shown
136
+ * once), its SHA-256 hash (persisted), and the 12-char display prefix.
137
+ */
138
+ export function generateApiKey(env = 'live', kind = 'secret') {
139
+ const body = randomBase62(KEY_BODY_LEN);
140
+ const payload = `${PREFIX_BY_KIND[kind]}_${env}_${body}`;
141
+ const plaintext = `${payload}${checksum6(payload)}`;
142
+ return { plaintext, hash: hashApiKey(plaintext), prefix: plaintext.slice(0, 12) };
143
+ }
144
+ /**
145
+ * Stable SHA-256 hex of a plaintext key. A fast hash is CORRECT here (not
146
+ * bcrypt) — API keys are high-entropy random, so there's no dictionary to
147
+ * defend against. Used at both write (mint) and lookup.
148
+ */
149
+ export function hashApiKey(plaintext) {
150
+ return createHash('sha256').update(plaintext).digest('hex');
151
+ }