@abloatai/ablo 0.6.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 (121) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +95 -57
  3. package/dist/BaseSyncedStore.d.ts +1 -1
  4. package/dist/BaseSyncedStore.js +8 -4
  5. package/dist/SyncEngineContext.d.ts +2 -1
  6. package/dist/SyncEngineContext.js +5 -3
  7. package/dist/agent/session.js +3 -2
  8. package/dist/auth/index.js +39 -11
  9. package/dist/client/Ablo.d.ts +112 -3
  10. package/dist/client/Ablo.js +144 -10
  11. package/dist/client/ApiClient.d.ts +32 -0
  12. package/dist/client/ApiClient.js +76 -44
  13. package/dist/client/auth.d.ts +11 -1
  14. package/dist/client/auth.js +21 -2
  15. package/dist/client/createModelProxy.d.ts +120 -53
  16. package/dist/client/createModelProxy.js +66 -31
  17. package/dist/client/identity.js +14 -0
  18. package/dist/client/registerDataSource.d.ts +19 -0
  19. package/dist/client/registerDataSource.js +57 -0
  20. package/dist/client/validateAbloOptions.d.ts +2 -1
  21. package/dist/client/validateAbloOptions.js +8 -7
  22. package/dist/coordination/index.d.ts +6 -0
  23. package/dist/coordination/index.js +6 -0
  24. package/dist/coordination/schema.d.ts +329 -0
  25. package/dist/coordination/schema.js +209 -0
  26. package/dist/core/QueryView.d.ts +4 -1
  27. package/dist/core/QueryView.js +1 -1
  28. package/dist/core/query-utils.d.ts +7 -10
  29. package/dist/core/query-utils.js +2 -3
  30. package/dist/errorCodes.d.ts +286 -0
  31. package/dist/errorCodes.js +284 -0
  32. package/dist/errors.d.ts +103 -7
  33. package/dist/errors.js +192 -41
  34. package/dist/index.d.ts +11 -6
  35. package/dist/index.js +10 -6
  36. package/dist/keys/index.d.ts +61 -0
  37. package/dist/keys/index.js +151 -0
  38. package/dist/policy/index.d.ts +1 -1
  39. package/dist/policy/index.js +1 -1
  40. package/dist/policy/types.d.ts +31 -0
  41. package/dist/policy/types.js +15 -0
  42. package/dist/query/client.js +19 -8
  43. package/dist/react/AbloProvider.d.ts +37 -0
  44. package/dist/react/AbloProvider.js +107 -4
  45. package/dist/react/ClientSideSuspense.d.ts +1 -1
  46. package/dist/react/DefaultFallback.d.ts +1 -1
  47. package/dist/react/SyncGroupProvider.d.ts +1 -1
  48. package/dist/react/index.d.ts +3 -2
  49. package/dist/react/index.js +3 -2
  50. package/dist/react/useAblo.d.ts +4 -4
  51. package/dist/react/useAblo.js +10 -5
  52. package/dist/react/useReactive.js +16 -3
  53. package/dist/schema/ddl.d.ts +62 -0
  54. package/dist/schema/ddl.js +317 -0
  55. package/dist/schema/diff.d.ts +6 -0
  56. package/dist/schema/diff.js +21 -3
  57. package/dist/schema/field.d.ts +16 -19
  58. package/dist/schema/field.js +30 -17
  59. package/dist/schema/index.d.ts +7 -4
  60. package/dist/schema/index.js +9 -3
  61. package/dist/schema/model.d.ts +87 -25
  62. package/dist/schema/model.js +33 -3
  63. package/dist/schema/relation.d.ts +17 -0
  64. package/dist/schema/roles.d.ts +148 -0
  65. package/dist/schema/roles.js +149 -0
  66. package/dist/schema/schema.d.ts +2 -112
  67. package/dist/schema/schema.js +50 -62
  68. package/dist/schema/select.d.ts +25 -0
  69. package/dist/schema/select.js +55 -0
  70. package/dist/schema/serialize.d.ts +16 -12
  71. package/dist/schema/serialize.js +16 -12
  72. package/dist/schema/sugar.d.ts +20 -3
  73. package/dist/schema/sugar.js +5 -1
  74. package/dist/schema/tenancy.d.ts +66 -0
  75. package/dist/schema/tenancy.js +58 -0
  76. package/dist/sync/BootstrapHelper.js +46 -27
  77. package/dist/sync/ConnectionManager.d.ts +3 -1
  78. package/dist/sync/ConnectionManager.js +37 -1
  79. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  80. package/dist/sync/HydrationCoordinator.js +26 -19
  81. package/dist/sync/NetworkProbe.d.ts +8 -0
  82. package/dist/sync/NetworkProbe.js +24 -2
  83. package/dist/sync/SyncWebSocket.d.ts +1 -1
  84. package/dist/sync/SyncWebSocket.js +43 -53
  85. package/dist/sync/createIntentStream.d.ts +2 -1
  86. package/dist/sync/createIntentStream.js +46 -1
  87. package/dist/sync/participants.js +10 -16
  88. package/dist/transactions/TransactionQueue.js +13 -1
  89. package/dist/types/streams.d.ts +53 -33
  90. package/docs/api-keys.md +47 -3
  91. package/docs/api.md +103 -57
  92. package/docs/audit.md +16 -9
  93. package/docs/cli.md +222 -0
  94. package/docs/client-behavior.md +35 -21
  95. package/docs/coordination.md +74 -36
  96. package/docs/data-sources.md +23 -21
  97. package/docs/examples/agent-human.md +72 -28
  98. package/docs/examples/ai-sdk-tool.md +14 -11
  99. package/docs/examples/existing-python-backend.md +30 -19
  100. package/docs/examples/nextjs.md +21 -8
  101. package/docs/examples/scoped-agent.md +93 -0
  102. package/docs/examples/server-agent.md +27 -5
  103. package/docs/guarantees.md +29 -17
  104. package/docs/identity.md +198 -121
  105. package/docs/index.md +35 -18
  106. package/docs/integration-guide.md +79 -83
  107. package/docs/interaction-model.md +40 -25
  108. package/docs/mcp/claude-code.md +9 -17
  109. package/docs/mcp/cursor.md +6 -24
  110. package/docs/mcp/windsurf.md +6 -19
  111. package/docs/mcp.md +103 -26
  112. package/docs/quickstart.md +31 -39
  113. package/docs/react.md +18 -14
  114. package/docs/roadmap.md +15 -3
  115. package/docs/schema-contract.md +109 -0
  116. package/examples/README.md +8 -4
  117. package/examples/data-source/README.md +6 -2
  118. package/examples/data-source/run.ts +4 -3
  119. package/examples/quickstart.ts +1 -1
  120. package/llms.txt +27 -16
  121. package/package.json +13 -1
@@ -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
+ }
@@ -16,4 +16,4 @@
16
16
  * ```
17
17
  */
18
18
  export type { Conflict, ConflictDecision, ConflictKind, ConflictOperation, ConflictPolicy, StaleContextConflict, IntentHeldConflict, } from './types.js';
19
- export { defaultPolicy } from './types.js';
19
+ export { defaultPolicy, capabilityPreemptPolicy } from './types.js';
@@ -15,4 +15,4 @@
15
15
  * };
16
16
  * ```
17
17
  */
18
- export { defaultPolicy } from './types.js';
18
+ export { defaultPolicy, capabilityPreemptPolicy } from './types.js';
@@ -48,6 +48,14 @@ export interface IntentHeldConflict extends ConflictBase {
48
48
  readonly entityId: string;
49
49
  /** Holder's intent expiry (ms since epoch). */
50
50
  readonly expiresAt: number;
51
+ /**
52
+ * The committer's granted capability operations (the key's allowlist). A
53
+ * policy is a pure function of the conflict value, so it can only authorize
54
+ * on what's carried here — this is what lets a policy express "preempt iff
55
+ * the committer holds `intent.preempt`" (see `capabilityPreemptPolicy`).
56
+ * Empty for a human session with no allowlist.
57
+ */
58
+ readonly committerOperations: readonly string[];
51
59
  }
52
60
  /**
53
61
  * The discriminated union the policy receives. Switch on `.kind` to
@@ -61,6 +69,20 @@ export type ConflictDecision = {
61
69
  } | {
62
70
  readonly action: 'allow';
63
71
  readonly note?: string;
72
+ }
73
+ /**
74
+ * Evict the current holder and grant the target to the committer. Only
75
+ * meaningful for an `intent_held` conflict at claim time (`intent_begin`):
76
+ * the holder receives an `intent_lost` (reason `'preempted'`) and the
77
+ * preemptor takes the lease, jumping ahead of any FIFO waiters. This is the
78
+ * authorization seam for preemption — a policy returns `preempt` only for a
79
+ * committer it deems higher-priority (e.g. a supervisor over its sub-agents,
80
+ * or an identity holding a preempt capability). At commit time there is no
81
+ * holder to evict, so a `preempt` decision there is treated as `allow`.
82
+ */
83
+ | {
84
+ readonly action: 'preempt';
85
+ readonly reason?: string;
64
86
  };
65
87
  /**
66
88
  * Pluggable decision function. Sync or async.
@@ -81,4 +103,13 @@ export type ConflictPolicy = (conflict: Conflict) => ConflictDecision | Promise<
81
103
  * intent-conflicting write through.
82
104
  */
83
105
  export declare const defaultPolicy: ConflictPolicy;
106
+ /**
107
+ * Capability-gated preemption. An `intent_held` conflict is PREEMPTED when the
108
+ * committer holds the `intent.preempt` operation in its capability allowlist
109
+ * (the holder is evicted, the committer takes the lease); everything else falls
110
+ * back to `defaultPolicy` (reject). Opt-in — wire it as a `conflictPolicies`
111
+ * global to let a privileged identity jump a held entity without a bespoke
112
+ * policy. The authorization is the capability, not an identity string.
113
+ */
114
+ export declare const capabilityPreemptPolicy: ConflictPolicy;
84
115
  export {};
@@ -15,3 +15,18 @@ export const defaultPolicy = (conflict) => ({
15
15
  action: 'reject',
16
16
  reason: conflict.kind === 'stale_context' ? 'stale_context' : 'intent_conflict',
17
17
  });
18
+ /**
19
+ * Capability-gated preemption. An `intent_held` conflict is PREEMPTED when the
20
+ * committer holds the `intent.preempt` operation in its capability allowlist
21
+ * (the holder is evicted, the committer takes the lease); everything else falls
22
+ * back to `defaultPolicy` (reject). Opt-in — wire it as a `conflictPolicies`
23
+ * global to let a privileged identity jump a held entity without a bespoke
24
+ * policy. The authorization is the capability, not an identity string.
25
+ */
26
+ export const capabilityPreemptPolicy = (conflict) => {
27
+ if (conflict.kind === 'intent_held' &&
28
+ conflict.committerOperations.includes('intent.preempt')) {
29
+ return { action: 'preempt', reason: 'capability:intent.preempt' };
30
+ }
31
+ return defaultPolicy(conflict);
32
+ };
@@ -12,6 +12,7 @@
12
12
  * without duplicating the fetch boilerplate.
13
13
  */
14
14
  import { z } from 'zod';
15
+ import { translateHttpError } from '../errors.js';
15
16
  // ── Response validation ─────────────────────────────────────────────────
16
17
  //
17
18
  // Each result slot is an array of rows (or an object for bundled
@@ -60,14 +61,24 @@ export async function postQuery(options, batch) {
60
61
  signal: controller.signal,
61
62
  });
62
63
  if (!response.ok) {
63
- // Direct console.error is INTENTIONAL operators alert on the
64
- // `[postQuery.error]` prefix in browser console. Routing through
65
- // an injected logger here would require a coordinated change to
66
- // the alerting pipeline. Tracked as future work; the dual-channel
67
- // alternative (logger + observability.captureException) is the
68
- // production target. Never throw fire-and-forget callers would
69
- // kill Next.js router on unhandled rejection.
70
- console.error(`[postQuery.error] ${response.status} ${response.statusText} for ${batch.queries.map((q) => q.model).join(',')}`);
64
+ // Build the typed AbloError for this HTTP failure (same code→class
65
+ // map the throwing paths use) so the log is tagged + carries a
66
+ // registry `code` (e.g. AbloAuthenticationError/session_expired on a
67
+ // 401) instead of a bare status. We deliberately DON'T throw
68
+ // fire-and-forget callers would kill the Next.js router on an
69
+ // unhandled rejection — and still return empty slots, but the failure
70
+ // is now legible as an Ablo error. Direct console.error is
71
+ // INTENTIONAL: operators alert on the `[postQuery.error]` prefix.
72
+ let body = null;
73
+ try {
74
+ body = await response.clone().json();
75
+ }
76
+ catch {
77
+ // non-JSON error page — translateHttpError falls back to status text
78
+ }
79
+ const err = translateHttpError(response.status, body);
80
+ console.error(`[postQuery.error] ${err.type} ${err.code ?? response.status} for ` +
81
+ `${batch.queries.map((q) => q.model).join(',')}: ${err.message}`);
71
82
  return { results: batch.queries.map(() => []) };
72
83
  }
73
84
  const raw = await response.json();
@@ -72,6 +72,31 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
72
72
  * same-origin session cookies.
73
73
  */
74
74
  apiKey?: string;
75
+ /**
76
+ * Static bearer auth token, sent as `Authorization: Bearer <token>` on the
77
+ * WebSocket upgrade + HTTP. For a token that must be refreshed (e.g. a
78
+ * short-lived JWT), prefer {@link getToken}.
79
+ */
80
+ authToken?: string | null;
81
+ /**
82
+ * Async resolver for a short-lived bearer token. Called once before the
83
+ * engine connects (so the first connection carries a fresh token) and then on
84
+ * a refresh interval ahead of expiry — each result is pushed via
85
+ * `engine.setAuthToken` without tearing down the connection. Wire this to
86
+ * a resolver that mints a short-lived session token (`ek_`/`rk_`) — e.g.
87
+ * `getSyncCapabilityToken` — or your own. Takes precedence over
88
+ * {@link authEndpoint}.
89
+ */
90
+ getToken?: () => Promise<string | null>;
91
+ /**
92
+ * Liveblocks/Stripe-style auth endpoint: a URL on YOUR backend that returns
93
+ * `{ token }` — the `ek_` ephemeral key your server minted for the logged-in
94
+ * user with `ablo.sessions.create({ user: { id } })`. The provider POSTs to it
95
+ * (with cookies) to fetch + refresh the bearer, so the browser carries no
96
+ * secret. Shorthand for a {@link getToken} that does the fetch; ignored when
97
+ * `getToken` is set.
98
+ */
99
+ authEndpoint?: string;
75
100
  /** Optional Zero-style custom mutators. */
76
101
  mutators?: MutatorDefs<Schema<R>>;
77
102
  /** Options forwarded to the internal `useMutators` call (e.g., `undoScope`). */
@@ -114,7 +139,19 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
114
139
  sessionErrorDetector?: SessionErrorDetector;
115
140
  onlineStatus?: OnlineStatusProvider;
116
141
  configOverrides?: SyncEngineConfig;
142
+ /**
143
+ * Raw sync-group strings for the initial connection. Prefer {@link scope} —
144
+ * the model form (`{ decks: deckId }`) that the engine resolves through the
145
+ * schema's `scope`, so you never hand-write a `deck:<id>` string. Both merge.
146
+ */
117
147
  syncGroups?: string[];
148
+ /**
149
+ * Model-form connection scope: `{ decks: deckId, documents: documentId }` or
150
+ * entity refs. Resolved through the schema's per-model `scope` into group
151
+ * strings (so typename `SlideDeck` → `deck:<id>`), unioned with {@link syncGroups}.
152
+ * Memoize the object if it's derived, to avoid rotating the engine each render.
153
+ */
154
+ scope?: ParticipantScope;
118
155
  bootstrapBaseUrl?: string;
119
156
  maxPoolSize?: number;
120
157
  /**
@@ -5,7 +5,7 @@ import { Ablo } from '../client/Ablo.js';
5
5
  import { createParticipantClaimId, parseParticipantTtlSeconds, resolveParticipantSyncGroups, } from '../sync/participants.js';
6
6
  import { SyncContext } from './context.js';
7
7
  import { AbloInternalContext } from './internalContext.js';
8
- import { AbloValidationError } from '../errors.js';
8
+ import { AbloValidationError, AbloAuthenticationError } from '../errors.js';
9
9
  import { useSyncStatus } from './useSyncStatus.js';
10
10
  import { DefaultFallback } from './DefaultFallback.js';
11
11
  // ── Implementation ───────────────────────────────────────────────────
@@ -32,7 +32,7 @@ function createErrorEmitter() {
32
32
  };
33
33
  }
34
34
  export function AbloProvider(props) {
35
- const { schema, url = 'wss://mesh.ablo.finance', userId, teamIds, apiKey, preventUnsavedChanges, onSessionExpired, onError, observability, logger, mutationExecutor, mutationDispatcher, sessionErrorDetector, onlineStatus, configOverrides, syncGroups, bootstrapBaseUrl, maxPoolSize, persistence, bootstrapMode, fallback = _jsx(DefaultFallback, {}), children, } = props;
35
+ const { schema, url = 'wss://mesh.ablo.finance', userId, teamIds, apiKey, authToken, getToken, authEndpoint, preventUnsavedChanges, onSessionExpired, onError, observability, logger, mutationExecutor, mutationDispatcher, sessionErrorDetector, onlineStatus, configOverrides, syncGroups, scope, bootstrapBaseUrl, maxPoolSize, persistence, bootstrapMode, fallback = _jsx(DefaultFallback, {}), children, } = props;
36
36
  // Account scope is no longer accepted from props. The engine learns
37
37
  // it from auth (capability token) at bootstrap and we read it back
38
38
  // out of `_store.orgId` once `engine.ready()` resolves.
@@ -54,6 +54,25 @@ export function AbloProvider(props) {
54
54
  useEffect(() => {
55
55
  return errorEmitter.subscribe((err) => onErrorRef.current?.(err));
56
56
  }, [errorEmitter]);
57
+ // Stash the token resolver in a ref so a new function identity each render
58
+ // does not re-key (tear down) the engine. Read at fire time in the connect +
59
+ // refresh paths below — same `useEventCallback` idiom as `onError`. `getToken`
60
+ // wins; otherwise `authEndpoint` is fetched for `{ token }`.
61
+ const tokenFromEndpoint = authEndpoint
62
+ ? async () => {
63
+ const res = await fetch(authEndpoint, {
64
+ method: 'POST',
65
+ credentials: 'include',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ });
68
+ if (!res.ok)
69
+ return null;
70
+ const body = (await res.json());
71
+ return body.token ?? null;
72
+ }
73
+ : undefined;
74
+ const getTokenRef = useRef(getToken ?? tokenFromEndpoint);
75
+ getTokenRef.current = getToken ?? tokenFromEndpoint;
57
76
  // ── Engine lifecycle keyed on (userId, url) ─────────────────────
58
77
  //
59
78
  // The engine rotates when either of these change. For everything
@@ -79,6 +98,7 @@ export function AbloProvider(props) {
79
98
  schema,
80
99
  ...(userId ? { user: { id: userId, teamIds } } : {}),
81
100
  apiKey,
101
+ ...(authToken ? { authToken } : {}),
82
102
  logger,
83
103
  observability,
84
104
  sessionErrorDetector,
@@ -86,7 +106,11 @@ export function AbloProvider(props) {
86
106
  mutationExecutor,
87
107
  mutationDispatcher,
88
108
  configOverrides,
89
- syncGroups,
109
+ // Union raw strings with model-form `scope` resolved through the schema,
110
+ // so `scope={{ decks: id }}` becomes `deck:<id>` via the model's `scope`.
111
+ syncGroups: scope
112
+ ? [...(syncGroups ?? []), ...resolveParticipantSyncGroups(scope, schema)]
113
+ : syncGroups,
90
114
  bootstrapBaseUrl,
91
115
  maxPoolSize,
92
116
  persistence,
@@ -117,6 +141,52 @@ export function AbloProvider(props) {
117
141
  // saves once `useAblo` exists.
118
142
  (async () => {
119
143
  try {
144
+ // Resolve a fresh bearer token BEFORE `ready()` (which connects —
145
+ // the engine is built with `autoStart: false`), so the first WS
146
+ // upgrade + bootstrap carry it. No race: nothing has connected yet.
147
+ const fetchToken = getTokenRef.current;
148
+ if (fetchToken) {
149
+ const token = await fetchToken();
150
+ if (isStale || abort.signal.aborted)
151
+ return;
152
+ if (token) {
153
+ engine.setAuthToken(token);
154
+ }
155
+ else {
156
+ // A configured `getToken` that resolves to falsy means "no active
157
+ // session" — the session-token resolver (e.g. `getSyncCapabilityToken`)
158
+ // returns null when logged out, the session hasn't hydrated, or the
159
+ // mint endpoint rejects. Its contract is "the provider then surfaces
160
+ // the usual unauthenticated path rather than connecting with a bad token."
161
+ //
162
+ // We MUST short-circuit here rather than fall through to
163
+ // `engine.ready()`. On apps/web's identity path (org + user.id are
164
+ // both supplied) `resolveParticipantIdentity` takes the
165
+ // trust-the-caller Branch 3, which does NOT validate the (absent)
166
+ // token — so init would proceed into `store.initialize()` and open
167
+ // IndexedDB *before* any auth-bearing bootstrap/WS call. If storage
168
+ // is at all wedged the no-session condition then surfaces only as an
169
+ // opaque `IndexedDB "ablo_databases" open did not resolve within
170
+ // 10000ms` stall, never the real auth error. Surface `session_expired`
171
+ // explicitly and let the app redirect to sign-in.
172
+ //
173
+ // Unlike the server-detected `onSessionError` handler above, we do
174
+ // NOT purge: nothing connected or wrote this mount, so any cached
175
+ // IndexedDB belongs to a prior session and is reconciled by the next
176
+ // valid bootstrap — purging here would drop a still-valid offline
177
+ // queue on a merely transient null token.
178
+ const authErr = new AbloAuthenticationError('No session token available — getToken() resolved to null. The ' +
179
+ 'session is missing or expired; sign in again.', { code: 'session_expired' });
180
+ errorEmitter.emit(authErr);
181
+ try {
182
+ await onSessionExpired?.();
183
+ }
184
+ catch (hookErr) {
185
+ errorEmitter.emit(hookErr);
186
+ }
187
+ return;
188
+ }
189
+ }
120
190
  await engine.ready();
121
191
  if (isStale || abort.signal.aborted)
122
192
  return;
@@ -142,6 +212,35 @@ export function AbloProvider(props) {
142
212
  // `mutationExecutor` identity change would destroy the WebSocket.
143
213
  // eslint-disable-next-line react-hooks/exhaustive-deps
144
214
  }, [engineKey]);
215
+ // ── Bearer-token refresh ─────────────────────────────────────────
216
+ //
217
+ // Short-lived tokens (the sync-server JWT defaults to 15m) must be rolled
218
+ // before they expire or the next reconnect/HTTP call fails auth. Re-resolve
219
+ // ahead of expiry and push via `engine.setAuthToken`, which swaps the token
220
+ // on the live WebSocket + bootstrap header without tearing down the engine.
221
+ // Only runs when a `getToken` resolver is wired (cookie deployments skip it).
222
+ useEffect(() => {
223
+ const engine = engineState.engine;
224
+ if (!engine || !getTokenRef.current)
225
+ return;
226
+ // Comfortably inside the 15m JWT lifetime; a missed tick is recovered by
227
+ // the next one well before expiry.
228
+ const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
229
+ const id = setInterval(() => {
230
+ void (async () => {
231
+ try {
232
+ const token = await getTokenRef.current?.();
233
+ if (token)
234
+ engine.setAuthToken(token);
235
+ }
236
+ catch {
237
+ // Transient (offline / session check). The next tick retries; the
238
+ // engine keeps using the still-valid current token until then.
239
+ }
240
+ })();
241
+ }, REFRESH_INTERVAL_MS);
242
+ return () => clearInterval(id);
243
+ }, [engineState.engine]);
145
244
  // ── beforeunload + preventUnsavedChanges ─────────────────────────
146
245
  useEffect(() => {
147
246
  if (typeof window === 'undefined')
@@ -251,7 +350,11 @@ export function useParticipant(opts) {
251
350
  const ctx = useContext(AbloInternalContext);
252
351
  const engine = ctx?.engine ?? null;
253
352
  const { paused = false } = opts;
254
- const scopeKey = JSON.stringify(resolveParticipantSyncGroups(opts.scope).sort());
353
+ // Resolve the model-form scope ({ decks: id } / refs) THROUGH the schema, so a
354
+ // model's declared `scope` kind is honored (typename `SlideDeck` → `deck:<id>`,
355
+ // not the `type:id` string fallback). Schema appears once the engine is ready;
356
+ // until then refs resolve by convention, then re-resolve when it arrives.
357
+ const scopeKey = JSON.stringify(resolveParticipantSyncGroups(opts.scope, engine?.schema).sort());
255
358
  const scopedSyncGroups = useMemo(() => JSON.parse(scopeKey), [scopeKey]);
256
359
  const [claimError, setClaimError] = useState(null);
257
360
  const [claimConnected, setClaimConnected] = useState(false);
@@ -33,4 +33,4 @@ export interface ClientSideSuspenseProps {
33
33
  /** What to render once the subtree is cleared to render. */
34
34
  children: ReactNode;
35
35
  }
36
- export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react/jsx-runtime").JSX.Element;
36
+ export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react").JSX.Element;
@@ -21,4 +21,4 @@
21
21
  * pass `fallback={null}`. Consumers who want to skip the gate entirely
22
22
  * pass `fallback="passthrough"`.
23
23
  */
24
- export declare function DefaultFallback(): import("react/jsx-runtime").JSX.Element;
24
+ export declare function DefaultFallback(): import("react").JSX.Element;
@@ -4,7 +4,7 @@ export interface SyncGroupProviderProps {
4
4
  id: string;
5
5
  children: ReactNode;
6
6
  }
7
- export declare function SyncGroupProvider({ id, children }: SyncGroupProviderProps): import("react/jsx-runtime").JSX.Element;
7
+ export declare function SyncGroupProvider({ id, children }: SyncGroupProviderProps): import("react").JSX.Element;
8
8
  /**
9
9
  * Returns the ID of the nearest `<SyncGroupProvider>`. Throws if
10
10
  * called outside one — sync-group awareness is mandatory by design,
@@ -13,9 +13,10 @@
13
13
  * immediately. The provider-level `fallback` is the default path.
14
14
  *
15
15
  * Data hooks:
16
- * useAblo((ablo) => ablo.tasks.retrieve(id)) — primary React read API
16
+ * useAblo((ablo) => ablo.tasks.get(id)) — primary React read API (sync local snapshot)
17
17
  * useAblo() — typed client for callbacks/effects
18
- * (reads: ablo.<model>.retrieve/list;
18
+ * (sync local reads: ablo.<model>.get/getAll;
19
+ * async server reads: ablo.<model>.retrieve/list;
19
20
  * writes: ablo.<model>.create/update/delete)
20
21
  * useMutators(defs, opts?) — Zero-style custom mutators
21
22
  * useUndoScope(name) — per-surface undo/redo
@@ -13,9 +13,10 @@
13
13
  * immediately. The provider-level `fallback` is the default path.
14
14
  *
15
15
  * Data hooks:
16
- * useAblo((ablo) => ablo.tasks.retrieve(id)) — primary React read API
16
+ * useAblo((ablo) => ablo.tasks.get(id)) — primary React read API (sync local snapshot)
17
17
  * useAblo() — typed client for callbacks/effects
18
- * (reads: ablo.<model>.retrieve/list;
18
+ * (sync local reads: ablo.<model>.get/getAll;
19
+ * async server reads: ablo.<model>.retrieve/list;
19
20
  * writes: ablo.<model>.create/update/delete)
20
21
  * useMutators(defs, opts?) — Zero-style custom mutators
21
22
  * useUndoScope(name) — per-surface undo/redo
@@ -45,11 +45,11 @@ export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'>
45
45
  * // With Register augmentation (recommended):
46
46
  * const ablo = useAblo();
47
47
  * if (!ablo) return <Loading />;
48
- * const docs = await ablo.documents.load({ where: { id } });
48
+ * const doc = await ablo.documents.retrieve(id); // async server read
49
49
  *
50
- * // Reactive selector:
51
- * const doc = useAblo((ablo) => ablo.documents.retrieve(id)) ?? serverDoc;
52
- * const active = useAblo((ablo) => ablo.documents.claimState(id));
50
+ * // Reactive selector (sync local-graph snapshot):
51
+ * const doc = useAblo((ablo) => ablo.documents.get(id)) ?? serverDoc;
52
+ * const active = useAblo((ablo) => ablo.documents.claim.state(id));
53
53
  *
54
54
  * // Without augmentation, pass the schema generic:
55
55
  * const ablo = useAblo<(typeof schema)['models']>();
@@ -9,7 +9,7 @@ function readModelResult(engine, modelClient, id, initial) {
9
9
  if (!modelClient || id === undefined) {
10
10
  return { data: initial, claims: EMPTY_CLAIMS, claimed: false };
11
11
  }
12
- const data = snapshotValue(modelClient.retrieve(id) ?? initial);
12
+ const data = snapshotValue(modelClient.get(id) ?? initial);
13
13
  const meta = getModelClientMeta(modelClient);
14
14
  const claims = meta && engine
15
15
  ? engine.intents.list({ model: meta.key, id })
@@ -29,7 +29,6 @@ export function useAblo(modelOrSelect, id, options) {
29
29
  const ctx = useContext(AbloInternalContext);
30
30
  const engine = ctx?.engine ?? null;
31
31
  const initial = options?.initial;
32
- const hasSelection = modelOrSelect !== undefined;
33
32
  const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
34
33
  const modelClient = typeof modelOrSelect === 'function' && id !== undefined
35
34
  ? engine
@@ -38,14 +37,20 @@ export function useAblo(modelOrSelect, id, options) {
38
37
  : typeof modelOrSelect === 'function'
39
38
  ? undefined
40
39
  : modelOrSelect;
40
+ // Claims live on a non-MobX event emitter (engine.intents), so the useReactive
41
+ // reactions below cannot track them — we bridge changes through a setState bump.
42
+ // ONLY the model-row form (`id !== undefined`) actually reads claims, so gate the
43
+ // subscription on `id`. The selector-only form (`useAblo((a) => a.x.get/getAll)`)
44
+ // never reads claims; subscribing it to the workspace-global intent stream would
45
+ // re-render + double-compute it on every intent/presence delta anywhere (a real
46
+ // storm during AI editing / live collaboration) for a value that can't change.
41
47
  const [claimVersion, setClaimVersion] = useState(0);
42
48
  useEffect(() => {
43
- if (!engine || !hasSelection)
49
+ if (!engine || id === undefined)
44
50
  return;
45
51
  return engine.intents.onChange(() => setClaimVersion((version) => version + 1));
46
- }, [engine, hasSelection]);
52
+ }, [engine, id]);
47
53
  const selected = useReactive(() => {
48
- void claimVersion;
49
54
  if (!engine || !isSelectorOnly || typeof modelOrSelect !== 'function') {
50
55
  return undefined;
51
56
  }
@@ -66,16 +66,29 @@ export function useReactive(compute, equals = defaultEquals) {
66
66
  const subscribeVersionRef = useRef(0);
67
67
  if (snapshotRef.current === null) {
68
68
  snapshotRef.current = { value: compute() };
69
- computeRef.current = compute;
70
69
  }
71
70
  else if (computeRef.current !== compute) {
71
+ // `compute` is a fresh inline arrow at virtually every call site, so this
72
+ // branch runs on essentially every render. Reconcile the snapshot against
73
+ // the latest closure, but only force a re-subscription when the value
74
+ // ACTUALLY changed. For the dominant case (same observable source, new
75
+ // arrow identity, unchanged value) this avoids tearing down + recreating
76
+ // the MobX reaction — and its double-compute — on every render. A genuine
77
+ // source swap (a memoized compute closing over a new observable source)
78
+ // changes the value, which both updates the snapshot and bumps
79
+ // `subscribeVersion` so the reaction below re-subscribes and re-tracks the
80
+ // new source's observables.
72
81
  const next = compute();
73
82
  if (!equals(snapshotRef.current.value, next)) {
74
83
  snapshotRef.current = { value: next };
84
+ subscribeVersionRef.current++;
75
85
  }
76
- computeRef.current = compute;
77
- subscribeVersionRef.current++;
78
86
  }
87
+ // Point the long-lived reaction at the latest closure every render. The
88
+ // reaction expression reads `computeRef.current` at fire time, so it always
89
+ // runs the newest compute (and re-tracks its observables) even when we did
90
+ // not re-subscribe above.
91
+ computeRef.current = compute;
79
92
  const subscribeVersion = subscribeVersionRef.current;
80
93
  const subscribe = useCallback((onChange) => {
81
94
  return reaction(() => computeRef.current(), (next) => {