@abloatai/ablo 0.5.0 → 0.6.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 +22 -0
- package/README.md +242 -135
- package/dist/BaseSyncedStore.d.ts +2 -2
- package/dist/BaseSyncedStore.js +2 -2
- package/dist/api/index.d.ts +3 -3
- package/dist/api/index.js +1 -1
- package/dist/client/Ablo.d.ts +90 -93
- package/dist/client/Ablo.js +121 -60
- package/dist/client/ApiClient.d.ts +14 -14
- package/dist/client/ApiClient.js +81 -55
- package/dist/client/createInternalComponents.d.ts +2 -3
- package/dist/client/createInternalComponents.js +2 -3
- package/dist/client/createModelProxy.d.ts +90 -87
- package/dist/client/createModelProxy.js +124 -127
- package/dist/client/index.d.ts +6 -7
- package/dist/client/index.js +4 -5
- package/dist/client/validateAbloOptions.js +3 -3
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +7 -0
- package/dist/errors.d.ts +8 -8
- package/dist/errors.js +18 -10
- package/dist/index.d.ts +9 -8
- package/dist/index.js +7 -11
- package/dist/interfaces/index.d.ts +2 -10
- package/dist/mutators/Transaction.d.ts +2 -2
- package/dist/mutators/Transaction.js +2 -2
- package/dist/mutators/mutateActions.d.ts +44 -0
- package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
- package/dist/mutators/readerActions.d.ts +32 -0
- package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
- package/dist/query/types.d.ts +1 -1
- package/dist/react/AbloProvider.d.ts +1 -1
- package/dist/react/AbloProvider.js +3 -3
- package/dist/react/context.d.ts +4 -4
- package/dist/react/index.d.ts +4 -5
- package/dist/react/index.js +3 -7
- package/dist/react/useAblo.d.ts +14 -14
- package/dist/react/useAblo.js +26 -26
- package/dist/react/useIntent.d.ts +2 -2
- package/dist/react/useIntent.js +2 -2
- package/dist/react/useMutators.d.ts +1 -1
- package/dist/react/usePresence.d.ts +3 -3
- package/dist/react/usePresence.js +4 -4
- package/dist/react/useUndoScope.d.ts +1 -1
- package/dist/schema/diff.d.ts +161 -0
- package/dist/schema/diff.js +262 -0
- package/dist/schema/generate.d.ts +19 -0
- package/dist/schema/generate.js +87 -0
- package/dist/schema/index.d.ts +4 -1
- package/dist/schema/index.js +7 -1
- package/dist/schema/schema.d.ts +83 -32
- package/dist/schema/schema.js +58 -12
- package/dist/schema/serialize.d.ts +92 -0
- package/dist/schema/serialize.js +227 -0
- package/dist/sync/SyncWebSocket.d.ts +17 -0
- package/dist/sync/SyncWebSocket.js +46 -1
- package/dist/sync/awaitIntentGrant.d.ts +26 -0
- package/dist/sync/awaitIntentGrant.js +60 -0
- package/dist/sync/createIntentStream.js +43 -4
- package/dist/sync/createPresenceStream.js +1 -1
- package/dist/sync/participants.d.ts +2 -2
- package/dist/sync/participants.js +4 -4
- package/dist/types/global.d.ts +43 -52
- package/dist/types/global.js +16 -18
- package/dist/types/streams.d.ts +37 -9
- package/docs/api.md +68 -158
- package/docs/audit.md +5 -5
- package/docs/client-behavior.md +41 -42
- package/docs/coordination.md +294 -0
- package/docs/data-sources.md +14 -14
- package/docs/examples/agent-human.md +30 -32
- package/docs/examples/ai-sdk-tool.md +32 -33
- package/docs/examples/existing-python-backend.md +35 -33
- package/docs/examples/nextjs.md +24 -25
- package/docs/examples/server-agent.md +20 -61
- package/docs/guarantees.md +30 -55
- package/docs/identity.md +458 -0
- package/docs/index.md +12 -24
- package/docs/integration-guide.md +106 -116
- package/docs/interaction-model.md +29 -95
- package/docs/mcp/claude-code.md +3 -3
- package/docs/mcp/cursor.md +1 -1
- package/docs/mcp/windsurf.md +1 -1
- package/docs/mcp.md +11 -26
- package/docs/quickstart.md +43 -49
- package/docs/react.md +73 -23
- package/docs/roadmap.md +5 -7
- package/llms.txt +34 -39
- package/package.json +1 -1
- package/dist/react/useMutate.d.ts +0 -83
- package/dist/react/useQuery.d.ts +0 -123
- package/dist/react/useQuery.js +0 -145
- package/dist/react/useReader.d.ts +0 -69
- package/docs/capabilities.md +0 -163
package/docs/identity.md
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
# Identity & Sync Groups
|
|
2
|
+
|
|
3
|
+
This is the doc the Quickstart skips: **who is connecting, and which slice
|
|
4
|
+
of shared state do they get?** If you've wired `<AbloProvider schema={schema}>`
|
|
5
|
+
and wondered where org / team / user actually come from — start here.
|
|
6
|
+
|
|
7
|
+
## Ablo does not do auth
|
|
8
|
+
|
|
9
|
+
Ablo is not an identity provider. It has no login, no password store, no
|
|
10
|
+
session of its own. You keep whatever you already use — Clerk, Auth0,
|
|
11
|
+
NextAuth, WorkOS, your own session table. Ablo's job begins **after** you've
|
|
12
|
+
authenticated the user: you hand Ablo the already-authenticated identity, and
|
|
13
|
+
Ablo decides which **sync groups** that identity may read and write.
|
|
14
|
+
|
|
15
|
+
So the integration question is never "how do I log into Ablo?" It's: *"My app
|
|
16
|
+
already knows this request is user `U` in org `O`. How do I tell Ablo, so it
|
|
17
|
+
scopes their realtime data correctly?"* The rest of this doc answers exactly
|
|
18
|
+
that.
|
|
19
|
+
|
|
20
|
+
## What a sync group is
|
|
21
|
+
|
|
22
|
+
A **sync group** is a named channel of shared state — a string like
|
|
23
|
+
`org:acme` or `deck:abc123`. It is simultaneously:
|
|
24
|
+
|
|
25
|
+
- **the unit of fan-out** — a confirmed write to a row publishes a delta to
|
|
26
|
+
every participant subscribed to that row's sync group(s), and
|
|
27
|
+
- **the unit of access** — a participant receives a row's deltas *only if* the
|
|
28
|
+
row's sync group is in their allowed set.
|
|
29
|
+
|
|
30
|
+
There is no built-in `org` / `team` / `user` concept in the engine. Those are
|
|
31
|
+
*your* domain words. Ablo only knows sync-group strings. The mapping from "this
|
|
32
|
+
is user U in org O" to "they may subscribe to `org:acme` and `user:U`" is
|
|
33
|
+
something **you declare in your schema** — covered next.
|
|
34
|
+
|
|
35
|
+
### Two kinds of group — the whole mental model
|
|
36
|
+
|
|
37
|
+
Every sync group is named after one of two things, and that's the cleanest way
|
|
38
|
+
to hold the model in your head:
|
|
39
|
+
|
|
40
|
+
- **Membership groups** — named after *who you are*: `org:{id}`, `team:{id}`,
|
|
41
|
+
`user:{id}`. Produced from **identity** (`identityRoles`, Half 1). They're
|
|
42
|
+
standing and durable — they don't change as you work.
|
|
43
|
+
- **Entity groups** — named after *a thing*: `dataroom:{id}`, `deck:{id}`,
|
|
44
|
+
`slide:{id}`. Produced from a **row's id** (a model's entity scope, Half 2).
|
|
45
|
+
They're granular — one per record — and any participant can be pointed at a
|
|
46
|
+
specific set of them.
|
|
47
|
+
|
|
48
|
+
Humans and agents fill that same space differently — and, crucially, the two
|
|
49
|
+
are **declared in different places**, because membership is static (a property of
|
|
50
|
+
identity) while entity scope is dynamic (a property of the task):
|
|
51
|
+
|
|
52
|
+
| | Subscribed by | Declared where | Gets |
|
|
53
|
+
| --- | --- | --- | --- |
|
|
54
|
+
| **Human** | *who they are* — membership | **the schema** (`identityRoles`) — a rule, written once | every `org` / `team` / `user` group their identity implies — their whole standing world |
|
|
55
|
+
| **Agent** | *what it's been given* — entities | **code, at the spawn site** — chosen per run | a handful of entity groups: the dataroom it's in, the slides it has read — never beyond what its user's membership could reach |
|
|
56
|
+
|
|
57
|
+
> **One line:** humans subscribe by who they are; agents subscribe by what
|
|
58
|
+
> they've been given.
|
|
59
|
+
|
|
60
|
+
The asymmetry is the point. A user's org/team/user don't change per request, so
|
|
61
|
+
their scope is a **rule the schema derives automatically** — you never write
|
|
62
|
+
per-user scope code. An agent's reach depends on *what it's working on*, which is
|
|
63
|
+
only knowable at dispatch — so you pass its `syncGroups` **at the call site, in
|
|
64
|
+
code**. The schema's only job for entities is to declare *that* a model is
|
|
65
|
+
entity-scopable and *what its group is named* (`entity: 'deck'` → `deck:{id}`);
|
|
66
|
+
it never declares *which* entities a given agent gets. (A human can opt into the
|
|
67
|
+
same runtime narrowing — a page scoped to one deck — but by default a human's
|
|
68
|
+
scope is fully schema-derived.)
|
|
69
|
+
|
|
70
|
+
So an agent doesn't need a `user:{id}` standing grant. It's a participant pointed
|
|
71
|
+
at a few entity groups, bounded above by its triggering user's membership. That
|
|
72
|
+
boundary is the whole safety story, and it's covered in
|
|
73
|
+
[Agents are participants too](#agents-are-participants-too).
|
|
74
|
+
|
|
75
|
+
```txt
|
|
76
|
+
your auth → identity { kind, userId|agentId, organizationId, teamIds }
|
|
77
|
+
→ identityRoles (schema) → allowed sync groups
|
|
78
|
+
→ participant receives deltas for rows in those groups
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The identity is a **participant** — and a participant is either a human
|
|
82
|
+
(`kind: 'user'`) or an agent (`kind: 'agent'`). Same shape, same path; see
|
|
83
|
+
[Agents are participants too](#agents-are-participants-too) below. Everything in
|
|
84
|
+
the next two sections applies to both.
|
|
85
|
+
|
|
86
|
+
## Declare it, end to end
|
|
87
|
+
|
|
88
|
+
The entire declaration surface is three things: `identityRoles` (who may see
|
|
89
|
+
what), `syncGroupFormat` (which group a row fans out on), and an optional
|
|
90
|
+
`syncGroups` prop (narrowing). Here they are in one runnable place — the sections
|
|
91
|
+
after this explain each.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// 1. src/ablo.schema.ts — map identity → groups, and anchor each model to a group
|
|
95
|
+
import { defineSchema, identityRole, model, z } from '@abloatai/ablo/schema';
|
|
96
|
+
|
|
97
|
+
export const schema = defineSchema(
|
|
98
|
+
{
|
|
99
|
+
conversations: model(
|
|
100
|
+
{ title: z.string(), createdBy: z.string() },
|
|
101
|
+
{},
|
|
102
|
+
{ orgScoped: true, syncGroupFormat: 'conversation:{id}' },
|
|
103
|
+
),
|
|
104
|
+
decks: model(
|
|
105
|
+
{ title: z.string(), status: z.enum(['draft', 'published']) },
|
|
106
|
+
{},
|
|
107
|
+
{ orgScoped: true, syncGroupFormat: 'deck:{id}' },
|
|
108
|
+
),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
// Each role is pure data: a `template` and the identity `source` field to
|
|
112
|
+
// read. No closures — so the schema stays JSON-serializable end to end.
|
|
113
|
+
identityRoles: [
|
|
114
|
+
identityRole({ kind: 'tenant', template: 'org:{id}', source: 'organizationId' }),
|
|
115
|
+
identityRole({ kind: 'participant', template: 'user:{id}', source: 'userId' }),
|
|
116
|
+
identityRole({ kind: 'membership', template: 'team:{id}', source: 'teamIds', multi: true }),
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
// 2. app/providers.tsx — a HUMAN gets their full org / team scope
|
|
124
|
+
<AbloProvider schema={schema} userId={user.id} teamIds={user.teamIds}>
|
|
125
|
+
{children}
|
|
126
|
+
</AbloProvider>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
// 3. an AGENT run inherits its user, narrowed to the entities in play
|
|
131
|
+
<AbloProvider
|
|
132
|
+
schema={schema}
|
|
133
|
+
userId={user.id} // ceiling: the triggering user
|
|
134
|
+
syncGroups={[`conversation:${conversationId}`, `deck:${deckId}`]} // floor: just its work
|
|
135
|
+
>
|
|
136
|
+
{children}
|
|
137
|
+
</AbloProvider>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
That's the whole surface. The rest of this doc is the *why* behind each line.
|
|
141
|
+
|
|
142
|
+
## The two halves of scoping
|
|
143
|
+
|
|
144
|
+
Scoping is two declarations that meet in the middle. One describes the
|
|
145
|
+
**participant** (what may I subscribe to?), the other describes each **row**
|
|
146
|
+
(which group does this row belong to?). A participant sees a row **iff** the
|
|
147
|
+
row's sync group is in the participant's allowed set.
|
|
148
|
+
|
|
149
|
+
### Half 1 — `identityRoles`: identity → allowed groups
|
|
150
|
+
|
|
151
|
+
Declared once, on the schema, via the `identityRole({ kind, template, source })`
|
|
152
|
+
factory. Each role is **pure data**: a `template` with a single `{id}`
|
|
153
|
+
placeholder, and the `source` — the identity field to read. The engine reads
|
|
154
|
+
`source` off the identity *you* supply and substitutes each value into the
|
|
155
|
+
`template` to build the participant's allowed set. There is no hardcoded `org:` /
|
|
156
|
+
`user:` anywhere in the engine — the templates and sources are entirely yours.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// src/ablo.schema.ts
|
|
160
|
+
import { defineSchema, identityRole, model, z } from '@abloatai/ablo/schema';
|
|
161
|
+
|
|
162
|
+
export const schema = defineSchema(
|
|
163
|
+
{
|
|
164
|
+
decks: model({
|
|
165
|
+
title: z.string(),
|
|
166
|
+
status: z.enum(['draft', 'published']),
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
identityRoles: [
|
|
171
|
+
identityRole({ kind: 'tenant', template: 'org:{id}', source: 'organizationId' }),
|
|
172
|
+
identityRole({ kind: 'participant', template: 'user:{id}', source: 'userId' }),
|
|
173
|
+
// `multi: true` reads an array field — one team group per id.
|
|
174
|
+
identityRole({ kind: 'membership', template: 'team:{id}', source: 'teamIds', multi: true }),
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The identity these `source` fields read is what your app resolves from its own
|
|
181
|
+
auth — Ablo never invents it. Roles are pure data (no closures) on purpose: a
|
|
182
|
+
`Schema` stays JSON-serializable end to end, so the same declaration works
|
|
183
|
+
in-process and on a hosted server that only ever sees the compiled JSON.
|
|
184
|
+
|
|
185
|
+
> **Single field per role.** `source` reads one field. An agent doesn't need its
|
|
186
|
+
> own role: it runs on behalf of a user and carries that user's `userId`, so the
|
|
187
|
+
> `user:{id}` role above already covers it — see
|
|
188
|
+
> [Agents are participants too](#agents-are-participants-too).
|
|
189
|
+
|
|
190
|
+
### Half 2 — per-model scope: row → group
|
|
191
|
+
|
|
192
|
+
On each model's options, you declare how its rows are tenanted and which
|
|
193
|
+
sync-group label they fan out on.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
model(
|
|
197
|
+
{ /* fields */ },
|
|
198
|
+
{ /* relations */ },
|
|
199
|
+
{
|
|
200
|
+
// Rows carry organization_id; bootstrap + fan-out filter on it.
|
|
201
|
+
orgScoped: true,
|
|
202
|
+
|
|
203
|
+
// Per-entity anchor. Lets a session narrow into ONE row's scope,
|
|
204
|
+
// e.g. open a single deck: syncGroupFormat.replace('{id}', deckId).
|
|
205
|
+
syncGroupFormat: 'deck:{id}',
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
For rows that don't carry `organization_id` directly but inherit tenancy
|
|
211
|
+
through a foreign key, use `scopedVia` rather than `orgScoped: false` — the
|
|
212
|
+
latter exposes the whole table cross-tenant. See
|
|
213
|
+
`packages/sync-engine/src/schema/model.ts` for the full option set.
|
|
214
|
+
|
|
215
|
+
## How identity reaches Ablo — the proxy model
|
|
216
|
+
|
|
217
|
+
This is the part the README's "authenticates with the signed-in user's
|
|
218
|
+
session" glossed over. Concretely:
|
|
219
|
+
|
|
220
|
+
1. **Your `ABLO_API_KEY` lives only on your trusted server**, scoped to your
|
|
221
|
+
account. It signs your app's relationship with Ablo. It must never reach a
|
|
222
|
+
browser bundle — treat it like a Stripe secret key.
|
|
223
|
+
2. **Your server authenticates the user with your own system.** That's the
|
|
224
|
+
request that knows "this is user `U`, org `O`, teams `[...]`".
|
|
225
|
+
3. **Your server hands that authenticated identity to Ablo**, and the browser
|
|
226
|
+
talks to the realtime plane as an already-scoped participant. The browser
|
|
227
|
+
never holds the API key and cannot widen its own scope — the security
|
|
228
|
+
boundary is the identity your **server** vouched for, not anything the client
|
|
229
|
+
asserts.
|
|
230
|
+
4. **Ablo runs your `identityRoles` over that identity** to compute the allowed
|
|
231
|
+
sync groups, and the participant subscribes to exactly that set.
|
|
232
|
+
|
|
233
|
+
The Ablo web app (`apps/web`) is the reference implementation of this shape:
|
|
234
|
+
its server resolves the signed-in user and active organization from its own
|
|
235
|
+
auth, and the sync layer composes the participant's sync groups from that
|
|
236
|
+
resolved identity — the API key stays server-side throughout. The generic,
|
|
237
|
+
library-agnostic name for "my server tells Ablo which of my users is acting" is
|
|
238
|
+
the `Ablo-Acting-User` request dimension; the web app realizes it through its
|
|
239
|
+
own session, but the contract is the same: **identity is asserted by your
|
|
240
|
+
server, never by the browser.**
|
|
241
|
+
|
|
242
|
+
> **Why the proxy, not a client API key?** A browser is a hostile runtime. If
|
|
243
|
+
> the client could name its own org or sync groups, any user could read another
|
|
244
|
+
> tenant's data by editing a request. By keeping the API key server-side and
|
|
245
|
+
> deriving scope from the identity your server already authenticated, the trust
|
|
246
|
+
> boundary lands in the one place you control. This is the same reason
|
|
247
|
+
> Liveblocks resolves scope in `prepareSession` and Stripe mints ephemeral keys
|
|
248
|
+
> server-side.
|
|
249
|
+
|
|
250
|
+
## Wiring the provider
|
|
251
|
+
|
|
252
|
+
The provider props carry the identity your server resolved. In a Next.js app,
|
|
253
|
+
resolve the user in a Server Component and pass it down:
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
// app/providers.tsx — 'use client'
|
|
257
|
+
import { AbloProvider } from '@abloatai/ablo/react';
|
|
258
|
+
import { schema } from '@/ablo.schema';
|
|
259
|
+
|
|
260
|
+
export function Providers({
|
|
261
|
+
children,
|
|
262
|
+
user, // { id, teamIds } — resolved server-side from YOUR auth
|
|
263
|
+
}: {
|
|
264
|
+
children: React.ReactNode;
|
|
265
|
+
user: { id: string; teamIds: string[] };
|
|
266
|
+
}) {
|
|
267
|
+
return (
|
|
268
|
+
<AbloProvider
|
|
269
|
+
schema={schema}
|
|
270
|
+
userId={user.id}
|
|
271
|
+
teamIds={user.teamIds}
|
|
272
|
+
fallback={<AppSkeleton />}
|
|
273
|
+
>
|
|
274
|
+
{children}
|
|
275
|
+
</AbloProvider>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
What each identity-related prop does — and just as importantly, does *not* do:
|
|
281
|
+
|
|
282
|
+
| Prop | Purpose |
|
|
283
|
+
| ------------ | ------------------------------------------------------------------------------------------------ |
|
|
284
|
+
| `userId` | App-level participant id, used for app-owned fields and read by your `identityRole` `source`. **Not** the security boundary — the server enforces scope from the authenticated request. |
|
|
285
|
+
| `teamIds` | Team ids expanded into team sync groups via your `identityRoles`. |
|
|
286
|
+
| `syncGroups` | Optional. **Narrows** the subscription to a subset of what auth already allows — it can never widen it. Use it to scope a page to one entity (e.g. `['deck:abc123']`). |
|
|
287
|
+
|
|
288
|
+
Because the server is the boundary, a client that changes `userId` to another
|
|
289
|
+
user's id does not gain their data — the server resolves and enforces the real
|
|
290
|
+
identity on the connection. The props are how your app *tells* Ablo who it
|
|
291
|
+
already authenticated, not how it *proves* it.
|
|
292
|
+
|
|
293
|
+
## Agents are participants too
|
|
294
|
+
|
|
295
|
+
An agent and a human **authenticate through the exact same path** — same proxy,
|
|
296
|
+
same `identityRoles`, same server-enforced boundary. An agent is a participant;
|
|
297
|
+
the only data difference is that it carries `kind: 'agent'` and an `agentId`
|
|
298
|
+
where a human carries `userId`. There is no separate identity model to learn.
|
|
299
|
+
|
|
300
|
+
What differs is **authority, not identity** — and the distinction is the whole
|
|
301
|
+
point. An agent always runs *on behalf of* the user who set it off, so its
|
|
302
|
+
**ceiling is exactly that user's access**: the same conversations, messages, and
|
|
303
|
+
models the triggering user can reach, and nothing that user couldn't. But within
|
|
304
|
+
that ceiling it is **narrowed to the model instances it is touching, or has
|
|
305
|
+
touched** — never the user's whole org.
|
|
306
|
+
|
|
307
|
+
Scope is therefore an intersection:
|
|
308
|
+
|
|
309
|
+
```txt
|
|
310
|
+
agent authority = (triggering user's allowed set) ← ceiling, inherited (on-behalf-of)
|
|
311
|
+
∩ (the model instances it touches) ← floor, least privilege per run
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Mechanically, this is the per-model anchor from
|
|
315
|
+
[Half 2](#half-2--per-model-scope-row--group) doing the work. Declare an entity
|
|
316
|
+
anchor on the models an agent operates on:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
// a conversation and the deck an agent edits each get a per-entity group
|
|
320
|
+
conversations: model({ /* … */ }, {}, { syncGroupFormat: 'conversation:{id}' }),
|
|
321
|
+
decks: model({ /* … */ }, {}, { syncGroupFormat: 'deck:{id}' }),
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Then a run subscribes only to the entity groups for the rows it works on — a
|
|
325
|
+
subset of what its user could see:
|
|
326
|
+
|
|
327
|
+
```tsx
|
|
328
|
+
// agent run triggered by `user`, working on one conversation + one deck
|
|
329
|
+
<AbloProvider
|
|
330
|
+
schema={schema}
|
|
331
|
+
// identity inherited from the triggering user (the ceiling)
|
|
332
|
+
userId={user.id}
|
|
333
|
+
// authority narrowed to just the entities in play (the floor)
|
|
334
|
+
syncGroups={[`conversation:${conversationId}`, `deck:${deckId}`]}
|
|
335
|
+
>
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
As the run touches more entities its set **accretes** to cover them; it never
|
|
339
|
+
widens past the user's ceiling, and it carries no standing access to entities it
|
|
340
|
+
isn't working on. The `identityRoles` need no agent-specific entry: the agent
|
|
341
|
+
carries the triggering user's `userId`, so the same `user:{id}` role that scopes
|
|
342
|
+
a human already scopes the agent. Nothing about the *identity* declaration
|
|
343
|
+
branches on agent vs human.
|
|
344
|
+
|
|
345
|
+
`kind` is what attribution uses — not access. `kind: 'agent'` plus `agentId` is
|
|
346
|
+
connection metadata that tags every write with the executing agent **and** the
|
|
347
|
+
user it ran on behalf of, so audit answers "who did this, and on whose behalf."
|
|
348
|
+
It never appears in an `identityRole`, because it changes *who's accountable*,
|
|
349
|
+
not *what's reachable*.
|
|
350
|
+
|
|
351
|
+
This is deliberately the shape the 2025–2026 agent-identity consensus converged
|
|
352
|
+
on, expressed in Ablo's primitives rather than a bolted-on agent ACL:
|
|
353
|
+
|
|
354
|
+
- **Inherit the user, and no more** — the OAuth
|
|
355
|
+
[on-behalf-of](https://workos.com/blog/oauth-on-behalf-of-ai-agents) model: the
|
|
356
|
+
agent's reach is tied to the consenting user, never the org.
|
|
357
|
+
- **Least privilege, just-in-time** — scoped to the task's entities, not standing
|
|
358
|
+
org-wide access (the over-privilege pattern
|
|
359
|
+
[OWASP's NHI Top 10](https://www.token.security/assets/the-ultimate-non-human-identity-security-guide)
|
|
360
|
+
flags as the dominant agent risk).
|
|
361
|
+
- **Dual-principal attribution** — record both the executing agent and the
|
|
362
|
+
triggering human.
|
|
363
|
+
|
|
364
|
+
Identity is 1:1 with a human participant; authority is narrowed to the work. That
|
|
365
|
+
split is what lets Ablo keep *one model API for every actor* without ever
|
|
366
|
+
granting an agent standing access to everything its user can see. The agent that
|
|
367
|
+
runs the [Coordinating long agent work](../README.md#coordinating-long-agent-work)
|
|
368
|
+
`claim` loop is, to the scoping layer, that same participant — scoped to the row
|
|
369
|
+
it claimed.
|
|
370
|
+
|
|
371
|
+
## Narrowing to a single entity
|
|
372
|
+
|
|
373
|
+
For a page that should only sync one record, combine a per-entity
|
|
374
|
+
`syncGroupFormat` (Half 2) with the `syncGroups` prop:
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
// schema: decks fan out on deck:{id}
|
|
378
|
+
syncGroupFormat: 'deck:{id}'
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
```tsx
|
|
382
|
+
// page provider: subscribe to just this deck (still inside what auth allows)
|
|
383
|
+
<AbloProvider schema={schema} userId={user.id} syncGroups={[`deck:${deckId}`]}>
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
The participant now receives deltas for that one deck instead of the whole org
|
|
387
|
+
— smaller bootstrap, less fan-out — without weakening the server-enforced
|
|
388
|
+
boundary.
|
|
389
|
+
|
|
390
|
+
## How this compares — and the best practices it follows
|
|
391
|
+
|
|
392
|
+
Ablo's identity model is not novel; it's the convergent answer every serious
|
|
393
|
+
realtime / sync SDK arrived at. Knowing which industry pattern it *is* tells you
|
|
394
|
+
how to reason about it.
|
|
395
|
+
|
|
396
|
+
**Realtime authorization splits into two shapes.** Ablo is firmly in the first:
|
|
397
|
+
|
|
398
|
+
- **Server derives scope from authenticated identity** — the server decides what
|
|
399
|
+
a participant may read/write and the client cannot override it. This is Ablo's
|
|
400
|
+
proxy model. It's the same shape as
|
|
401
|
+
[Supabase Realtime's RLS-on-connect](https://supabase.com/docs/guides/realtime/authorization)
|
|
402
|
+
(policies evaluated at subscribe, cached for the connection),
|
|
403
|
+
[Liveblocks **ID tokens**](https://liveblocks.io/docs/authentication) ("Liveblocks
|
|
404
|
+
checks the permissions for you" — recommended for production), and
|
|
405
|
+
[ElectricSQL **proxy auth**](https://electric-sql.com/docs/guides/auth) (a
|
|
406
|
+
reverse-proxy sets shape params server-side before forwarding).
|
|
407
|
+
- **Client proposes, server authorizes the exact request** — the client names
|
|
408
|
+
the room/shape and the server signs off, as in
|
|
409
|
+
[Pusher's channel authorization endpoint](https://pusher.com/docs/channels/server_api/authorizing-users/),
|
|
410
|
+
[ElectricSQL **gatekeeper auth**](https://github.com/electric-sql/electric/blob/main/examples/gatekeeper-auth/README.md),
|
|
411
|
+
and Liveblocks **access tokens**. Ablo's `syncGroups` prop is the *narrowing*
|
|
412
|
+
half of this — but it can only ever shrink the server-derived set, never grow
|
|
413
|
+
it.
|
|
414
|
+
|
|
415
|
+
The best practices Ablo inherits from that lineage:
|
|
416
|
+
|
|
417
|
+
1. **The secret never reaches the client.** Your `ABLO_API_KEY` lives only on a
|
|
418
|
+
trusted server — exactly as
|
|
419
|
+
[Ably mandates](https://ably.com/docs/auth/token) ("never use API keys in
|
|
420
|
+
client-side code; they don't expire, so once compromised they grant indefinite
|
|
421
|
+
access") and
|
|
422
|
+
[PowerSync's flow](https://docs.powersync.com/installation/authentication-setup/custom)
|
|
423
|
+
(app auth → backend mints a signed token → client connects with the token).
|
|
424
|
+
|
|
425
|
+
2. **Trusted vs untrusted claims is the whole security argument.** PowerSync draws
|
|
426
|
+
the line precisely: [token parameters are trusted and usable for access
|
|
427
|
+
control; client parameters are not](https://docs.powersync.com/usage/sync-rules/advanced-topics/client-parameters).
|
|
428
|
+
In Ablo terms, the identity your server vouches for is the *trusted* claim that
|
|
429
|
+
sets scope; the provider's `userId` / `syncGroups` props are *untrusted client
|
|
430
|
+
input* — convenient for app-owned fields and narrowing, but never the boundary.
|
|
431
|
+
This is why changing `userId` in the browser grants nothing.
|
|
432
|
+
|
|
433
|
+
3. **Scope by a hierarchical naming convention, declared once.** Ablo's
|
|
434
|
+
`identityRoles` templates (`org:{id}`, `team:{id}`, `deck:{id}`) are the same
|
|
435
|
+
idea as [Liveblocks' recommended room-id naming pattern](https://liveblocks.io/docs/authentication/access-token)
|
|
436
|
+
(`org:*`, `org:group:*`) and [Ably's channel capabilities](https://ably.com/docs/auth/capabilities).
|
|
437
|
+
Declaring the convention in one place — never composing scope strings in
|
|
438
|
+
consumer code — is the practice all three enforce.
|
|
439
|
+
|
|
440
|
+
4. **Attribution and presence ride the authenticated identity.** Just as
|
|
441
|
+
[Pusher attaches `channel_data` to presence at auth time](https://pusher.com/docs/channels/server_api/authorizing-users/),
|
|
442
|
+
Ablo's participant identity (the one your server vouched for) is what powers
|
|
443
|
+
presence and per-write attribution — not a value the client asserts after the
|
|
444
|
+
fact.
|
|
445
|
+
|
|
446
|
+
The one practice that differs by deployment: short-lived, auto-refreshed bearer
|
|
447
|
+
tokens ([Ably](https://ably.com/docs/auth/token),
|
|
448
|
+
[Supabase's `access_token` refresh](https://supabase.com/docs/guides/realtime/authorization))
|
|
449
|
+
are the right shape when an untrusted client holds a credential directly. Ablo's
|
|
450
|
+
proxy model keeps the credential server-side instead, so token rotation is the
|
|
451
|
+
server's concern, not the browser's — the same trade ElectricSQL's proxy pattern
|
|
452
|
+
makes versus its gatekeeper tokens.
|
|
453
|
+
|
|
454
|
+
## See also
|
|
455
|
+
|
|
456
|
+
- [Integration Guide](./integration-guide.md) — `identityRoles`, backing modes, and the full app path.
|
|
457
|
+
- [React](./react.md) — the complete `<AbloProvider>` prop surface.
|
|
458
|
+
- [API Keys](./api-keys.md) — server-side keys for the public API.
|
package/docs/index.md
CHANGED
|
@@ -4,11 +4,11 @@ Ablo is a state control API for **humans and AI agents editing the same
|
|
|
4
4
|
typed state in real time, with attribution, conflict handling, and
|
|
5
5
|
fast cutoff**.
|
|
6
6
|
|
|
7
|
-
It gives agents a narrow way to write production state: declare models, load current state, coordinate active work, and write with stale-state checks.
|
|
7
|
+
It gives agents a narrow way to write production state: declare models, load current state, coordinate active work, and write with stale-state checks.
|
|
8
8
|
|
|
9
9
|
Multiplayer is not a separate product mode. If humans, server actions, and agents
|
|
10
10
|
use the same `Ablo({ schema, apiKey })` client and write through
|
|
11
|
-
`ablo.<model>`, Ablo fans out confirmed deltas, exposes active
|
|
11
|
+
`ablo.<model>`, Ablo fans out confirmed deltas, exposes active claims, and
|
|
12
12
|
rejects stale writes for every participant.
|
|
13
13
|
|
|
14
14
|
## What you get, in three commitments
|
|
@@ -27,21 +27,15 @@ query-shaped sync).
|
|
|
27
27
|
declared once on the schema's `identityRoles`. Consumer code never
|
|
28
28
|
composes group strings. Same boundary Liveblocks (`prepareSession`),
|
|
29
29
|
PowerSync (named streams), and Zero (synced queries) settled on.
|
|
30
|
-
- **Capabilities, not API keys, for agents.** Per-run, per-scope, leased
|
|
31
|
-
credentials with per-request signature verification and instant
|
|
32
|
-
revocation. The 2025-26 AI-agent auth consensus (OAuth 2.1 / MCP,
|
|
33
|
-
AWS STS, Vault leases, Auth0 Token Vault) converged on this shape;
|
|
34
|
-
capabilities are Ablo's instance. See
|
|
35
|
-
[Capabilities](./capabilities.md#why-capabilities-not-api-keys) for
|
|
36
|
-
the design rationale.
|
|
37
30
|
|
|
38
31
|
## Start here
|
|
39
32
|
|
|
40
33
|
- [Quickstart](./quickstart.md) — Make your first schema-backed write.
|
|
34
|
+
- [Identity & Sync Groups](./identity.md) — Bring your own auth; tell Ablo who's connecting and how org / team / user map to sync-group scope.
|
|
41
35
|
- [Integration Guide](./integration-guide.md) — Choose Ablo-managed state, Data Source, React, multiplayer, and agent patterns.
|
|
42
|
-
- [Guarantees](./guarantees.md) — What confirmed writes, stale checks, and
|
|
43
|
-
- [Interaction Model](./interaction-model.md) — The
|
|
44
|
-
- [API Reference](./api.md) —
|
|
36
|
+
- [Guarantees](./guarantees.md) — What confirmed writes, stale checks, and claims guarantee.
|
|
37
|
+
- [Interaction Model](./interaction-model.md) — The schema, claim, update, confirmation loop.
|
|
38
|
+
- [API Reference](./api.md) — Model-by-model method shape.
|
|
45
39
|
- [Client Behavior](./client-behavior.md) — Options, errors, retries, timeouts, and imports.
|
|
46
40
|
- [Connect Your Database](./data-sources.md) — Keep canonical rows in your app database without giving Ablo database credentials.
|
|
47
41
|
- [API Keys](./api-keys.md) — Bearer tokens for the public API.
|
|
@@ -50,15 +44,13 @@ query-shaped sync).
|
|
|
50
44
|
|
|
51
45
|
| Plane | Primitives | Purpose |
|
|
52
46
|
|---|---|---|
|
|
53
|
-
| State | `Schema`, `Model`, `
|
|
54
|
-
| Commit | `Resource`, `Commit` | Advanced protocol path for custom runtimes and batches. |
|
|
55
|
-
| Control | `Capability`, `Task`, `Usage` | The authority path. Scope, attribute, meter, audit. |
|
|
47
|
+
| State | `Schema`, `Model`, `Claim`, `Receipt` | The product path. Load, coordinate, write, confirm. |
|
|
56
48
|
| Storage | `Managed State`, `Data Source` | Ablo stores declared models by default; existing app tables use a signed Data Source. |
|
|
57
49
|
|
|
58
50
|
## Use cases
|
|
59
51
|
|
|
60
52
|
- **Let agents write to shared state** — Give an AI agent scoped, revocable write access to your typed data.
|
|
61
|
-
- **Coordinate multiple actors** — Use
|
|
53
|
+
- **Coordinate multiple actors** — Use claims to show pre-write work across humans and agents.
|
|
62
54
|
- **Audit every agent action** — Trace any write back to a human in one query.
|
|
63
55
|
- **Build collaborative editors** — Humans and agents on the same record, with realtime updates and stale-read protection.
|
|
64
56
|
- **Meter and gate API usage** — Per-key, per-team usage reports and quota enforcement.
|
|
@@ -69,26 +61,22 @@ query-shaped sync).
|
|
|
69
61
|
- [Model Methods](./api.md#model-methods) — Load and write typed state.
|
|
70
62
|
- [Integration Guide](./integration-guide.md) — The normal app path and optional pieces.
|
|
71
63
|
- [Guarantees](./guarantees.md) — Confirmed writes, optimistic state, stale-write protection, and agent lifecycle.
|
|
72
|
-
- [
|
|
73
|
-
- [Advanced Commit API](./api.md#advanced-commit-api) — Apply custom batch mutations.
|
|
64
|
+
- [Coordination](./coordination.md) — `claim`, `claimState`, and `queue` for active work.
|
|
74
65
|
- [Connect Your Database](./data-sources.md) — Where data lands when your app database is canonical.
|
|
75
66
|
- [Receipt](./api.md#receipt) — Confirm what landed.
|
|
76
|
-
- [Capability](./api.md#capability) — Signed credentials for a bounded actor, operation set, sync group, and lease.
|
|
77
|
-
- [Task](./api.md#task) — One agent run; the audit envelope for commits and cost.
|
|
78
67
|
- [Usage](./api.md#usage) — Metering and audit dimensions.
|
|
79
68
|
|
|
80
69
|
## Examples
|
|
81
70
|
|
|
82
71
|
- [AI SDK Tool](./examples/ai-sdk-tool.md) — Put Ablo inside an AI SDK tool call.
|
|
83
72
|
- [Existing Python Backend](./examples/existing-python-backend.md) — Add multiplayer and future agent writes without replacing a Python API server.
|
|
84
|
-
- [Agent + Human](./examples/agent-human.md) — Yield when a human is editing the same
|
|
85
|
-
- [Server Agent](./examples/server-agent.md) — Schema-backed worker
|
|
73
|
+
- [Agent + Human](./examples/agent-human.md) — Yield when a human is editing the same report.
|
|
74
|
+
- [Server Agent](./examples/server-agent.md) — Schema-backed worker.
|
|
86
75
|
- [Next.js](./examples/nextjs.md) — App-router setup with React bindings.
|
|
87
76
|
|
|
88
77
|
## Runtime builds
|
|
89
78
|
|
|
90
|
-
- `@abloatai/ablo` — schema-powered sync client for typed model operations, realtime
|
|
91
|
-
- `Ablo({ apiKey })` — advanced resource client for runtimes that intentionally cannot import a schema.
|
|
79
|
+
- `@abloatai/ablo` — schema-powered sync client for typed model operations, realtime claims, and receipts.
|
|
92
80
|
|
|
93
81
|
## More
|
|
94
82
|
|