@abloatai/ablo 0.16.2 → 0.17.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 +50 -0
- package/dist/BaseSyncedStore.d.ts +3 -1
- package/dist/BaseSyncedStore.js +27 -4
- package/dist/cli.cjs +366 -211
- package/dist/client/Ablo.js +24 -11
- package/dist/schema/diff.d.ts +14 -0
- package/docs/agent-messaging.md +149 -0
- package/docs/agents.md +104 -0
- package/docs/data-sources.md +164 -287
- package/docs/index.md +0 -1
- package/docs/projects.md +97 -0
- package/docs/quickstart.md +51 -63
- package/docs/react.md +1 -1
- package/docs/sessions.md +239 -0
- package/docs/webhooks.md +231 -0
- package/package.json +4 -2
package/docs/quickstart.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
# Quickstart
|
|
2
2
|
|
|
3
3
|
Build with Ablo on **the Postgres you already have**. You declare a small Ablo
|
|
4
|
-
schema for the models humans and agents edit together,
|
|
5
|
-
|
|
6
|
-
`ablo.<model>`.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
schema for the models humans and agents edit together, connect Ablo to your
|
|
5
|
+
database with **logical replication** (`ablo connect`), and coordinate every read
|
|
6
|
+
and claim through `ablo.<model>`. Your database stays the system of record: your
|
|
7
|
+
app keeps writing through its own backend, Ablo tails your write-ahead log (WAL),
|
|
8
|
+
and the confirmed rows fan out to every connected client. Ablo **never runs DDL,
|
|
9
|
+
owns, or migrates your schema**.
|
|
10
10
|
|
|
11
11
|
> No database yet? The hosted **sandbox** can host rows in Ablo's test plane —
|
|
12
|
-
> pass an `apiKey` only and
|
|
13
|
-
> try Ablo before
|
|
12
|
+
> pass an `apiKey` only and skip the database setup, like Stripe test mode — so
|
|
13
|
+
> you can try Ablo before connecting your Postgres.
|
|
14
14
|
|
|
15
15
|
## 1. Install and initialize
|
|
16
16
|
|
|
@@ -95,15 +95,34 @@ same idiom as tRPC's `typeof appRouter` and Drizzle's `typeof db`; it resolves
|
|
|
95
95
|
the typed overload at the call site. Avoid `ReturnType<typeof Ablo>`, which
|
|
96
96
|
collapses to the untyped client.
|
|
97
97
|
|
|
98
|
-
## 3.
|
|
98
|
+
## 3. Connect your database with `ablo connect`
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
Connecting a real database = Postgres logical replication, and `ablo connect` is
|
|
101
|
+
the one way to set it up. Ablo **reads** your WAL and never runs DDL, owns, or
|
|
102
|
+
migrates your schema — your app keeps writing through its own backend.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# 1. Enable logical decoding (then RESTART Postgres — wal_level is not reloadable)
|
|
106
|
+
# ALTER SYSTEM SET wal_level = 'logical';
|
|
107
|
+
# On RDS/Aurora: set rds.logical_replication = 1 in the parameter group, reboot.
|
|
108
|
+
|
|
109
|
+
# 2. Print the exact publication + replication-role SQL for YOUR Postgres, run it:
|
|
110
|
+
npx ablo connect
|
|
111
|
+
|
|
112
|
+
# 3. Put the replication role's connection string in DATABASE_URL, then validate:
|
|
113
|
+
npx ablo connect --check
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`ablo connect` prints the copy-pasteable SQL (a publication named
|
|
117
|
+
`ablo_publication` and a least-privilege `ablo_replicator` role with
|
|
118
|
+
`REPLICATION` + `SELECT`, password you choose). `ablo connect --check` connects to
|
|
119
|
+
`DATABASE_URL` and verifies `wal_level=logical`, the publication, the role's
|
|
120
|
+
`REPLICATION` attribute, and that every published table has a usable
|
|
121
|
+
`REPLICA IDENTITY` — a green checklist or the precise fix per item.
|
|
103
122
|
|
|
104
123
|
```bash
|
|
105
124
|
# .env — server runtime only, never the browser
|
|
106
|
-
DATABASE_URL=postgres://
|
|
125
|
+
DATABASE_URL=postgres://ablo_replicator:<password>@host:5432/db?sslmode=require
|
|
107
126
|
ABLO_API_KEY=sk_test_...
|
|
108
127
|
```
|
|
109
128
|
|
|
@@ -115,50 +134,18 @@ import { schema } from './schema';
|
|
|
115
134
|
export const ablo = Ablo({
|
|
116
135
|
schema,
|
|
117
136
|
apiKey: process.env.ABLO_API_KEY,
|
|
118
|
-
databaseUrl: process.env.DATABASE_URL, // your Postgres, passed explicitly — rows live here
|
|
119
137
|
});
|
|
120
138
|
```
|
|
121
139
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
Use a dedicated **non-superuser role** for the connection — Ablo enforces
|
|
127
|
-
tenant isolation with row-level security, so the server rejects superuser or
|
|
128
|
-
`BYPASSRLS` roles outright (`database_role_cannot_enforce_rls`).
|
|
129
|
-
|
|
130
|
-
> **Neon / Supabase note:** the connection string those dashboards hand you
|
|
131
|
-
> uses the database OWNER role (e.g. `neondb_owner`), which is `BYPASSRLS` —
|
|
132
|
-
> Ablo will reject it. You don't have to fix that by hand: `npx ablo dev`
|
|
133
|
-
> (next step) detects the unsafe role and offers to create the scoped one for
|
|
134
|
-
> you — from your machine, so the owner credential never reaches Ablo. It
|
|
135
|
-
> writes the new `DATABASE_URL` into your env file (the generated password is
|
|
136
|
-
> never printed).
|
|
137
|
-
>
|
|
138
|
-
> Prefer to do it manually? The equivalent SQL:
|
|
139
|
-
>
|
|
140
|
-
> ```sql
|
|
141
|
-
> CREATE ROLE ablo_app LOGIN PASSWORD '<strong password>'
|
|
142
|
-
> NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
|
|
143
|
-
> GRANT CREATE, CONNECT ON DATABASE <your_db> TO ablo_app;
|
|
144
|
-
> GRANT CREATE, USAGE ON SCHEMA public TO ablo_app;
|
|
145
|
-
> ```
|
|
146
|
-
>
|
|
147
|
-
> Then swap the user/password in the dashboard's string:
|
|
148
|
-
> `postgres://ablo_app:<password>@<same-host>/<same-db>?sslmode=require`.
|
|
149
|
-
|
|
150
|
-
Don't want a connection string to leave your infrastructure? Keep
|
|
151
|
-
`DATABASE_URL` in your app only and expose one signed **Data Source endpoint**
|
|
152
|
-
built from an ORM adapter instead — same product, same writes, see
|
|
153
|
-
[Connect Your Database](./data-sources.md). In that setup, omit `databaseUrl`
|
|
154
|
-
from `Ablo(...)`.
|
|
140
|
+
The full setup, the honest footprint (publication + slot + `REPLICATION` role +
|
|
141
|
+
the `wal_level` restart + slot/WAL retention Ablo monitors), and the Preview
|
|
142
|
+
status of the WAL runtime are in [Connect Your Database](./data-sources.md).
|
|
155
143
|
|
|
156
144
|
## 4. Push the schema, then map it to tables
|
|
157
145
|
|
|
158
146
|
```bash
|
|
159
|
-
npx ablo push #
|
|
160
|
-
#
|
|
161
|
-
# re-push on every save.
|
|
147
|
+
npx ablo push # pushes the schema definition and writes ABLO_API_KEY to
|
|
148
|
+
# .env.local. Add --watch to re-push on every save.
|
|
162
149
|
```
|
|
163
150
|
|
|
164
151
|
`ablo push` uploads the schema *definition* — model names, fields, types. That
|
|
@@ -166,21 +153,22 @@ metadata is what tells Ablo which models to coordinate. Skipping it makes every
|
|
|
166
153
|
write to a new or changed model fail with `server_execute_unknown_model` — that
|
|
167
154
|
error literally means "run `npx ablo push`."
|
|
168
155
|
|
|
169
|
-
Now
|
|
170
|
-
|
|
156
|
+
Now map those models to your real Postgres tables. **Your migration tool owns the
|
|
157
|
+
tables** — Ablo reads them, it does not create or migrate them:
|
|
158
|
+
|
|
159
|
+
- Run `npx ablo pull` to import the shape of your existing tables (created by
|
|
160
|
+
Prisma, Drizzle, or hand-written migrations) into your schema, or
|
|
161
|
+
`npx ablo check` to verify your schema and the live tables agree. Keep managing
|
|
162
|
+
the tables with your own migration tool; Ablo syncs the subset of models you
|
|
163
|
+
declared and reports the rest as "ignored / owned by you."
|
|
171
164
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
with your own migration tool; Ablo just syncs the subset of models you
|
|
177
|
-
declared.
|
|
178
|
-
- **Let Ablo provision them.** If Ablo should own the tables, `npx ablo migrate`
|
|
179
|
-
creates your synced-model tables (with row-level security) in the registered
|
|
180
|
-
database. Your other tables are left untouched.
|
|
165
|
+
> **Optional escape hatch:** if you have no tables yet and want Ablo to scaffold
|
|
166
|
+
> them, `npx ablo migrate` can create your synced-model tables for you. This is
|
|
167
|
+
> not the happy path — connecting a real database is `ablo connect` (step 3), and
|
|
168
|
+
> your own migrations stay in charge of your schema.
|
|
181
169
|
|
|
182
170
|
Nothing runs locally — there is no dev server to start. Your app talks to Ablo's
|
|
183
|
-
hosted API
|
|
171
|
+
hosted API; the rows live in your database.
|
|
184
172
|
|
|
185
173
|
## 5. Write through the model
|
|
186
174
|
|
|
@@ -279,5 +267,5 @@ Keep using the schema client for app and agent writes.
|
|
|
279
267
|
- [Schema Contract](./schema-contract.md) explains what the schema drives across SDK, React, agents, Data Source, and schema push.
|
|
280
268
|
- [Guarantees](./guarantees.md) explains what confirmed writes and stale checks mean.
|
|
281
269
|
- [Client Behavior](./client-behavior.md) covers errors, retries, and public imports.
|
|
282
|
-
- [Connect Your Database](./data-sources.md) covers
|
|
270
|
+
- [Connect Your Database](./data-sources.md) covers the logical-replication connect path end to end — `ablo connect`, the honest footprint, and the WAL runtime's Preview status.
|
|
283
271
|
- [AI SDK Tool](./examples/ai-sdk-tool.md) shows the same write path inside a tool call.
|
package/docs/react.md
CHANGED
package/docs/sessions.md
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# Sessions
|
|
2
|
+
|
|
3
|
+
A **session** is a short-lived credential your backend mints with its `sk_` and
|
|
4
|
+
hands to one actor — a signed-in **person's browser** or a scoped **agent**. It's
|
|
5
|
+
the same primitive in both cases (backend-minted, short-lived, scoped); the only
|
|
6
|
+
difference is the subject and how much authority it carries.
|
|
7
|
+
|
|
8
|
+
One resource mints both:
|
|
9
|
+
|
|
10
|
+
```ts Your backend (sk_)
|
|
11
|
+
// A logged-in person's browser session — full authority within their org.
|
|
12
|
+
const userSession = await ablo.sessions.create({
|
|
13
|
+
user: { id: currentUser.id },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// A scoped agent session — gated to exactly the operations you name.
|
|
17
|
+
const agentSession = await ablo.sessions.create({
|
|
18
|
+
agent: { id: 'agent:task-writer' },
|
|
19
|
+
can: { Task: ['read', 'update'], Deck: ['read'] },
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`user` mints an `ek_` (ephemeral key); `agent` mints an `rk_` (restricted key).
|
|
24
|
+
You pass `user` **or** `agent` — never both.
|
|
25
|
+
|
|
26
|
+
It exists because of one rule: **the browser can never hold a secret.** Your
|
|
27
|
+
`sk_` lives on the server; the browser only ever holds a minted session token
|
|
28
|
+
(which already names your org). So the per-actor credential is minted
|
|
29
|
+
server-side, scoped, and expires in minutes — the model Stripe uses for
|
|
30
|
+
client-side SDKs.
|
|
31
|
+
|
|
32
|
+
## Why
|
|
33
|
+
|
|
34
|
+
Ablo doesn't authenticate your users — you do, however you like (your own
|
|
35
|
+
sessions, an IdP, anything). Ablo authenticates your **project** (the `sk_` that
|
|
36
|
+
minted the session) and trusts the identity you asserted at mint time. The
|
|
37
|
+
session token *is* that assertion: "this connection is acting as `U`, in org
|
|
38
|
+
`O`, until it expires."
|
|
39
|
+
|
|
40
|
+
## End-user sessions (`ek_`)
|
|
41
|
+
|
|
42
|
+
For a logged-in person using your app. Mint on a backend route that has already
|
|
43
|
+
authenticated the user:
|
|
44
|
+
|
|
45
|
+
```ts Your backend route (session-authed)
|
|
46
|
+
const { token } = await ablo.sessions.create({
|
|
47
|
+
user: { id: currentUser.id }, // who the session acts as
|
|
48
|
+
// syncGroups: [...], // optional; defaults to the user's org + user
|
|
49
|
+
});
|
|
50
|
+
return Response.json({ token }); // return ONLY the token to the browser
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A user session has **full data authority** within its org — no operation
|
|
54
|
+
allowlist. It's the human acting as themselves.
|
|
55
|
+
|
|
56
|
+
Build a browser `Ablo` client whose `getToken` fetches from that route, and pass
|
|
57
|
+
the **instance** to [`<AbloProvider>`](/react). The client fetches the token,
|
|
58
|
+
opens the connection, and re-mints before expiry — your app writes no token
|
|
59
|
+
plumbing:
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
'use client';
|
|
63
|
+
|
|
64
|
+
import Ablo from '@abloatai/ablo';
|
|
65
|
+
import { AbloProvider } from '@abloatai/ablo/react';
|
|
66
|
+
import { schema } from '@/ablo.schema';
|
|
67
|
+
|
|
68
|
+
const ablo = Ablo({
|
|
69
|
+
schema,
|
|
70
|
+
getToken: () =>
|
|
71
|
+
fetch('/api/ablo-session', { method: 'POST' })
|
|
72
|
+
.then((r) => r.json())
|
|
73
|
+
.then((d) => d.token),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
77
|
+
return <AbloProvider client={ablo}>{children}</AbloProvider>;
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The client owns auth, the credential lifecycle, and the connection; the provider
|
|
82
|
+
is the thin reactive binding over it (Stripe's `<Elements stripe={...}>` model).
|
|
83
|
+
Build the client **once** at module scope — a new instance per render tears down
|
|
84
|
+
the socket. `authEndpoint: '/api/ablo-session'` is accepted as sugar for the
|
|
85
|
+
`getToken` fetch above if you prefer a URL.
|
|
86
|
+
|
|
87
|
+
## Agent sessions (`rk_`)
|
|
88
|
+
|
|
89
|
+
For a non-human actor — an agent or automation that should only do **specific**
|
|
90
|
+
operations. The `can` map is the permission boundary, and it's **typed against
|
|
91
|
+
your schema** — the model keys are your schema's models, so a typo is a compile
|
|
92
|
+
error, not a silent over-grant:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
const session = await ablo.sessions.create({
|
|
96
|
+
agent: { id: 'agent:task-writer' },
|
|
97
|
+
can: { Task: ['read', 'update'] }, // typed off the schema — no magic strings
|
|
98
|
+
ttlSeconds: 600,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const agent = Ablo({ schema, apiKey: session.token }); // the agent's scoped client
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`can: { Task: ['update'] }` serializes to the wire allowlist `task.update`; the
|
|
105
|
+
server rejects any commit whose operation isn't listed. Operations are
|
|
106
|
+
`'read' | 'create' | 'update' | 'delete'`.
|
|
107
|
+
|
|
108
|
+
<Note>
|
|
109
|
+
Use `sessions.create({ agent })` to mint a scoped agent credential, then write
|
|
110
|
+
with `ablo.<model>.update(...)` / `ablo.commits.create(...)` under a `claim`.
|
|
111
|
+
This is the path for custom runtimes, MCP sessions, and protocol-level integrations.
|
|
112
|
+
</Note>
|
|
113
|
+
|
|
114
|
+
## Mint
|
|
115
|
+
|
|
116
|
+
Only a **secret key** (`sk_`) can mint a session — never another session token.
|
|
117
|
+
The `sk_` is the trust anchor; minting is your backend vouching
|
|
118
|
+
for the actor.
|
|
119
|
+
|
|
120
|
+
| Param | For | Meaning |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `user` / `agent` | both | The actor. `id` becomes the token's `participantId`. Pass exactly one. |
|
|
123
|
+
| `can` | agent | Per-model operation allowlist, typed off the schema. |
|
|
124
|
+
| `syncGroups` | both | Narrow the session below its default scope. Omit to inherit. |
|
|
125
|
+
| `ttlSeconds` | both | Lifetime in seconds. Defaults to `900` (15m). |
|
|
126
|
+
| `userMeta` | both | Opaque identity blob echoed back to the client. |
|
|
127
|
+
|
|
128
|
+
## Lifecycle
|
|
129
|
+
|
|
130
|
+
Sessions are **short-lived by design** (~15 minutes) and, for browsers,
|
|
131
|
+
**auto-refreshed** — the provider re-mints ahead of expiry, so a session never
|
|
132
|
+
drops at the boundary. A revoked or signed-out actor simply stops getting a fresh
|
|
133
|
+
token; the old one expires on its own. There's nothing to revoke by hand.
|
|
134
|
+
|
|
135
|
+
### Offline & sign-out
|
|
136
|
+
|
|
137
|
+
The short session token is **not** your user's login — it's a minutes-long
|
|
138
|
+
credential layered on top of whatever long-lived auth your `authEndpoint`
|
|
139
|
+
already enforces (your own session cookie, an IdP, etc.). The provider keeps
|
|
140
|
+
those two lifetimes separate, which means:
|
|
141
|
+
|
|
142
|
+
- **Going offline never signs the user out.** The provider keeps working from
|
|
143
|
+
its local cache and treats a failed re-mint (no network, a timeout, a `5xx`
|
|
144
|
+
from your endpoint) as **transient** — it retries, and re-mints the instant
|
|
145
|
+
connectivity or tab focus returns. The user stays signed in for as long as
|
|
146
|
+
your underlying session is valid, however brief or long the network drop.
|
|
147
|
+
- **The user is signed out only when the underlying session is genuinely
|
|
148
|
+
rejected** — i.e. your `authEndpoint` responds **`401`/`403`** because the
|
|
149
|
+
cookie (or IdP session) is missing, expired, or revoked. That's the one
|
|
150
|
+
signal the provider treats as terminal.
|
|
151
|
+
|
|
152
|
+
This mirrors the OAuth refresh-token rule (Okta/Auth0/Authgear): only a
|
|
153
|
+
rejection of the *long-lived* credential ends the session — a network failure
|
|
154
|
+
never does.
|
|
155
|
+
|
|
156
|
+
<Note>
|
|
157
|
+
Your `authEndpoint` contract follows from this: return the token on success,
|
|
158
|
+
respond **`401`/`403`** only when the user's session is actually gone, and let
|
|
159
|
+
network/`5xx` failures surface as errors. Don't collapse "can't reach the mint
|
|
160
|
+
endpoint" into "session expired" — returning a `401` for a transient blip will
|
|
161
|
+
bounce a still-valid user to your sign-in page.
|
|
162
|
+
</Note>
|
|
163
|
+
|
|
164
|
+
## Scope
|
|
165
|
+
|
|
166
|
+
A user session carries the user's **base** sync-groups (`org:`/`user:`/`team:`),
|
|
167
|
+
derived from the identity you minted it for. **Dynamic, relation-driven
|
|
168
|
+
membership** (e.g. a `dataroom:<id>` the user was just added to) is resolved
|
|
169
|
+
**server-side at connect** and unioned on top — so scope stays live, not frozen
|
|
170
|
+
at mint time. Pass `syncGroups` only when you want to *narrow* below the default.
|
|
171
|
+
|
|
172
|
+
## Your schema, your users (the default)
|
|
173
|
+
|
|
174
|
+
Your schema lives in a **project** — you push it once (`npx ablo push`) and every
|
|
175
|
+
session you mint resolves against it. The flow for serving end-users:
|
|
176
|
+
|
|
177
|
+
1. **Push your schema** to your project.
|
|
178
|
+
2. **Mint an `ek_` per user** — `sessions.create({ user: { id } })`. Your users
|
|
179
|
+
commit to that one schema.
|
|
180
|
+
|
|
181
|
+
**Your users do not have Ablo accounts.** You authenticate them however you
|
|
182
|
+
already do; your server's `sk_` mints the `ek_`. By default the session lands in
|
|
183
|
+
your project's own org, so all your users share one schema and one data tenant,
|
|
184
|
+
isolated from each other by sync-groups. For most apps (the Cursor shape) that's
|
|
185
|
+
the whole story — nothing below is needed.
|
|
186
|
+
|
|
187
|
+
## Org-per-customer isolation (the add-on)
|
|
188
|
+
|
|
189
|
+
Some apps need each customer to be its **own** tenant — a hard data boundary
|
|
190
|
+
(separate row-level isolation, optionally a separate database), not just per-user
|
|
191
|
+
scoping. The law-firm shape (Legora): every firm is its own org, many users
|
|
192
|
+
inside it.
|
|
193
|
+
|
|
194
|
+
The problem that creates: if each customer is a separate org, a naïve setup would
|
|
195
|
+
make you re-push your schema into every new customer's org. You don't have to.
|
|
196
|
+
Keep **one** project as the home of your schema, and point each customer's
|
|
197
|
+
session's *schema* at it while its *data* stays in the customer's own org:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
const { token } = await mintUserSessionKey({
|
|
201
|
+
apiKey: process.env.ABLO_PLATFORM_KEY, // sk_ with the ephemeral:mint-any-org scope
|
|
202
|
+
userId,
|
|
203
|
+
organizationId, // DATA → this customer's org (its own isolated tenant)
|
|
204
|
+
schemaProject: { // SCHEMA → the project that owns your schema
|
|
205
|
+
organizationId: schemaOwnerOrgId,
|
|
206
|
+
projectId: schemaProjectId,
|
|
207
|
+
},
|
|
208
|
+
ttlSeconds: 3600,
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Server-side the split is clean: the model **shape** loads from your schema
|
|
213
|
+
project, but column enrichment and the tenant connection target the customer's
|
|
214
|
+
`organizationId` — so the shared schema only *describes* the shape; the data
|
|
215
|
+
plane (connection + row-level isolation) stays the customer's. A shared schema
|
|
216
|
+
can't leak data across orgs.
|
|
217
|
+
|
|
218
|
+
<Note>
|
|
219
|
+
This requires a platform `sk_` carrying the `ephemeral:mint-any-org` scope —
|
|
220
|
+
only a trusted first-party key can mint a session into another org and bind its
|
|
221
|
+
schema to your project. Omit these fields and you get the default above: one
|
|
222
|
+
project, one schema, all your users.
|
|
223
|
+
</Note>
|
|
224
|
+
|
|
225
|
+
## Security
|
|
226
|
+
|
|
227
|
+
The whole safety argument is the short TTL: a session token leaked from a
|
|
228
|
+
browser (XSS) is valid for minutes, scoped to one actor's data, and can't mint
|
|
229
|
+
anything or touch the control plane. Contrast `sk_`, which would be a full org
|
|
230
|
+
compromise — which is exactly why it never leaves your server.
|
|
231
|
+
|
|
232
|
+
## User vs. agent sessions
|
|
233
|
+
|
|
234
|
+
| | User session (`ek_`) | Agent session (`rk_`) |
|
|
235
|
+
|---|---|---|
|
|
236
|
+
| For | a **person** in the browser | an **agent** / automation |
|
|
237
|
+
| Authority | full, within their org | narrow (explicit `can` allowlist) |
|
|
238
|
+
| Mint | `ablo.sessions.create({ user: { id } })` | `ablo.sessions.create({ agent: { id }, can })` |
|
|
239
|
+
| Lives where | the user's **browser** | the agent runtime |
|
package/docs/webhooks.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Webhooks
|
|
2
|
+
|
|
3
|
+
Ablo owns the ordered transaction log — the source of truth. **Webhooks are how
|
|
4
|
+
that log reaches your database.** Ablo streams every committed change to an
|
|
5
|
+
endpoint in your app as a signed event; your handler writes your own database.
|
|
6
|
+
|
|
7
|
+
It's the same two-sided shape as Stripe: you call Ablo to make changes (the
|
|
8
|
+
client), and Ablo calls you to persist them (this webhook). Ablo never holds your
|
|
9
|
+
database credentials.
|
|
10
|
+
|
|
11
|
+
## The loop
|
|
12
|
+
|
|
13
|
+
```mermaid
|
|
14
|
+
flowchart LR
|
|
15
|
+
App["Your app<br/>(the client)"]
|
|
16
|
+
subgraph Ablo["Ablo (hosted)"]
|
|
17
|
+
Log["Transaction log<br/><i>source of truth</i>"]
|
|
18
|
+
end
|
|
19
|
+
Clients["Other clients<br/>(live, optimistic)"]
|
|
20
|
+
Route["/api/ablo/[...all]<br/>(your webhook route)"]
|
|
21
|
+
DB[("Your database")]
|
|
22
|
+
|
|
23
|
+
App -->|write| Log
|
|
24
|
+
Log -->|realtime sync| Clients
|
|
25
|
+
Log -->|signed event| Route
|
|
26
|
+
Route -->|write| DB
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
There are two ways data flows out of Ablo, and they're for different jobs:
|
|
30
|
+
|
|
31
|
+
| | reaches | use it for |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| **Realtime** (`useAblo`, WSS) | your live UI | instant, optimistic rendering |
|
|
34
|
+
| **Webhooks** (this page) | your database | a durable copy you own — analytics, backups, server logic |
|
|
35
|
+
|
|
36
|
+
Most apps use both: the realtime stream for the UI, the webhook stream to keep
|
|
37
|
+
their database in sync. This page is the webhook stream.
|
|
38
|
+
|
|
39
|
+
If you know Stripe, you already know the shape:
|
|
40
|
+
|
|
41
|
+
| Stripe | Ablo |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `stripe.x.create(...)` — make the change | the Ablo client — make the change (+ live sync) |
|
|
44
|
+
| `/stripe-webhook` — confirm and persist | `/api/ablo/[...all]` — persist into your database |
|
|
45
|
+
| Stripe owns the charges | Ablo owns the transaction log |
|
|
46
|
+
| you mirror charges into your database | you mirror the log into your database |
|
|
47
|
+
|
|
48
|
+
The difference in Ablo's favor: every event carries `syncId`, a monotonic log
|
|
49
|
+
position, so you can both dedupe **and** apply in order — Ablo guarantees the
|
|
50
|
+
order because it owns the log.
|
|
51
|
+
|
|
52
|
+
## The event object
|
|
53
|
+
|
|
54
|
+
Every delivery is a batch of events. Each event:
|
|
55
|
+
|
|
56
|
+
| field | meaning |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `type` | `"<model>.<verb>"`, e.g. `task.updated` |
|
|
59
|
+
| `model` | the model name (`task`) — the table to write |
|
|
60
|
+
| `objectId` | the changed row's id |
|
|
61
|
+
| `data` | the post-change row, or `null` on delete (like Stripe's `event.data.object`) |
|
|
62
|
+
| `syncId` | monotonic log position — **dedupe and order by this** |
|
|
63
|
+
| `id` | `String(syncId)` — the event id |
|
|
64
|
+
| `createdAt` | ISO commit timestamp |
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import type { AbloWebhookEvent } from '@abloatai/ablo/webhooks';
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 1. Create a handler
|
|
71
|
+
|
|
72
|
+
`npx ablo init` scaffolds this for you at `app/api/ablo/[...all]/route.ts`. If
|
|
73
|
+
your project uses Prisma it's a **working generic mirror** — one upsert/delete
|
|
74
|
+
for every model, no per-model code. Otherwise it's a neutral route with a single
|
|
75
|
+
place to plug your database in.
|
|
76
|
+
|
|
77
|
+
```ts app/api/ablo/[...all]/route.ts
|
|
78
|
+
import { Webhook } from 'svix'; // any Standard Webhooks library
|
|
79
|
+
import type { AbloWebhookEvent } from '@abloatai/ablo/webhooks';
|
|
80
|
+
import { PrismaClient } from '@prisma/client';
|
|
81
|
+
|
|
82
|
+
const wh = new Webhook(process.env.ABLO_WEBHOOK_SECRET!);
|
|
83
|
+
const prisma = new PrismaClient();
|
|
84
|
+
|
|
85
|
+
type ModelDelegate = {
|
|
86
|
+
upsert(a: { where: { id: string }; create: Record<string, unknown>; update: Record<string, unknown> }): Promise<unknown>;
|
|
87
|
+
delete(a: { where: { id: string } }): Promise<unknown>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export async function POST(req: Request): Promise<Response> {
|
|
91
|
+
const body = await req.text(); // RAW body — required to verify
|
|
92
|
+
let batch: { data: AbloWebhookEvent[] };
|
|
93
|
+
try {
|
|
94
|
+
batch = wh.verify(body, Object.fromEntries(req.headers)) as { data: AbloWebhookEvent[] };
|
|
95
|
+
} catch {
|
|
96
|
+
return new Response('invalid signature', { status: 400 });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const delegates = prisma as unknown as Record<string, ModelDelegate | undefined>;
|
|
100
|
+
for (const event of [...batch.data].sort((a, b) => a.syncId - b.syncId)) {
|
|
101
|
+
const model = delegates[event.model];
|
|
102
|
+
if (!model) continue; // a model you don't mirror — skip
|
|
103
|
+
if (event.data === null) {
|
|
104
|
+
await model.delete({ where: { id: event.objectId } }).catch(() => {});
|
|
105
|
+
} else {
|
|
106
|
+
await model.upsert({ where: { id: event.objectId }, create: event.data, update: event.data });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return new Response(null, { status: 200 }); // 2xx = delivered
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
You only edit this if your tables **diverge** from Ablo's schema (renamed
|
|
115
|
+
columns, extra side effects) — add a `case` for that model before the generic
|
|
116
|
+
mirror. If your tables match Ablo's, you never touch it.
|
|
117
|
+
|
|
118
|
+
<Note>
|
|
119
|
+
Return a `2xx` quickly. Any other status is a failure and is retried. Do heavy
|
|
120
|
+
work asynchronously after responding.
|
|
121
|
+
</Note>
|
|
122
|
+
|
|
123
|
+
## 2. Test locally
|
|
124
|
+
|
|
125
|
+
You don't register a `localhost` URL. `npx ablo dev` forwards committed changes
|
|
126
|
+
to your machine with a local signing secret — the same idea as Stripe's
|
|
127
|
+
`stripe listen`. Run your app, run `ablo dev`, and writes flow into your local
|
|
128
|
+
handler.
|
|
129
|
+
|
|
130
|
+
## 3. Register your endpoint
|
|
131
|
+
|
|
132
|
+
For a deployed URL, register it once. Ablo mints the signing secret and returns
|
|
133
|
+
it a single time — the CLI writes it straight into your `.env.local`.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npx ablo webhooks create https://yourapp.com/api/ablo/[...all]
|
|
137
|
+
# ✓ Registered we_… → https://yourapp.com/api/ablo/[...all]
|
|
138
|
+
# ✓ Wrote ABLO_WEBHOOK_SECRET to .env.local (shown once)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Manage and inspect endpoints:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
npx ablo webhooks list # endpoints + delivery health (status, cursor, last error)
|
|
145
|
+
npx ablo webhooks roll <id> # mint a fresh signing secret
|
|
146
|
+
npx ablo webhooks enable <id> # re-enable a disabled endpoint
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Or call the API directly — the org is derived from your secret key:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
curl https://api.abloatai.com/api/v1/webhook_endpoints \
|
|
153
|
+
-H "authorization: Bearer $ABLO_API_KEY" \
|
|
154
|
+
-H "content-type: application/json" \
|
|
155
|
+
-d '{ "url": "https://yourapp.com/api/ablo/[...all]" }'
|
|
156
|
+
# → { "id": "we_…", "secret": "whsec_…", "status": "enabled", ... }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## 4. Verify the signature
|
|
160
|
+
|
|
161
|
+
Ablo signs every request with the [Standard Webhooks](https://www.standardwebhooks.com)
|
|
162
|
+
scheme (the spec Svix authored). Verify with any compatible library — `svix` or
|
|
163
|
+
`standardwebhooks` — using the secret from registration. Ablo ships no
|
|
164
|
+
verification code of its own; you use the open library.
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const wh = new Webhook(process.env.ABLO_WEBHOOK_SECRET!);
|
|
168
|
+
const event = wh.verify(rawBody, Object.fromEntries(req.headers));
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Verification checks three headers — `webhook-id`, `webhook-timestamp`,
|
|
172
|
+
`webhook-signature` — and rejects a timestamp outside a 5-minute window (replay
|
|
173
|
+
protection). Always verify against the **raw** request body.
|
|
174
|
+
|
|
175
|
+
## Event delivery
|
|
176
|
+
|
|
177
|
+
Ablo delivers from a per-endpoint **cursor** over the log, advancing only on a
|
|
178
|
+
`2xx`. A failed delivery leaves the cursor in place, so the same events are
|
|
179
|
+
re-sent until they land — at-least-once, in order.
|
|
180
|
+
|
|
181
|
+
```mermaid
|
|
182
|
+
sequenceDiagram
|
|
183
|
+
participant L as Ablo log
|
|
184
|
+
participant W as Delivery worker
|
|
185
|
+
participant E as Your endpoint
|
|
186
|
+
W->>L: read events after cursor
|
|
187
|
+
L-->>W: events (ordered by syncId)
|
|
188
|
+
W->>E: POST signed batch
|
|
189
|
+
alt 2xx
|
|
190
|
+
E-->>W: 200
|
|
191
|
+
W->>L: advance cursor
|
|
192
|
+
else non-2xx / timeout
|
|
193
|
+
E-->>W: error
|
|
194
|
+
Note over W,E: retry with backoff<br/>5s → 5m → … → 10h (8 attempts)<br/>then auto-disable
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
| behavior | how it works |
|
|
199
|
+
|---|---|
|
|
200
|
+
| **Ordering** | Every event carries `syncId`, a monotonic log position. Apply in `syncId` order — Ablo guarantees the order because it owns the log. |
|
|
201
|
+
| **Retries** | A non-`2xx` (or no response within the timeout) is retried with backoff: immediate, 5s, 5m, 30m, 2h, 5h, 10h, 10h — 8 attempts over ~32h. |
|
|
202
|
+
| **Auto-disable** | After the retries exhaust, the endpoint is marked `disabled` and delivery stops until you `ablo webhooks enable <id>`. |
|
|
203
|
+
| **Replay** | Nothing is lost on failure: the log *is* the durable buffer, and delivery resumes from the endpoint's cursor once it's healthy. |
|
|
204
|
+
|
|
205
|
+
## Best practices
|
|
206
|
+
|
|
207
|
+
- **Dedupe by `syncId`.** Skip any `syncId` you've already stored. Delivery is
|
|
208
|
+
at-least-once, so the same event can arrive twice after a retry.
|
|
209
|
+
- **Apply in `syncId` order.** It's the log position; sort each batch by it.
|
|
210
|
+
- **Return `2xx` fast.** Acknowledge first, then do slow work asynchronously.
|
|
211
|
+
- **Subscribe to only what you need.** Set `enabledEvents` to the models you
|
|
212
|
+
mirror (`['*']` is the default = all).
|
|
213
|
+
- **Roll secrets periodically.** `ablo webhooks roll <id>` mints a new secret;
|
|
214
|
+
Standard Webhooks supports a rotation window so in-flight events still verify.
|
|
215
|
+
- **Verify the raw body.** Frameworks that re-serialize JSON will break the
|
|
216
|
+
signature — verify the bytes you received.
|
|
217
|
+
|
|
218
|
+
## Event types
|
|
219
|
+
|
|
220
|
+
The verb is derived from the change:
|
|
221
|
+
|
|
222
|
+
| type | when |
|
|
223
|
+
|---|---|
|
|
224
|
+
| `<model>.created` | a row was inserted |
|
|
225
|
+
| `<model>.updated` | a row was updated |
|
|
226
|
+
| `<model>.deleted` | a row was deleted (`data` is `null`) |
|
|
227
|
+
| `<model>.archived` | a row was soft-archived |
|
|
228
|
+
| `<model>.unarchived` | a soft-archived row was restored |
|
|
229
|
+
|
|
230
|
+
Internal coordination changes (permissions, sync groups) carry no webhook — only
|
|
231
|
+
your data models produce events.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/ablo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "The Collaboration Layer For AI Agents",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -140,7 +140,9 @@
|
|
|
140
140
|
"build:cli": "tsup --config tsup.cli.config.ts",
|
|
141
141
|
"typecheck:cli": "tsc -p tsconfig.cli.json",
|
|
142
142
|
"pack:check": "npm_config_cache=${TMPDIR:-/tmp}/ablo-npm-cache npm pack --dry-run",
|
|
143
|
-
"lint": "npm run lint:imports && npm run lint:errors && npm run lint:docs",
|
|
143
|
+
"lint": "npm run lint:imports && npm run lint:errors && npm run lint:docs && npm run lint:mintlify",
|
|
144
|
+
"build:docs": "node scripts/build-mintlify-docs.mjs",
|
|
145
|
+
"lint:mintlify": "node scripts/build-mintlify-docs.mjs --check",
|
|
144
146
|
"lint:imports": "node scripts/check-js-extensions.mjs",
|
|
145
147
|
"generate:errors": "tsx scripts/generate-error-docs.mts",
|
|
146
148
|
"lint:errors": "tsx scripts/check-error-docs.mts",
|