@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.
- package/CHANGELOG.md +32 -0
- package/README.md +54 -45
- package/dist/BaseSyncedStore.js +7 -3
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +3 -2
- package/dist/auth/index.js +39 -11
- package/dist/client/Ablo.d.ts +111 -3
- package/dist/client/Ablo.js +143 -10
- package/dist/client/ApiClient.d.ts +32 -0
- package/dist/client/ApiClient.js +76 -44
- package/dist/client/auth.d.ts +11 -1
- package/dist/client/auth.js +21 -2
- package/dist/client/createModelProxy.d.ts +107 -63
- package/dist/client/createModelProxy.js +65 -33
- package/dist/client/identity.js +14 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +57 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/errorCodes.d.ts +23 -1
- package/dist/errorCodes.js +34 -1
- package/dist/errors.d.ts +52 -1
- package/dist/errors.js +140 -42
- package/dist/index.d.ts +9 -5
- package/dist/index.js +9 -5
- package/dist/keys/index.d.ts +61 -0
- package/dist/keys/index.js +151 -0
- package/dist/query/client.js +19 -8
- package/dist/react/AbloProvider.d.ts +25 -0
- package/dist/react/AbloProvider.js +97 -2
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/serialize.d.ts +3 -3
- package/dist/schema/serialize.js +2 -2
- package/dist/sync/BootstrapHelper.js +46 -27
- package/dist/sync/ConnectionManager.d.ts +3 -1
- package/dist/sync/ConnectionManager.js +37 -1
- package/dist/sync/HydrationCoordinator.js +3 -2
- package/dist/sync/NetworkProbe.d.ts +8 -0
- package/dist/sync/NetworkProbe.js +24 -2
- package/dist/sync/SyncWebSocket.d.ts +1 -1
- package/dist/sync/SyncWebSocket.js +43 -53
- package/dist/sync/participants.js +5 -2
- package/dist/transactions/TransactionQueue.js +13 -1
- package/docs/api-keys.md +5 -5
- package/docs/api.md +101 -44
- package/docs/audit.md +16 -9
- package/docs/cli.md +27 -17
- package/docs/client-behavior.md +34 -20
- package/docs/coordination.md +40 -51
- package/docs/data-sources.md +21 -19
- package/docs/examples/agent-human.md +72 -28
- package/docs/examples/ai-sdk-tool.md +14 -11
- package/docs/examples/existing-python-backend.md +27 -16
- package/docs/examples/nextjs.md +21 -8
- package/docs/examples/scoped-agent.md +42 -27
- package/docs/examples/server-agent.md +27 -5
- package/docs/guarantees.md +26 -17
- package/docs/identity.md +65 -59
- package/docs/index.md +30 -19
- package/docs/integration-guide.md +52 -52
- package/docs/interaction-model.md +38 -26
- package/docs/mcp/claude-code.md +9 -17
- package/docs/mcp/cursor.md +6 -24
- package/docs/mcp/windsurf.md +6 -19
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +31 -39
- package/docs/react.md +15 -11
- package/docs/roadmap.md +13 -13
- package/docs/schema-contract.md +109 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +6 -2
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +27 -16
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
A callable `claim` coordination namespace and bring-your-own-database support
|
|
6
|
+
via a new `databaseUrl` option.
|
|
7
|
+
|
|
8
|
+
### Minor Changes
|
|
9
|
+
|
|
10
|
+
- **Callable `claim` coordination namespace.** Taking a claim and inspecting its
|
|
11
|
+
state now live under one accessor: `claim(id, work)` acquires a claim and runs
|
|
12
|
+
`work` while it's held, and `claim.state(id)`, `claim.queue(id)`,
|
|
13
|
+
`claim.release(id)`, and `claim.reorder(id, order)` cover the surrounding
|
|
14
|
+
lifecycle. The README leads with the problem (who is allowed to act, and in
|
|
15
|
+
what order) and the Quick Start now demonstrates `claim` directly.
|
|
16
|
+
|
|
17
|
+
- **Bring-your-own-database via `databaseUrl`.** Point a project at your own
|
|
18
|
+
Postgres with `Ablo({ schema, apiKey, databaseUrl })`. Ablo writes synced rows
|
|
19
|
+
back into your database, so your data stays canonical. Server-side only;
|
|
20
|
+
defaults to `process.env.DATABASE_URL`. See the data-sources guide for setup
|
|
21
|
+
and role requirements.
|
|
22
|
+
|
|
23
|
+
### Breaking
|
|
24
|
+
|
|
25
|
+
- The flat coordination methods `claimState`, `queue`, `release`, and `reorder`
|
|
26
|
+
are removed in favor of the `claim` namespace above.
|
|
27
|
+
|
|
28
|
+
```diff
|
|
29
|
+
- await ablo.task.claimState(id)
|
|
30
|
+
- await ablo.task.release(id)
|
|
31
|
+
+ await ablo.task.claim.state(id)
|
|
32
|
+
+ await ablo.task.claim.release(id)
|
|
33
|
+
```
|
|
34
|
+
|
|
3
35
|
## 0.7.0
|
|
4
36
|
|
|
5
37
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -5,35 +5,40 @@
|
|
|
5
5
|
[](#)
|
|
6
6
|
[](#keys--runtime)
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
server code, and AI agents all edit at once.
|
|
8
|
+
**Let people and AI agents work on the same data without overwriting each other.**
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
silently
|
|
13
|
-
|
|
10
|
+
When an agent and a person change the same thing at once, work gets lost: one
|
|
11
|
+
edit silently clobbers another, or the agent acts on data that already moved.
|
|
12
|
+
Ablo gives them one shared, typed write path so people, server actions, and
|
|
13
|
+
agents can all work on the same rows without working blind.
|
|
14
|
+
|
|
15
|
+
The core idea is a **claim**. An agent's work is rarely one instant write; it
|
|
16
|
+
reads something, thinks, calls an LLM or tool, then writes back. While that is
|
|
17
|
+
happening, the row can change underneath it. So before slow work starts, the
|
|
18
|
+
agent claims the row. If someone else is already working on it, `claim` waits,
|
|
19
|
+
re-reads the fresh row, then hands it over. No stale overwrite, no separate
|
|
20
|
+
agent mutation path.
|
|
21
|
+
|
|
22
|
+
Under the hood, you define a Zod schema once and get typed model clients for
|
|
23
|
+
every actor:
|
|
14
24
|
|
|
15
25
|
```txt
|
|
16
26
|
schema -> ablo.<model>.create/retrieve/update/claim(...)
|
|
17
27
|
```
|
|
18
28
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
groups* from your existing identity, and can leave your database as the source
|
|
33
|
-
of truth via a Data Source.
|
|
34
|
-
|
|
35
|
-
**Built for:** collaborative editors, AI agent workflows, internal tools, and any
|
|
36
|
-
app where multiple actors mutate shared state and everyone must see it live.
|
|
29
|
+
The schema is the public contract. It gives you typed model methods, realtime
|
|
30
|
+
fanout, React selectors, agent writes, and the HTTP/Data Source shape for
|
|
31
|
+
non-JavaScript services. Every confirmed change shows up everywhere, and active
|
|
32
|
+
claims are visible while the work is still in progress.
|
|
33
|
+
|
|
34
|
+
[Get started ↓](#quick-start) · point your coding agent at the shipped `llms.txt`
|
|
35
|
+
|
|
36
|
+
It works with the auth and database you already have: realtime data is scoped to
|
|
37
|
+
*sync groups* from your own identity, and your database can stay the source of
|
|
38
|
+
truth via a Data Source.
|
|
39
|
+
|
|
40
|
+
**Built for** collaborative editors, AI agent workflows, and internal tools —
|
|
41
|
+
anywhere people and agents change shared state and everyone has to see it live.
|
|
37
42
|
|
|
38
43
|
## Set up
|
|
39
44
|
|
|
@@ -83,12 +88,16 @@ const created = await ablo.weatherReports.create({
|
|
|
83
88
|
status: 'pending',
|
|
84
89
|
});
|
|
85
90
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
// An agent claims the row, does its slow work, then writes back. While the
|
|
92
|
+
// claim is held nobody else can overwrite it; anyone else who tries waits in
|
|
93
|
+
// line and re-reads the result. This is the whole point of Ablo.
|
|
94
|
+
await ablo.weatherReports.claim(created.id, async (report) => {
|
|
95
|
+
const forecast = await fetchForecast(report.location); // slow: API or LLM call
|
|
96
|
+
await ablo.weatherReports.update(report.id, { status: 'ready', forecast });
|
|
89
97
|
});
|
|
90
98
|
|
|
91
|
-
|
|
99
|
+
const ready = ablo.weatherReports.get(created.id);
|
|
100
|
+
console.log({ id: ready.id, status: ready.status });
|
|
92
101
|
|
|
93
102
|
await ablo.dispose();
|
|
94
103
|
```
|
|
@@ -99,31 +108,30 @@ Expected output:
|
|
|
99
108
|
{ id: '...', status: 'ready' }
|
|
100
109
|
```
|
|
101
110
|
|
|
102
|
-
Pass `schema` to get typed models like `ablo.weatherReports.update(...)`.
|
|
103
|
-
|
|
104
111
|
## Reading
|
|
105
112
|
|
|
106
|
-
|
|
107
|
-
`
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
Two ways to read, depending on whether you can wait. `get(id)` / `getAll({ where })`
|
|
114
|
+
/ `getCount({ where })` are instant — they read what's already local and re-render
|
|
115
|
+
on their own when it changes, so they're what your UI uses. `retrieve(id)` /
|
|
116
|
+
`list({ where })` go ask the server and return a `Promise`, for when you need the
|
|
117
|
+
authoritative answer right now.
|
|
110
118
|
|
|
111
119
|
```ts
|
|
112
|
-
ablo.weatherReports.
|
|
120
|
+
ablo.weatherReports.get('report_stockholm');
|
|
113
121
|
|
|
114
|
-
const pending = ablo.weatherReports.
|
|
122
|
+
const pending = ablo.weatherReports.getAll({
|
|
115
123
|
where: { status: 'pending' },
|
|
116
124
|
orderBy: { location: 'asc' },
|
|
117
125
|
limit: 20,
|
|
118
126
|
});
|
|
119
127
|
|
|
120
|
-
const ready = await ablo.weatherReports.
|
|
128
|
+
const ready = await ablo.weatherReports.list({
|
|
121
129
|
where: { status: 'ready' },
|
|
122
130
|
type: 'complete',
|
|
123
131
|
});
|
|
124
132
|
```
|
|
125
133
|
|
|
126
|
-
An array value in `where` means `IN`. On `
|
|
134
|
+
An array value in `where` means `IN`. On `list`, `type: 'complete'` waits for
|
|
127
135
|
the server; `'unknown'` returns what's local now and refreshes in the background.
|
|
128
136
|
|
|
129
137
|
## Writing
|
|
@@ -163,8 +171,8 @@ returns or throws.
|
|
|
163
171
|
See who's mid-edit before you act — decide to wait, or skip:
|
|
164
172
|
|
|
165
173
|
```ts
|
|
166
|
-
ablo.weatherReports.
|
|
167
|
-
ablo.weatherReports.queue('report_stockholm');
|
|
174
|
+
ablo.weatherReports.claim.state('report_stockholm');
|
|
175
|
+
ablo.weatherReports.claim.queue('report_stockholm');
|
|
168
176
|
|
|
169
177
|
await ablo.weatherReports.claim(id, async (report) => {
|
|
170
178
|
/* do the held work */
|
|
@@ -175,7 +183,7 @@ await ablo.weatherReports.claim(id, async (report) => {
|
|
|
175
183
|
}, { maxQueueDepth: 2 });
|
|
176
184
|
```
|
|
177
185
|
|
|
178
|
-
`
|
|
186
|
+
`claim.state` returns the holder (or `null`); `claim.queue` returns the line waiting
|
|
179
187
|
behind it. `wait: false` skips rather than waiting when the row is held;
|
|
180
188
|
`maxQueueDepth: 2` bails when two or more are already ahead.
|
|
181
189
|
|
|
@@ -195,8 +203,8 @@ try {
|
|
|
195
203
|
> Prefer the callback form for ordinary held work. Manual scoped claims are
|
|
196
204
|
> available for wider lifetimes, but callback claims are the docs default.
|
|
197
205
|
|
|
198
|
-
See [Coordination](./docs/coordination.md) for the full `claim` / `
|
|
199
|
-
`queue` / `release` reference.
|
|
206
|
+
See [Coordination](./docs/coordination.md) for the full `claim` / `claim.state` /
|
|
207
|
+
`claim.queue` / `claim.release` reference.
|
|
200
208
|
|
|
201
209
|
## React
|
|
202
210
|
|
|
@@ -217,7 +225,7 @@ function App() {
|
|
|
217
225
|
}
|
|
218
226
|
|
|
219
227
|
function Report({ id }: { id: string }) {
|
|
220
|
-
const report = useAblo((ablo) => ablo.weatherReports.
|
|
228
|
+
const report = useAblo((ablo) => ablo.weatherReports.get(id));
|
|
221
229
|
const ablo = useAblo();
|
|
222
230
|
|
|
223
231
|
if (!report) return null;
|
|
@@ -277,7 +285,7 @@ each other's changes in real time — that's the default, not a feature you turn
|
|
|
277
285
|
|
|
278
286
|
- `ablo.<model>.create/update/delete` fan out confirmed deltas to subscribers.
|
|
279
287
|
- `useAblo(...)` gives React clients the live row, kept current automatically.
|
|
280
|
-
- `ablo.<model>.claim(id)` / `
|
|
288
|
+
- `ablo.<model>.claim(id)` / `claim.state(id)` / `queue(id)` let humans and agents coordinate (and observe) active work on a row — and the line waiting behind it — before a write lands.
|
|
281
289
|
|
|
282
290
|
Always write through Ablo — either the SDK model methods
|
|
283
291
|
(`ablo.<model>.create/update/delete`) or the HTTP write endpoint below. If you
|
|
@@ -372,10 +380,11 @@ contract; there are no retry or timeout knobs to tune.
|
|
|
372
380
|
## Production Reference
|
|
373
381
|
|
|
374
382
|
- [Identity & Sync Groups](./docs/identity.md) — bring your own auth; tell Ablo who's connecting and how org / team / user map to sync-group scope.
|
|
383
|
+
- [Schema Contract](./docs/schema-contract.md) — one schema becomes typed model clients, React reads, agent writes, Data Source shape, and schema push.
|
|
375
384
|
- [Guarantees](./docs/guarantees.md) — confirmed writes, stale-write protection, claim coordination, and agent lifecycle.
|
|
376
385
|
- [Integration Guide](./docs/integration-guide.md) — pick the backing mode and integrate React, Data Source, multiplayer, and agents.
|
|
377
386
|
- [React](./docs/react.md) — `<AbloProvider>`, `useAblo`, presence, status, and bootstrap gating.
|
|
378
|
-
- [Coordination](./docs/coordination.md) — `claim` / `
|
|
387
|
+
- [Coordination](./docs/coordination.md) — `claim` / `claim.state` / `claim.queue` / `claim.release` reference: hold a row across slow agent work, and observe the line waiting behind it.
|
|
379
388
|
- [Client Behavior](./docs/client-behavior.md) — options, errors, retries, timeouts, and public imports.
|
|
380
389
|
- [Connect Your Database](./docs/data-sources.md) — keep canonical rows in your app database without giving Ablo database credentials.
|
|
381
390
|
- [Existing Python Backend](./docs/examples/existing-python-backend.md) — migrate existing Python endpoints to multiplayer and agent-safe writes gradually.
|
package/dist/BaseSyncedStore.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* pull generic methods into this base class.
|
|
13
13
|
*/
|
|
14
14
|
import { makeObservable, observable, computed, runInAction } from 'mobx';
|
|
15
|
-
import { AbloConnectionError, AbloValidationError } from './errors.js';
|
|
15
|
+
import { AbloConnectionError, AbloValidationError, toAbloError } from './errors.js';
|
|
16
16
|
import { ConnectionManager } from './sync/ConnectionManager.js';
|
|
17
17
|
import { PropertyType } from './types/index.js';
|
|
18
18
|
import { SyncWebSocket, } from './sync/SyncWebSocket.js';
|
|
@@ -447,14 +447,18 @@ export class BaseSyncedStore {
|
|
|
447
447
|
}
|
|
448
448
|
}
|
|
449
449
|
}
|
|
450
|
-
throw lastError
|
|
450
|
+
throw lastError
|
|
451
|
+
? toAbloError(lastError)
|
|
452
|
+
: new AbloConnectionError('Bootstrap failed after all retry attempts', {
|
|
453
|
+
code: 'bootstrap_fetch_timeout',
|
|
454
|
+
});
|
|
451
455
|
}
|
|
452
456
|
/** Create a timeout promise for bootstrap attempts */
|
|
453
457
|
createBootstrapTimeout(attempt) {
|
|
454
458
|
const timeoutMs = BOOTSTRAP_CONFIG.OVERALL_TIMEOUT_MS + (attempt - 1) * 3_000;
|
|
455
459
|
return new Promise((_, reject) => {
|
|
456
460
|
setTimeout(() => {
|
|
457
|
-
reject(new
|
|
461
|
+
reject(new AbloConnectionError(`Bootstrap timed out after ${timeoutMs}ms (attempt ${attempt})`, { code: 'bootstrap_fetch_timeout' }));
|
|
458
462
|
}, timeoutMs);
|
|
459
463
|
});
|
|
460
464
|
}
|
|
@@ -33,7 +33,8 @@ export declare const noopObservability: SyncObservabilityProvider;
|
|
|
33
33
|
export declare const noopAnalytics: SyncAnalytics;
|
|
34
34
|
/** Browser-native online status provider */
|
|
35
35
|
export declare const browserOnlineStatus: OnlineStatusProvider;
|
|
36
|
-
/**
|
|
36
|
+
/** Session error detector — delegates to SyncSessionError so detection is
|
|
37
|
+
* code-aware (only genuine session/JWT expiry counts), not a blunt 401/403. */
|
|
37
38
|
export declare const defaultSessionErrorDetector: SessionErrorDetector;
|
|
38
39
|
/**
|
|
39
40
|
* Fallback config used when the context is read before
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* All SDK classes receive this context at construction time.
|
|
5
5
|
* It bundles every injectable dependency so constructors stay clean.
|
|
6
6
|
*/
|
|
7
|
+
import { SyncSessionError } from './errors.js';
|
|
7
8
|
// ─────────────────────────────────────────────
|
|
8
9
|
// No-op defaults for optional dependencies
|
|
9
10
|
// ─────────────────────────────────────────────
|
|
@@ -45,7 +46,8 @@ export const browserOnlineStatus = {
|
|
|
45
46
|
return typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
46
47
|
},
|
|
47
48
|
};
|
|
48
|
-
/**
|
|
49
|
+
/** Session error detector — delegates to SyncSessionError so detection is
|
|
50
|
+
* code-aware (only genuine session/JWT expiry counts), not a blunt 401/403. */
|
|
49
51
|
export const defaultSessionErrorDetector = {
|
|
50
52
|
isSessionError(error) {
|
|
51
53
|
if (error && typeof error === 'object' && 'isSessionError' in error) {
|
|
@@ -53,8 +55,8 @@ export const defaultSessionErrorDetector = {
|
|
|
53
55
|
}
|
|
54
56
|
return false;
|
|
55
57
|
},
|
|
56
|
-
isSessionErrorResponse(status) {
|
|
57
|
-
return status
|
|
58
|
+
isSessionErrorResponse(status, body) {
|
|
59
|
+
return SyncSessionError.isSessionErrorResponse(status, body);
|
|
58
60
|
},
|
|
59
61
|
};
|
|
60
62
|
/**
|
package/dist/agent/session.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* The helper itself imports nothing app-specific. Open-source-clean.
|
|
21
21
|
*/
|
|
22
22
|
import { Ablo } from '../client/Ablo.js';
|
|
23
|
+
import { AbloConnectionError } from '../errors.js';
|
|
23
24
|
/**
|
|
24
25
|
* Returns a session whose `getAgent` method handles cache, mint,
|
|
25
26
|
* sync_groups alignment, and lifecycle. Call `disposeAll()` from
|
|
@@ -113,8 +114,8 @@ export function createAgentSession(options) {
|
|
|
113
114
|
causeMsg,
|
|
114
115
|
err,
|
|
115
116
|
});
|
|
116
|
-
throw new
|
|
117
|
-
(code ? ` (${code})` : ''));
|
|
117
|
+
throw new AbloConnectionError(`ws bootstrap ${wsUrl} failed: ${e.message ?? 'bootstrap failed'}` +
|
|
118
|
+
(code ? ` (${code})` : ''), { code: 'bootstrap_fetch_timeout', cause: err });
|
|
118
119
|
}
|
|
119
120
|
cacheByKey.set(key, { agent, expiresAtMs: minted.expiresAtMs });
|
|
120
121
|
return agent;
|
package/dist/auth/index.js
CHANGED
|
@@ -11,7 +11,25 @@
|
|
|
11
11
|
* SDKs hide their internal auth-handshake — the apiKey is the only
|
|
12
12
|
* credential the consumer touches.
|
|
13
13
|
*/
|
|
14
|
-
import { AbloAuthenticationError } from '../errors.js';
|
|
14
|
+
import { AbloAuthenticationError, translateHttpError } from '../errors.js';
|
|
15
|
+
/**
|
|
16
|
+
* Whether an HTTP error body carries a code `translateHttpError` can read —
|
|
17
|
+
* a top-level `code`, a nested `error.code`, or a string `error`. When it
|
|
18
|
+
* doesn't (empty body, non-JSON, or a non-Ablo proxy 401), the caller falls
|
|
19
|
+
* back to its own default code rather than emitting a code-less error.
|
|
20
|
+
*/
|
|
21
|
+
function hasWireCode(body) {
|
|
22
|
+
if (typeof body !== 'object' || body === null)
|
|
23
|
+
return false;
|
|
24
|
+
const b = body;
|
|
25
|
+
if (typeof b.code === 'string')
|
|
26
|
+
return true;
|
|
27
|
+
if (typeof b.error === 'string')
|
|
28
|
+
return true;
|
|
29
|
+
return (typeof b.error === 'object' &&
|
|
30
|
+
b.error !== null &&
|
|
31
|
+
typeof b.error.code === 'string');
|
|
32
|
+
}
|
|
15
33
|
export async function exchangeApiKey(options) {
|
|
16
34
|
if (!options.apiKey) {
|
|
17
35
|
throw new AbloAuthenticationError('apiKey is required for capability exchange', { code: 'apikey_missing' });
|
|
@@ -59,11 +77,16 @@ export async function exchangeApiKey(options) {
|
|
|
59
77
|
catch {
|
|
60
78
|
// ignore — server returned non-JSON error
|
|
61
79
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
// Route through the canonical wire-error translator so the server's
|
|
81
|
+
// envelope (`code` + `message` + `doc_url`) propagates verbatim and maps to
|
|
82
|
+
// the right AbloError subclass — instead of the legacy `error`/`reason`
|
|
83
|
+
// shape this used to read (which the server no longer emits, collapsing
|
|
84
|
+
// every failure to a generic code with an empty message). Fall back to
|
|
85
|
+
// `exchange_failed` only when the body carried no recognizable code.
|
|
86
|
+
const requestId = response.headers.get('x-request-id') ?? undefined;
|
|
87
|
+
throw hasWireCode(body)
|
|
88
|
+
? translateHttpError(response.status, body, requestId)
|
|
89
|
+
: new AbloAuthenticationError(`apiKey exchange rejected (${response.status})`, { code: 'exchange_failed', httpStatus: response.status });
|
|
67
90
|
}
|
|
68
91
|
const raw = (await response.json());
|
|
69
92
|
if (!isCapabilityExchangeResponse(raw)) {
|
|
@@ -130,11 +153,16 @@ export async function resolveIdentity(options) {
|
|
|
130
153
|
catch {
|
|
131
154
|
// ignore non-JSON auth errors
|
|
132
155
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
156
|
+
// Canonical envelope translation (see `exchangeApiKey` above). This is what
|
|
157
|
+
// surfaces the sync-server's precise auth diagnosis — e.g.
|
|
158
|
+
// `jwt_issuer_untrusted` with its full message — to the SDK consumer,
|
|
159
|
+
// instead of collapsing every 401 to `identity_resolve_failed` with an
|
|
160
|
+
// empty reason because the old parser looked for `error`/`reason` keys the
|
|
161
|
+
// server doesn't emit.
|
|
162
|
+
const requestId = response.headers.get('x-request-id') ?? undefined;
|
|
163
|
+
throw hasWireCode(body)
|
|
164
|
+
? translateHttpError(response.status, body, requestId)
|
|
165
|
+
: new AbloAuthenticationError(`identity resolve rejected (${response.status})`, { code: 'identity_resolve_failed', httpStatus: response.status });
|
|
138
166
|
}
|
|
139
167
|
return (await response.json());
|
|
140
168
|
}
|
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -88,6 +88,21 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
88
88
|
* usually don't pass this explicitly server-side.
|
|
89
89
|
*/
|
|
90
90
|
apiKey?: string | ApiKeySetter | null | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* Connection string to YOUR OWN Postgres. When set, Ablo registers this
|
|
93
|
+
* database as your project's data store and writes synced rows back into it
|
|
94
|
+
* (dedicated/BYO tenant), so your data stays canonical in your DB while Ablo
|
|
95
|
+
* runs the sync/coordination plane. Defaults to `process.env['DATABASE_URL']`.
|
|
96
|
+
*
|
|
97
|
+
* SERVER-ONLY: this carries credentials, so it is never sent from the browser
|
|
98
|
+
* — constructing a client with `databaseUrl` and `dangerouslyAllowBrowser`
|
|
99
|
+
* throws. Provide Ablo a NON-superuser, non-`BYPASSRLS` role: the server runs
|
|
100
|
+
* the tenant plane with row-level security forced, and rejects a privileged
|
|
101
|
+
* role that couldn't enforce isolation.
|
|
102
|
+
*
|
|
103
|
+
* Omit it to use Ablo-managed storage (the hosted default).
|
|
104
|
+
*/
|
|
105
|
+
databaseUrl?: string | null | undefined;
|
|
91
106
|
/**
|
|
92
107
|
* Local persistence mode. Pass `indexeddb` only when you want offline
|
|
93
108
|
* queueing and a reload-surviving browser cache.
|
|
@@ -112,8 +127,8 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
112
127
|
defaultQuery?: Record<string, string | undefined> | undefined;
|
|
113
128
|
/**
|
|
114
129
|
* Client-side use is disabled by default because private API keys should
|
|
115
|
-
* not ship to browsers. Set this only when
|
|
116
|
-
*
|
|
130
|
+
* not ship to browsers. Set this only when the browser holds a minted
|
|
131
|
+
* session token (`ek_`/`rk_`) or you route through a controlled server proxy.
|
|
117
132
|
*/
|
|
118
133
|
dangerouslyAllowBrowser?: boolean | undefined;
|
|
119
134
|
}
|
|
@@ -168,7 +183,7 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
168
183
|
* Client-side use of this SDK is disabled by default — your apiKey
|
|
169
184
|
* would ship to every visitor's network tab. Only set this to
|
|
170
185
|
* `true` if you've understood the risk and have appropriate
|
|
171
|
-
* mitigations (a
|
|
186
|
+
* mitigations (a minted session token, a server-side proxy, etc).
|
|
172
187
|
*/
|
|
173
188
|
dangerouslyAllowBrowser?: boolean | undefined;
|
|
174
189
|
/**
|
|
@@ -459,6 +474,72 @@ export interface ModelClient<T = Record<string, unknown>> {
|
|
|
459
474
|
update(id: string, data: Record<string, unknown>, options?: ModelMutationOptions): Promise<CommitReceipt>;
|
|
460
475
|
delete(id: string, options?: ModelMutationOptions): Promise<CommitReceipt>;
|
|
461
476
|
}
|
|
477
|
+
/** A single data operation a scoped **agent** session may perform on a model. */
|
|
478
|
+
export type SessionOperation = 'read' | 'create' | 'update' | 'delete';
|
|
479
|
+
/** Mint params for an **end-user** session — full data authority within the
|
|
480
|
+
* org (the Stripe `ephemeralKeys.create` / Supabase session shape). Mints an
|
|
481
|
+
* `ek_` token. `user.id` is your end user's external IdP id (becomes the
|
|
482
|
+
* session's `participantId`); Ablo does not model your users, so it's an
|
|
483
|
+
* honest string at the trust boundary. */
|
|
484
|
+
export interface CreateUserSessionParams {
|
|
485
|
+
/** Your end user. `id` becomes the token's `participantId`. */
|
|
486
|
+
user: {
|
|
487
|
+
id: string;
|
|
488
|
+
};
|
|
489
|
+
/** Sync groups this session may subscribe to. Omit to inherit the key's scope. */
|
|
490
|
+
syncGroups?: readonly string[];
|
|
491
|
+
/** Token lifetime in seconds. Defaults to 900 (15m, the Stripe ephemeral default). */
|
|
492
|
+
ttlSeconds?: number;
|
|
493
|
+
/** Opaque identity blob echoed back to the client as `ablo.user`. */
|
|
494
|
+
userMeta?: Record<string, unknown>;
|
|
495
|
+
agent?: never;
|
|
496
|
+
can?: never;
|
|
497
|
+
}
|
|
498
|
+
/** Mint params for a scoped **agent** session — mints a restricted `rk_` token
|
|
499
|
+
* gated to exactly the operations named in `can`. `can` is typed off your
|
|
500
|
+
* schema (no magic `'task.update'` strings): `{ Task: ['update'], Deck: ['read'] }`
|
|
501
|
+
* — the SDK serializes each entry to the wire allowlist (`task.update`). */
|
|
502
|
+
export interface CreateAgentSessionParams<S extends SchemaRecord> {
|
|
503
|
+
/** Your agent. `id` becomes the token's `participantId`. */
|
|
504
|
+
agent: {
|
|
505
|
+
id: string;
|
|
506
|
+
};
|
|
507
|
+
/** Per-model operation allowlist, typed against the schema's model names. */
|
|
508
|
+
can: {
|
|
509
|
+
[M in keyof S & string]?: readonly SessionOperation[];
|
|
510
|
+
};
|
|
511
|
+
/** Sync groups this session may subscribe to. Omit to inherit the key's scope. */
|
|
512
|
+
syncGroups?: readonly string[];
|
|
513
|
+
/** Token lifetime in seconds. Defaults to 900 (15m, the Stripe ephemeral default). */
|
|
514
|
+
ttlSeconds?: number;
|
|
515
|
+
/** Opaque identity blob echoed back to the client as `ablo.agent`. */
|
|
516
|
+
userMeta?: Record<string, unknown>;
|
|
517
|
+
user?: never;
|
|
518
|
+
}
|
|
519
|
+
/** Params for {@link Ablo.sessions}.create — a discriminated union: pass
|
|
520
|
+
* `{ user }` for a full-authority end-user session (`ek_`) or `{ agent, can }`
|
|
521
|
+
* for a scoped agent session (`rk_`). */
|
|
522
|
+
export type CreateSessionParams<S extends SchemaRecord> = CreateUserSessionParams | CreateAgentSessionParams<S>;
|
|
523
|
+
/** A minted end-user session token — the Stripe ephemeral-key / Supabase
|
|
524
|
+
* session resource. `token` is the secret the browser presents as its bearer. */
|
|
525
|
+
export interface AbloSession {
|
|
526
|
+
object: 'session';
|
|
527
|
+
/** Stable id of the minted credential (for revocation). */
|
|
528
|
+
id: string;
|
|
529
|
+
/** The short-lived `rk_` session token. Hand this to the user's browser. */
|
|
530
|
+
token: string;
|
|
531
|
+
/** ISO-8601 expiry. */
|
|
532
|
+
expiresAt: string;
|
|
533
|
+
organizationId: string;
|
|
534
|
+
scope: {
|
|
535
|
+
organizationId: string;
|
|
536
|
+
syncGroups: readonly string[];
|
|
537
|
+
operations: readonly string[];
|
|
538
|
+
participantKind: 'user' | 'agent' | 'system';
|
|
539
|
+
participantId: string;
|
|
540
|
+
};
|
|
541
|
+
userMeta: Record<string, unknown>;
|
|
542
|
+
}
|
|
462
543
|
/** The typed sync engine client — one property per model in the schema */
|
|
463
544
|
export type Ablo<S extends SchemaRecord> = {
|
|
464
545
|
readonly [K in keyof S & string]: ModelOperations<InferModel<Schema<S>, K>, InferCreate<Schema<S>, K>>;
|
|
@@ -502,6 +583,31 @@ export type Ablo<S extends SchemaRecord> = {
|
|
|
502
583
|
waitForFlush(timeoutMs?: number): Promise<void>;
|
|
503
584
|
/** Disconnect and clean up */
|
|
504
585
|
dispose(): Promise<void>;
|
|
586
|
+
/**
|
|
587
|
+
* Replace the bearer auth token used for the WebSocket upgrade and HTTP
|
|
588
|
+
* requests, WITHOUT tearing down the engine. Use to push a refreshed
|
|
589
|
+
* short-lived token (e.g. a 15m JWT) before it expires — `<AbloProvider>`'s
|
|
590
|
+
* `getToken` refresh loop calls this. Reuses the same rotation path as the
|
|
591
|
+
* internal capability-token refresh; safe to call before `ready()`.
|
|
592
|
+
*/
|
|
593
|
+
setAuthToken(token: string): void;
|
|
594
|
+
/**
|
|
595
|
+
* Mint a short-lived, scoped **session token** for one end user — the
|
|
596
|
+
* Stripe `ephemeralKeys.create` / Supabase session shape. Call this on YOUR
|
|
597
|
+
* BACKEND (where the `sk_` secret key lives), then hand the returned
|
|
598
|
+
* `token` to that user's browser (typically via an authEndpoint the client
|
|
599
|
+
* fetches). The browser presents it as the bearer; the sync-server verifies
|
|
600
|
+
* the scoped `rk_` token via `apiKeyProvider`.
|
|
601
|
+
*
|
|
602
|
+
* The browser must NEVER see the `sk_` key — only the per-user session token.
|
|
603
|
+
*
|
|
604
|
+
* Pass `{ user: { id } }` for a full-authority end-user session (mints `ek_`),
|
|
605
|
+
* or `{ agent: { id }, can: { Task: ['update'] } }` for a scoped agent
|
|
606
|
+
* session (mints `rk_`); `can` is typed against your schema's model names.
|
|
607
|
+
*/
|
|
608
|
+
sessions: {
|
|
609
|
+
create(params: CreateSessionParams<S>): Promise<AbloSession>;
|
|
610
|
+
};
|
|
505
611
|
/**
|
|
506
612
|
* Destroy every IndexedDB database owned by this engine. Disconnects
|
|
507
613
|
* the WebSocket, releases timers, and deletes all `ablo_*` / `ablo-*`
|
|
@@ -766,6 +872,8 @@ export declare namespace Ablo {
|
|
|
766
872
|
type CapabilityRecord = import('./ApiClient.js').CapabilityRecord;
|
|
767
873
|
type CapabilityResource = import('./ApiClient.js').CapabilityResource;
|
|
768
874
|
type CapabilityRevocation = import('./ApiClient.js').CapabilityRevocation;
|
|
875
|
+
type CapabilityRotateOptions = import('./ApiClient.js').CapabilityRotateOptions;
|
|
876
|
+
type RotatedCapability = import('./ApiClient.js').RotatedCapability;
|
|
769
877
|
type Task = import('./ApiClient.js').Task;
|
|
770
878
|
type TaskCreateOptions = import('./ApiClient.js').TaskCreateOptions;
|
|
771
879
|
type TaskCloseOptions = import('./ApiClient.js').TaskCloseOptions;
|