@abloatai/ablo 0.8.0 → 0.9.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 (162) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +32 -27
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +172 -2
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/agent/session.js +3 -3
  8. package/dist/ai-sdk/coordination-context.js +4 -0
  9. package/dist/ai-sdk/index.d.ts +56 -47
  10. package/dist/ai-sdk/index.js +56 -47
  11. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  12. package/dist/ai-sdk/intent-broadcast.js +11 -4
  13. package/dist/ai-sdk/wrap.d.ts +14 -11
  14. package/dist/ai-sdk/wrap.js +11 -13
  15. package/dist/auth/credentialSource.d.ts +34 -0
  16. package/dist/auth/credentialSource.js +63 -0
  17. package/dist/auth/index.d.ts +2 -22
  18. package/dist/auth/index.js +4 -42
  19. package/dist/auth/schemas.d.ts +35 -0
  20. package/dist/auth/schemas.js +53 -0
  21. package/dist/client/Ablo.d.ts +160 -42
  22. package/dist/client/Ablo.js +145 -75
  23. package/dist/client/ApiClient.d.ts +20 -4
  24. package/dist/client/ApiClient.js +166 -28
  25. package/dist/client/auth.d.ts +14 -5
  26. package/dist/client/auth.js +60 -7
  27. package/dist/client/createInternalComponents.d.ts +2 -0
  28. package/dist/client/createInternalComponents.js +8 -1
  29. package/dist/client/createModelProxy.d.ts +130 -66
  30. package/dist/client/createModelProxy.js +152 -49
  31. package/dist/client/httpClient.d.ts +71 -0
  32. package/dist/client/httpClient.js +69 -0
  33. package/dist/client/identity.d.ts +2 -6
  34. package/dist/client/identity.js +49 -11
  35. package/dist/client/index.d.ts +1 -0
  36. package/dist/client/index.js +1 -0
  37. package/dist/client/registerDataSource.d.ts +3 -3
  38. package/dist/client/registerDataSource.js +11 -9
  39. package/dist/client/validateAbloOptions.js +1 -1
  40. package/dist/core/DatabaseManager.js +30 -2
  41. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  42. package/dist/core/openIDBWithTimeout.js +88 -1
  43. package/dist/errorCodes.d.ts +70 -1
  44. package/dist/errorCodes.js +108 -9
  45. package/dist/errors.d.ts +2 -2
  46. package/dist/errors.js +72 -22
  47. package/dist/index.d.ts +17 -8
  48. package/dist/index.js +15 -6
  49. package/dist/keys/index.d.ts +16 -1
  50. package/dist/keys/index.js +26 -6
  51. package/dist/mutators/UndoManager.d.ts +86 -50
  52. package/dist/mutators/UndoManager.js +129 -22
  53. package/dist/mutators/inverseOp.d.ts +129 -0
  54. package/dist/mutators/inverseOp.js +74 -0
  55. package/dist/mutators/readerActions.d.ts +1 -1
  56. package/dist/mutators/undoApply.d.ts +42 -0
  57. package/dist/mutators/undoApply.js +143 -0
  58. package/dist/query/client.d.ts +10 -9
  59. package/dist/query/client.js +3 -6
  60. package/dist/react/AbloProvider.d.ts +23 -126
  61. package/dist/react/AbloProvider.js +62 -199
  62. package/dist/react/useAblo.d.ts +2 -2
  63. package/dist/react/useCurrentUserId.d.ts +1 -1
  64. package/dist/react/useCurrentUserId.js +1 -1
  65. package/dist/react/useMutators.js +19 -12
  66. package/dist/schema/ddl.d.ts +26 -3
  67. package/dist/schema/ddl.js +152 -4
  68. package/dist/schema/index.d.ts +4 -0
  69. package/dist/schema/index.js +12 -0
  70. package/dist/schema/model.d.ts +11 -0
  71. package/dist/schema/model.js +2 -0
  72. package/dist/schema/openapi.d.ts +28 -0
  73. package/dist/schema/openapi.js +118 -0
  74. package/dist/schema/plane.d.ts +23 -0
  75. package/dist/schema/plane.js +19 -0
  76. package/dist/schema/relation.d.ts +20 -0
  77. package/dist/schema/serialize.d.ts +4 -0
  78. package/dist/schema/serialize.js +4 -0
  79. package/dist/schema/sync-delta-row.d.ts +157 -0
  80. package/dist/schema/sync-delta-row.js +102 -0
  81. package/dist/schema/sync-delta-wire.d.ts +180 -0
  82. package/dist/schema/sync-delta-wire.js +102 -0
  83. package/dist/server/adapter.d.ts +156 -0
  84. package/dist/server/adapter.js +19 -0
  85. package/dist/server/commit.d.ts +82 -0
  86. package/dist/server/commit.js +1 -0
  87. package/dist/server/index.d.ts +14 -0
  88. package/dist/server/index.js +1 -0
  89. package/dist/server/next.d.ts +51 -0
  90. package/dist/server/next.js +47 -0
  91. package/dist/server/read-config.d.ts +60 -0
  92. package/dist/server/read-config.js +8 -0
  93. package/dist/server/storage-mode.d.ts +17 -0
  94. package/dist/server/storage-mode.js +12 -0
  95. package/dist/source/adapter.d.ts +59 -0
  96. package/dist/source/adapter.js +19 -0
  97. package/dist/source/adapters/drizzle.d.ts +34 -0
  98. package/dist/source/adapters/drizzle.js +147 -0
  99. package/dist/source/adapters/memory.d.ts +12 -0
  100. package/dist/source/adapters/memory.js +114 -0
  101. package/dist/source/adapters/prisma.d.ts +57 -0
  102. package/dist/source/adapters/prisma.js +199 -0
  103. package/dist/source/conformance.d.ts +32 -0
  104. package/dist/source/conformance.js +134 -0
  105. package/dist/source/contract.d.ts +143 -0
  106. package/dist/source/contract.js +98 -0
  107. package/dist/source/index.d.ts +61 -10
  108. package/dist/source/index.js +98 -0
  109. package/dist/source/next.d.ts +33 -0
  110. package/dist/source/next.js +26 -0
  111. package/dist/sync/BootstrapHelper.d.ts +10 -0
  112. package/dist/sync/BootstrapHelper.js +10 -15
  113. package/dist/sync/ConnectionManager.d.ts +55 -1
  114. package/dist/sync/ConnectionManager.js +155 -16
  115. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  116. package/dist/sync/HydrationCoordinator.js +238 -39
  117. package/dist/sync/NetworkProbe.d.ts +58 -24
  118. package/dist/sync/NetworkProbe.js +118 -42
  119. package/dist/sync/SyncWebSocket.d.ts +45 -70
  120. package/dist/sync/SyncWebSocket.js +70 -36
  121. package/dist/sync/createIntentStream.js +10 -1
  122. package/dist/types/streams.d.ts +9 -0
  123. package/dist/utils/mobx-setup.js +1 -0
  124. package/dist/webhooks/events.d.ts +38 -0
  125. package/dist/webhooks/events.js +40 -0
  126. package/dist/webhooks/index.d.ts +10 -0
  127. package/dist/webhooks/index.js +10 -0
  128. package/dist/wire/errorEnvelope.d.ts +34 -0
  129. package/dist/wire/errorEnvelope.js +86 -0
  130. package/dist/wire/frames.d.ts +119 -0
  131. package/dist/wire/frames.js +1 -0
  132. package/dist/wire/index.d.ts +24 -0
  133. package/dist/wire/index.js +21 -0
  134. package/dist/wire/listEnvelope.d.ts +45 -0
  135. package/dist/wire/listEnvelope.js +17 -0
  136. package/docs/api.md +47 -44
  137. package/docs/cli.md +44 -44
  138. package/docs/client-behavior.md +30 -30
  139. package/docs/coordination.md +33 -36
  140. package/docs/data-sources.md +35 -15
  141. package/docs/examples/agent-human.md +45 -43
  142. package/docs/examples/ai-sdk-tool.md +20 -16
  143. package/docs/examples/existing-python-backend.md +16 -12
  144. package/docs/examples/nextjs.md +14 -12
  145. package/docs/examples/scoped-agent.md +1 -1
  146. package/docs/examples/server-agent.md +24 -21
  147. package/docs/guarantees.md +15 -13
  148. package/docs/index.md +1 -1
  149. package/docs/integration-guide.md +30 -30
  150. package/docs/interaction-model.md +19 -23
  151. package/docs/mcp/claude-code.md +3 -3
  152. package/docs/mcp/cursor.md +1 -1
  153. package/docs/mcp/windsurf.md +2 -2
  154. package/docs/mcp.md +6 -6
  155. package/docs/quickstart.md +41 -31
  156. package/docs/react.md +13 -9
  157. package/docs/schema-contract.md +12 -10
  158. package/docs/the-loop.md +21 -0
  159. package/examples/data-source/README.md +4 -5
  160. package/examples/data-source/customer-server.ts +27 -25
  161. package/llms.txt +28 -5
  162. package/package.json +43 -3
@@ -6,12 +6,15 @@
6
6
  * After laptop sleep/wake, it may report true before WiFi/DNS are functional.
7
7
  *
8
8
  * This module provides an authenticated probe against the sync server to verify
9
- * real connectivity + session validity in a single round-trip. The probe hits
10
- * `/api/auth/check`, which runs the SAME auth middleware as the WebSocket
11
- * upgrade path:
12
- * 204 No Content reachable, session cookie valid
13
- * 401/403 → reachable, session expired or invalid
14
- * network failunreachable
9
+ * real connectivity + credential validity in a single round-trip. The probe
10
+ * hits `/api/auth/check`, which runs the SAME auth middleware as the WebSocket
11
+ * upgrade path, and classifies the response into a single {@link ProbeOutcome}
12
+ * via the closed recovery taxonomy ({@link classifyRecovery}):
13
+ * 204 No Content `reachable` (credential valid)
14
+ * 401 `apikey_expired` (ephemeral key)`credential_stale` (re-mint & retry, NO sign-out)
15
+ * 401 `session_expired` / bare 401 → `session_expired` (sign out)
16
+ * 401/403 credential-type/config/perm → `auth_blocked` (stop, no loop, no sign-out)
17
+ * network fail / offline → `unreachable`
15
18
  *
16
19
  * This closes a real gap: the browser's WebSocket API hides HTTP status from
17
20
  * the handshake, so a 401 on the WS upgrade surfaces only as `close code
@@ -21,8 +24,39 @@
21
24
  *
22
25
  * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
23
26
  */
27
+ import { z } from 'zod';
24
28
  import { getContext } from '../context.js';
25
- import { SyncSessionError, isRetryableCode } from '../errors.js';
29
+ import { classifyRecovery } from '../errors.js';
30
+ import { withAuthHeaders } from '../auth/credentialSource.js';
31
+ /**
32
+ * The closed set of probe outcomes — one value carrying both reachability and
33
+ * credential disposition, so the {@link ConnectionManager} branches on a single
34
+ * exhaustive discriminant instead of reconstructing intent from a trio of
35
+ * booleans. Mirrors the {@link RecoveryClass} taxonomy at the connectivity tier.
36
+ */
37
+ export const PROBE_OUTCOMES = [
38
+ /** Server reachable and the access credential is currently valid. */
39
+ 'reachable',
40
+ /** Could not reach the server (offline / DNS / TLS / timeout). */
41
+ 'unreachable',
42
+ /** Reachable, but the long-lived login is gone → terminal, sign out. */
43
+ 'session_expired',
44
+ /** Reachable, but the ephemeral access key (`ek_`/`rk_`) expired → silently
45
+ * re-mint a fresh key from the still-valid login and retry. NOT a sign-out. */
46
+ 'credential_stale',
47
+ /** Reachable, but the credential TYPE/config was rejected (wrong key kind,
48
+ * untrusted issuer, no org, a 403) → stop; neither reconnecting nor re-auth
49
+ * helps. Distinct from a sign-out. */
50
+ 'auth_blocked',
51
+ ];
52
+ /** Zod enum derived from {@link PROBE_OUTCOMES}. */
53
+ export const probeOutcomeSchema = z.enum(PROBE_OUTCOMES);
54
+ /** Result of a network probe: a single {@link ProbeOutcome} plus round-trip
55
+ * latency (null when the probe never completed). */
56
+ export const probeResultSchema = z.object({
57
+ outcome: probeOutcomeSchema,
58
+ latencyMs: z.number().nullable(),
59
+ });
26
60
  const PROBE_TIMEOUT_MS = 4000;
27
61
  /**
28
62
  * Derive the probe URL from a sync-server base URL. Accepts `ws://`,
@@ -46,11 +80,14 @@ function resolveProbeUrl(baseUrl) {
46
80
  * Returns reachability AND session status in a single call, so the
47
81
  * ConnectionStore can make the right state transition without guessing.
48
82
  *
49
- * @param baseUrl The sync-server base URL (HTTP or WS scheme accepted).
50
- * If omitted, falls back to `NEXT_PUBLIC_GO_SERVER_URL`
51
- * `http://localhost:8080` for backwards compatibility.
83
+ * @param input The sync-server base URL (HTTP or WS scheme accepted), or an
84
+ * options bag with `authToken`. A bare string is still accepted
85
+ * for backwards compatibility.
52
86
  */
53
- export async function probeNetwork(baseUrl) {
87
+ export async function probeNetwork(input) {
88
+ const baseUrl = typeof input === 'string' ? input : input?.baseUrl;
89
+ const getAuthToken = typeof input === 'string' ? undefined : input?.getAuthToken;
90
+ const authToken = typeof input === 'string' ? undefined : input?.authToken;
54
91
  const url = resolveProbeUrl(baseUrl);
55
92
  // Fast-fail: if navigator.onLine is false, skip the probe entirely.
56
93
  // This is the ONE case where navigator.onLine is reliable (MDN: "false
@@ -58,51 +95,90 @@ export async function probeNetwork(baseUrl) {
58
95
  // because Node 22+ exposes `navigator` with `onLine === undefined`,
59
96
  // and `!undefined === true` would short-circuit the probe server-side.
60
97
  if (typeof navigator !== 'undefined' && navigator.onLine === false) {
61
- return { reachable: false, sessionValid: null, latencyMs: null };
98
+ return { outcome: 'unreachable', latencyMs: null };
62
99
  }
63
100
  const controller = new AbortController();
64
101
  const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
65
102
  const start = performance.now();
66
103
  try {
104
+ const headers = withAuthHeaders(getAuthToken, { 'Cache-Control': 'no-cache' }, authToken);
67
105
  const response = await fetch(url, {
68
106
  method: 'HEAD',
69
- credentials: 'include', // Send cookies for session check
70
107
  signal: controller.signal,
71
108
  // Cache-bust to avoid stale responses
72
- headers: { 'Cache-Control': 'no-cache' },
109
+ headers,
73
110
  });
74
111
  const latencyMs = Math.round(performance.now() - start);
75
112
  // The probe is a HEAD (no body), but the sync-server sets `X-Auth-Failure:
76
- // <code>` on every auth rejection feed that to the code-aware detector so
77
- // only a genuine session/JWT EXPIRY marks the session invalid. A non-expiry
78
- // auth failure (e.g. api_key_required, jwt_issuer_untrusted) leaves
79
- // sessionValid alone the user IS logged in; signing them out wouldn't fix
80
- // a credential-type/config problem and just bounces them to /signin.
113
+ // <code>` on every auth rejection. Route the code through the closed
114
+ // recovery taxonomy so each failure mode gets its correct outcome — the
115
+ // whole reason this taxonomy exists: an expired ephemeral key
116
+ // (`access_credential_expiry`) must re-mint, NOT sign the user out the way
117
+ // a genuine login expiry (`session_expiry`) does, and NOT wedge the way a
118
+ // credential-type/config rejection (`auth_blocked`) does.
81
119
  const authFailure = response.headers.get('x-auth-failure');
82
- const failureBody = authFailure
83
- ? JSON.stringify({ code: authFailure })
84
- : undefined;
85
- if (SyncSessionError.isSessionErrorResponse(response.status, failureBody)) {
86
- // Server reachable but session expired/invalid
87
- getContext().logger.info('[NetworkProbe] Server reachable, session expired', {
88
- status: response.status,
89
- latencyMs,
90
- });
91
- return { reachable: true, sessionValid: false, latencyMs };
120
+ if (authFailure) {
121
+ const recovery = classifyRecovery(authFailure);
122
+ switch (recovery) {
123
+ case 'session_expiry':
124
+ getContext().logger.info('[NetworkProbe] Server reachable, login expired', {
125
+ status: response.status,
126
+ code: authFailure,
127
+ latencyMs,
128
+ });
129
+ return { outcome: 'session_expired', latencyMs };
130
+ case 'access_credential_expiry':
131
+ getContext().logger.info('[NetworkProbe] Server reachable, access key stale — will re-mint', {
132
+ status: response.status,
133
+ code: authFailure,
134
+ latencyMs,
135
+ });
136
+ return { outcome: 'credential_stale', latencyMs };
137
+ case 'auth_blocked':
138
+ case 'permission':
139
+ case 'none':
140
+ // A non-expiry auth rejection — wrong credential type/config, a 403,
141
+ // or an auth-tagged code this SDK doesn't recognise. Re-auth re-mints
142
+ // the same rejected credential and retrying won't help, so STOP
143
+ // rather than reconnect-loop or sign the user out.
144
+ getContext().logger.warn('[NetworkProbe] Reachable but auth-blocked (non-retryable, non-expiry)', {
145
+ status: response.status,
146
+ code: authFailure,
147
+ recovery,
148
+ latencyMs,
149
+ });
150
+ return { outcome: 'auth_blocked', latencyMs };
151
+ case 'transient':
152
+ // Retryable auth-tagged response — connectivity is proven; fall
153
+ // through to `reachable` and let the normal retry path handle it.
154
+ break;
155
+ default: {
156
+ const _exhaustive = recovery;
157
+ void _exhaustive;
158
+ }
159
+ }
92
160
  }
93
- // Reachable, but a NON-retryable auth/config failure that is NOT a session
94
- // expiry (api_key_required, jwt_issuer_untrusted, …). Re-auth won't fix it
95
- // and retrying won't eithersignal authBlocked so the manager STOPS
96
- // rather than reconnect-looping or signing the user out.
97
- if (authFailure && !isRetryableCode(authFailure)) {
98
- getContext().logger.warn('[NetworkProbe] Reachable but auth-blocked (non-retryable, non-expiry)', {
99
- status: response.status,
100
- code: authFailure,
161
+ else if (response.status === 401) {
162
+ // Bare 401 with no READABLE structured code. This is AMBIGUOUS and must
163
+ // NOT sign the user out on its own two common causes are both
164
+ // recoverable, and only one is a real logout:
165
+ // 1. The server DID send `X-Auth-Failure: apikey_expired`, but it's a
166
+ // custom header on a cross-origin response and the server didn't list
167
+ // it in `Access-Control-Expose-Headers`, so the browser stripped it to
168
+ // null (the network-change logout bug). The access key just needs a
169
+ // re-mint.
170
+ // 2. A genuinely expired access key on a non-Ablo proxy / cookie path.
171
+ // So route to `credential_stale`: the FSM attempts a re-mint, and the ONLY
172
+ // way to actually sign out is the re-mint resolving `null` (login truly
173
+ // gone). If no refresher is wired, the bounded attempt counter falls
174
+ // through to `auth_blocked` (stop) — still never a spurious logout. This
175
+ // upholds the invariant: null is the only terminal path, never a bare 401.
176
+ getContext().logger.info('[NetworkProbe] Server reachable, bare 401 — re-mint (not sign-out)', {
101
177
  latencyMs,
102
178
  });
103
- return { reachable: true, sessionValid: true, authBlocked: true, latencyMs };
179
+ return { outcome: 'credential_stale', latencyMs };
104
180
  }
105
- // 2xx (including 204) means reachable + session valid.
181
+ // 2xx (including 204) means reachable + credential valid.
106
182
  // 3xx/4xx (non-auth) still prove connectivity even though the probe
107
183
  // expected 204; log a warning so misconfigurations surface instead of
108
184
  // silently passing.
@@ -114,12 +190,12 @@ export async function probeNetwork(baseUrl) {
114
190
  });
115
191
  }
116
192
  else {
117
- getContext().logger.debug('[NetworkProbe] Server reachable, session valid', {
193
+ getContext().logger.debug('[NetworkProbe] Server reachable, credential valid', {
118
194
  status: response.status,
119
195
  latencyMs,
120
196
  });
121
197
  }
122
- return { reachable: true, sessionValid: true, latencyMs };
198
+ return { outcome: 'reachable', latencyMs };
123
199
  }
124
200
  catch (error) {
125
201
  clearTimeout(timeout);
@@ -127,7 +203,7 @@ export async function probeNetwork(baseUrl) {
127
203
  getContext().logger.info('[NetworkProbe] Probe failed', {
128
204
  reason: isAbort ? 'timeout' : error.message,
129
205
  });
130
- return { reachable: false, sessionValid: null, latencyMs: null };
206
+ return { outcome: 'unreachable', latencyMs: null };
131
207
  }
132
208
  finally {
133
209
  clearTimeout(timeout);
@@ -8,40 +8,18 @@
8
8
  * - Automatic reconnection with exponential backoff
9
9
  */
10
10
  import { EventEmitter } from 'events';
11
- /** JSON model data from the sync engine — may arrive as a pre-parsed object or a JSON string. */
12
- type SyncDeltaPayload = Record<string, unknown> | string | null;
13
- export interface SyncDelta {
14
- id: number;
15
- /**
16
- * Delta action type full Linear-compatible vocabulary.
17
- *
18
- * Core CRUD:
19
- * I Insert
20
- * U Update
21
- * D — Delete (hard)
22
- * A Archive (soft delete)
23
- * V — Unarchive (reVive)
24
- *
25
- * Permission / access control:
26
- * C — Covering: client gained permission to see an existing entity
27
- * (treated as insert by the client — see handleCovering path).
28
- * G — GroupAdded: recipient was added to a sync group. Paired with
29
- * subsequent 'C' deltas for each newly-visible entity.
30
- * S — GroupRemoved: recipient lost access to a sync group. Client
31
- * purges affected entities from its local store.
32
- */
33
- actionType: 'I' | 'U' | 'D' | 'A' | 'V' | 'C' | 'G' | 'S';
34
- modelName: string;
35
- modelId: string;
36
- data: SyncDeltaPayload;
37
- previousData?: SyncDeltaPayload;
38
- metadata?: SyncDeltaPayload;
39
- syncGroups: string[];
40
- createdBy?: string;
41
- transactionId?: string;
42
- clientMutationId?: string;
43
- createdAt: string;
44
- }
11
+ import type { MutationOperation } from '../interfaces/index.js';
12
+ import type { ClientSyncDelta } from '../schema/sync-delta-wire.js';
13
+ import { type AuthTokenGetter } from '../auth/credentialSource.js';
14
+ /**
15
+ * The wire delta the client receives. Derived from the canonical
16
+ * `clientSyncDeltaSchema` (`@abloatai/ablo/schema`) via `z.infer` so the
17
+ * SDK and the sync-server share ONE contract instead of two hand-maintained
18
+ * interfaces. The action vocabulary (`I`/`U`/`D`/`A`/`V`/`C`/`G`/`S`) and the
19
+ * client-only extras (`metadata`, `clientMutationId`, deprecated flat
20
+ * `createdBy`) live in that schema; see its doc for the full field reference.
21
+ */
22
+ export type SyncDelta = ClientSyncDelta;
45
23
  /**
46
24
  * Payload for legacy actionType 'G' deltas emitted by EmitGroupChange.
47
25
  * Carries both added and removed groups in one delta, forces full re-bootstrap.
@@ -121,6 +99,15 @@ export interface SyncWebSocketOptions {
121
99
  * the Biscuit→opaque-key migration.)
122
100
  */
123
101
  capabilityToken?: string;
102
+ /**
103
+ * Shared credential getter. When provided, WebSocket URL auth reads this
104
+ * instead of a copied `capabilityToken`, so reconnects use refreshed tokens
105
+ * from the SDK's single auth source.
106
+ */
107
+ /** Shared SDK auth getter. Preferred internal name. */
108
+ getAuthToken?: AuthTokenGetter;
109
+ /** @deprecated Use `getAuthToken`. Kept for direct low-level callers. */
110
+ getCapabilityToken?: AuthTokenGetter;
124
111
  }
125
112
  /**
126
113
  * Bootstrap hint from server indicating full or partial bootstrap is needed.
@@ -271,7 +258,7 @@ export interface CoreSyncEventMap {
271
258
  /**
272
259
  * Per-entity wait-queue snapshot: `{ target, queue: Intent[] }` with each
273
260
  * entry `status: 'queued'` + `position`. Broadcast to entity peers on every
274
- * queue mutation — powers the reactive `ablo.<model>.claim.queue(id)` read.
261
+ * queue mutation — powers the reactive `ablo.<model>.claim.queue({ id })` read.
275
262
  */
276
263
  intent_queue: [Record<string, unknown>];
277
264
  intent_acquired: [Record<string, unknown>];
@@ -422,6 +409,16 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
422
409
  * Send message to server
423
410
  */
424
411
  send(message: any): void;
412
+ /**
413
+ * Project the SDK's `MutationOperation[]` onto the canonical wire
414
+ * `CommitMessage`. This is the single serialize boundary between the SDK op
415
+ * type (loose `type: string`, plus an SDK-internal `options` the server never
416
+ * reads) and the strict wire contract. The per-field map gives compile-time
417
+ * drift detection (a `CommitOperation` shape change breaks here) and the lone
418
+ * `as` narrows the validated op `type` to the wire union — the only
419
+ * loosening, localized to this boundary.
420
+ */
421
+ private buildCommitFrame;
425
422
  /**
426
423
  * Send a `commit` mutation request over the existing WebSocket and
427
424
  * resolve when the server's `mutation_result` frame comes back with
@@ -441,24 +438,7 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
441
438
  * NOT auto-retry here — the caller's TransactionQueue owns retry +
442
439
  * offline replay semantics and the SDK shouldn't duplicate that logic.
443
440
  */
444
- sendCommit(operations: ReadonlyArray<{
445
- type: string;
446
- model: string;
447
- id: string;
448
- input?: Record<string, unknown>;
449
- /**
450
- * Per-op client transaction id. The server stamps this onto
451
- * `sync_deltas.transaction_id` so the originating client
452
- * recognizes the broadcast as an echo of its own optimistic
453
- * mutation (echo detection in `SyncClient.applyDeltaBatchToPool`).
454
- * Distinct from the batch-level `clientTxId` argument below
455
- * (which keys `mutation_log` for retry idempotency). See
456
- * `apps/sync-server/docs/OPTIMISTIC_RECONCILIATION.md`.
457
- */
458
- transactionId?: string;
459
- readAt?: number | null;
460
- onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
461
- }>, clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null): Promise<{
441
+ sendCommit(operations: ReadonlyArray<MutationOperation>, clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null): Promise<{
462
442
  lastSyncId: number;
463
443
  }>;
464
444
  /**
@@ -469,15 +449,7 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
469
449
  * eventual `mutation_result` frame is intentionally ignored by this
470
450
  * instance because no pending resolver is registered.
471
451
  */
472
- sendCommitQueued(operations: ReadonlyArray<{
473
- type: string;
474
- model: string;
475
- id: string;
476
- input?: Record<string, unknown>;
477
- transactionId?: string;
478
- readAt?: number | null;
479
- onStale?: 'reject' | 'force' | 'flag' | 'merge' | null;
480
- }>, clientTxId: string, causedByTaskId?: string | null): void;
452
+ sendCommitQueued(operations: ReadonlyArray<MutationOperation>, clientTxId: string, causedByTaskId?: string | null): void;
481
453
  /**
482
454
  * Activate a participant claim on this connection. Multiplexed
483
455
  * subscription pattern (Phoenix Channels / Pusher) — the same
@@ -514,15 +486,19 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
514
486
  */
515
487
  sendRelease(claimId: string): void;
516
488
  /**
517
- * Replace the capability token used for authentication. The new
518
- * value is read by the next URL-build (i.e., next connect / reconnect
519
- * cycle). The currently-open WS is NOT torn down — servers keep
520
- * connections alive past cap expiry until they decide to close, and
521
- * a forced reconnect would interrupt in-flight deltas. The cap-mint
522
- * scheduler in `Ablo.ts` calls this on each successful refresh so
523
- * reconnects after server-initiated close pick up the fresh token.
489
+ * Compatibility setter for direct SyncWebSocket users. The SDK-owned
490
+ * `Ablo()` path passes `getAuthToken`, so reconnect URL auth reads the
491
+ * shared credential source instead of this copied value.
524
492
  */
525
493
  setCapabilityToken(token: string): void;
494
+ getAuthToken(): string | undefined;
495
+ /**
496
+ * Return the credential that will be used by the next WebSocket upgrade.
497
+ * ConnectionManager reads this for HTTP auth probes so visibility/network
498
+ * checks authenticate the same way reconnects do.
499
+ */
500
+ getCapabilityToken(): string | undefined;
501
+ private resolveAuthToken;
526
502
  /**
527
503
  * Send spreadsheet selection presence
528
504
  */
@@ -692,4 +668,3 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
692
668
  */
693
669
  private handlePresenceUpdate;
694
670
  }
695
- export {};
@@ -11,6 +11,7 @@ import { EventEmitter } from 'events';
11
11
  import { getContext } from '../context.js';
12
12
  import { flushOfflineQueueOnce } from './OfflineFlush.js';
13
13
  import { AbloConnectionError, AbloError, CapabilityError, SyncSessionError, errorFromWire, toAbloError, } from '../errors.js';
14
+ import { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL, } from '../auth/credentialSource.js';
14
15
  // ---------------------------------------------------------------------------
15
16
  // Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
16
17
  // Consumers pass their own event types as TCollaboration generic parameter.
@@ -170,16 +171,10 @@ export class SyncWebSocket extends EventEmitter {
170
171
  }
171
172
  this.isConnecting = true;
172
173
  this.isManualClose = false;
173
- // Pattern: one credential, server-resolved identity. The WS URL
174
- // carries the credential (cap-token bearer in `?authorization=`
175
- // for the cap path; session cookie in headers for the cookie
176
- // path). The server's AuthProvider chain (`apiKeyProvider
177
- // agentTokenProvider → betterAuthProvider`) resolves identity
178
- // from the verified credential — userId/organizationId are
179
- // NEVER read from URL params in production. See
180
- // `apps/sync-server/src/auth/provider.ts:148` (betterAuthProvider
181
- // calls `auth.api.getSession({headers})`) and `agentTokenProvider`
182
- // for the cap-token path.
174
+ // Pattern: one credential, server-resolved identity. The bearer travels
175
+ // in a `Sec-WebSocket-Protocol` value (built below), NOT the URL. The
176
+ // server is bearer-only (`apiKeyProvider`) and resolves identity from the
177
+ // verified token userId/organizationId are NEVER read from URL params.
183
178
  const params = new URLSearchParams({
184
179
  // Intentionally omit lastSyncId, versions, capabilities from URL; these are sent in sync_request
185
180
  // and ack messages to avoid stale baselines on reconnect.
@@ -191,22 +186,28 @@ export class SyncWebSocket extends EventEmitter {
191
186
  if (this.options.kind && this.options.kind !== 'user') {
192
187
  params.set('kind', this.options.kind);
193
188
  }
194
- // Capability bearer (query-param form so it works in both Node's
195
- // global WebSocket — which can't set headers — and browsers).
196
- if (this.options.capabilityToken) {
197
- params.set('authorization', `Bearer ${this.options.capabilityToken}`);
198
- }
199
189
  // Add sync groups if provided
200
190
  this.options.syncGroups.forEach((group) => {
201
191
  params.append('syncGroups', group);
202
192
  });
203
193
  const wsUrl = `${this.options.url}?${params.toString()}`;
194
+ // Carry the bearer in a `Sec-WebSocket-Protocol` value, NOT the URL. A
195
+ // browser can't set an Authorization header on a WS, but it CAN offer
196
+ // subprotocols — and unlike the query string, those don't land in ALB
197
+ // access logs, proxies, or browser history. The server reads
198
+ // `ablo.bearer.<token>` and selects the real `ablo.sync.v1` protocol,
199
+ // never echoing the token-bearing value back. (Token is the raw ek_/rk_,
200
+ // which is subprotocol-token-safe — alphanumerics + `_`.)
201
+ const authToken = this.resolveAuthToken();
202
+ const protocols = authToken
203
+ ? [`${WS_BEARER_SUBPROTOCOL_PREFIX}${authToken}`, WS_SYNC_SUBPROTOCOL]
204
+ : [WS_SYNC_SUBPROTOCOL];
204
205
  try {
205
206
  // Reset the handshake flag before wiring the new socket. Each connect()
206
207
  // gets its own lifecycle — a prior successful open on a previous socket
207
208
  // must not mask a handshake failure on the new one.
208
209
  this._everOpened = false;
209
- this.ws = new WebSocket(wsUrl);
210
+ this.ws = new WebSocket(wsUrl, protocols);
210
211
  this.setupEventHandlers();
211
212
  }
212
213
  catch (error) {
@@ -303,11 +304,10 @@ export class SyncWebSocket extends EventEmitter {
303
304
  this.handlePresenceUpdate(message);
304
305
  break;
305
306
  case 'mutation_result': {
306
- // Ack for a prior `commit` we sent. Wire format (mirrors
307
- // apps/sync-server/src/hub/types.ts MutationResultMessage):
308
- // { type: 'mutation_result',
309
- // payload: { clientTxId, serverTxId, success,
310
- // lastSyncId?, error? } }
307
+ // Ack for a prior `commit` we sent. Canonical shape is
308
+ // `MutationResultMessage` in `@abloatai/ablo/wire`. This stays a
309
+ // DEFENSIVE parse (not a typed cast) because the payload is
310
+ // untrusted wire data that may be malformed or from an older server.
311
311
  const p = message.payload ?? message;
312
312
  const { clientTxId, success, lastSyncId, error } = p ?? {};
313
313
  const pending = typeof clientTxId === 'string'
@@ -720,6 +720,32 @@ export class SyncWebSocket extends EventEmitter {
720
720
  }
721
721
  }
722
722
  }
723
+ /**
724
+ * Project the SDK's `MutationOperation[]` onto the canonical wire
725
+ * `CommitMessage`. This is the single serialize boundary between the SDK op
726
+ * type (loose `type: string`, plus an SDK-internal `options` the server never
727
+ * reads) and the strict wire contract. The per-field map gives compile-time
728
+ * drift detection (a `CommitOperation` shape change breaks here) and the lone
729
+ * `as` narrows the validated op `type` to the wire union — the only
730
+ * loosening, localized to this boundary.
731
+ */
732
+ buildCommitFrame(operations, clientTxId, causedByTaskId) {
733
+ const payload = {
734
+ operations: operations.map((op) => ({
735
+ type: op.type,
736
+ model: op.model,
737
+ id: op.id,
738
+ input: op.input,
739
+ transactionId: op.transactionId,
740
+ readAt: op.readAt,
741
+ onStale: op.onStale,
742
+ })),
743
+ clientTxId,
744
+ };
745
+ if (causedByTaskId)
746
+ payload.causedByTaskId = causedByTaskId;
747
+ return { type: 'commit', payload };
748
+ }
723
749
  /**
724
750
  * Send a `commit` mutation request over the existing WebSocket and
725
751
  * resolve when the server's `mutation_result` frame comes back with
@@ -754,10 +780,8 @@ export class SyncWebSocket extends EventEmitter {
754
780
  // an open turn — keeps the wire shape stable for sessions
755
781
  // that don't use turns. Servers that don't know the field
756
782
  // ignore it; newer servers stamp it onto every delta.
757
- const payload = { operations, clientTxId };
758
- if (causedByTaskId)
759
- payload.causedByTaskId = causedByTaskId;
760
- this.ws.send(JSON.stringify({ type: 'commit', payload }));
783
+ const frame = this.buildCommitFrame(operations, clientTxId, causedByTaskId);
784
+ this.ws.send(JSON.stringify(frame));
761
785
  }
762
786
  catch (error) {
763
787
  clearTimeout(timeout);
@@ -778,10 +802,8 @@ export class SyncWebSocket extends EventEmitter {
778
802
  if (this.ws?.readyState !== WebSocket.OPEN) {
779
803
  throw this.notConnectedError('commit');
780
804
  }
781
- const payload = { operations, clientTxId };
782
- if (causedByTaskId)
783
- payload.causedByTaskId = causedByTaskId;
784
- this.ws.send(JSON.stringify({ type: 'commit', payload }));
805
+ const frame = this.buildCommitFrame(operations, clientTxId, causedByTaskId);
806
+ this.ws.send(JSON.stringify(frame));
785
807
  }
786
808
  /**
787
809
  * Activate a participant claim on this connection. Multiplexed
@@ -863,17 +885,29 @@ export class SyncWebSocket extends EventEmitter {
863
885
  }
864
886
  }
865
887
  /**
866
- * Replace the capability token used for authentication. The new
867
- * value is read by the next URL-build (i.e., next connect / reconnect
868
- * cycle). The currently-open WS is NOT torn down — servers keep
869
- * connections alive past cap expiry until they decide to close, and
870
- * a forced reconnect would interrupt in-flight deltas. The cap-mint
871
- * scheduler in `Ablo.ts` calls this on each successful refresh so
872
- * reconnects after server-initiated close pick up the fresh token.
888
+ * Compatibility setter for direct SyncWebSocket users. The SDK-owned
889
+ * `Ablo()` path passes `getAuthToken`, so reconnect URL auth reads the
890
+ * shared credential source instead of this copied value.
873
891
  */
874
892
  setCapabilityToken(token) {
875
893
  this.options.capabilityToken = token;
876
894
  }
895
+ getAuthToken() {
896
+ return this.resolveAuthToken();
897
+ }
898
+ /**
899
+ * Return the credential that will be used by the next WebSocket upgrade.
900
+ * ConnectionManager reads this for HTTP auth probes so visibility/network
901
+ * checks authenticate the same way reconnects do.
902
+ */
903
+ getCapabilityToken() {
904
+ return this.resolveAuthToken();
905
+ }
906
+ resolveAuthToken = () => {
907
+ return this.options.getAuthToken?.()
908
+ ?? this.options.getCapabilityToken?.()
909
+ ?? this.options.capabilityToken;
910
+ };
877
911
  /**
878
912
  * Send spreadsheet selection presence
879
913
  */
@@ -93,6 +93,9 @@ export function createIntentStream(config, transport = null) {
93
93
  // `settled()`. Absent status means active (wire back-compat).
94
94
  if (claim.status && claim.status !== 'active')
95
95
  continue;
96
+ const description = typeof claim.meta?.description === 'string'
97
+ ? claim.meta.description
98
+ : undefined;
96
99
  activeByIntentId.set(claim.intentId, {
97
100
  id: claim.intentId,
98
101
  heldBy: event.userId,
@@ -106,6 +109,7 @@ export function createIntentStream(config, transport = null) {
106
109
  meta: claim.meta,
107
110
  },
108
111
  reason: claim.action,
112
+ ...(description ? { description } : {}),
109
113
  ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
110
114
  announcedAt: new Date(claim.declaredAt).toISOString(),
111
115
  expiresAt: new Date(claim.expiresAt).toISOString(),
@@ -227,6 +231,11 @@ export function createIntentStream(config, transport = null) {
227
231
  },
228
232
  });
229
233
  }
234
+ function withDescription(meta, description) {
235
+ if (!description)
236
+ return meta;
237
+ return { ...(meta ?? {}), description };
238
+ }
230
239
  function mintHandle(args) {
231
240
  const intentId = crypto.randomUUID();
232
241
  const estimatedMs = args.ttl !== undefined ? toMs(args.ttl) : undefined;
@@ -273,7 +282,7 @@ export function createIntentStream(config, transport = null) {
273
282
  path: resolved.path,
274
283
  range: resolved.range,
275
284
  field: resolved.field,
276
- meta: resolved.meta,
285
+ meta: withDescription(resolved.meta, opts?.description),
277
286
  action: opts?.reason ?? 'editing',
278
287
  ttl: opts?.ttl,
279
288
  queue: opts?.queue,
@@ -340,6 +340,12 @@ export interface ClaimOptions extends IntentOptions {
340
340
  * app-specific phases.
341
341
  */
342
342
  readonly reason?: string;
343
+ /**
344
+ * Peer-visible explanation of the exact work being performed. This is more
345
+ * specific than `reason`: `reason` is the phase (`'renaming'`), while
346
+ * `description` is the instruction other agents should see.
347
+ */
348
+ readonly description?: string;
343
349
  /**
344
350
  * Join the server's fair FIFO queue on contention instead of being
345
351
  * rejected. The grant arrives asynchronously (`intent_acquired` if the
@@ -528,6 +534,7 @@ export interface ActiveIntent extends IntentDeclaration {
528
534
  * from "user editing X" without string-parsing `heldBy`.
529
535
  */
530
536
  readonly participantKind: 'human' | 'agent';
537
+ readonly description?: string;
531
538
  readonly announcedAt: string;
532
539
  readonly expiresAt: string;
533
540
  }
@@ -565,6 +572,8 @@ export interface Intent {
565
572
  readonly target: EntityRef;
566
573
  /** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. */
567
574
  readonly action: string;
575
+ /** Peer-visible explanation of the work being performed. */
576
+ readonly description?: string;
568
577
  /** Participant holding it. */
569
578
  readonly heldBy: string;
570
579
  readonly participantKind: 'human' | 'agent';
@@ -146,6 +146,7 @@ export function M1(target, propertyMetadata, referenceMetadata) {
146
146
  'markAsPersisted',
147
147
  'clearChanges',
148
148
  'updateFromData',
149
+ 'applyChanges',
149
150
  ];
150
151
  for (const methodName of actionMethods) {
151
152
  if (typeof Reflect.get(target, methodName) === 'function') {