@abloatai/ablo 0.16.1 → 0.16.3
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 +40 -1
- package/dist/auth/index.d.ts +12 -7
- package/dist/auth/index.js +8 -2
- package/dist/cli.cjs +5 -4
- package/docs/agent-messaging.md +149 -0
- package/docs/agents.md +104 -0
- package/docs/coordination.md +67 -0
- package/docs/identity.md +34 -0
- package/docs/index.md +0 -1
- package/docs/projects.md +97 -0
- package/docs/react.md +1 -1
- package/docs/sessions.md +239 -0
- package/docs/webhooks.md +231 -0
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- **Docs.** The bundled SDK docs are now the single source for the documentation
|
|
8
|
+
site, and several pages were expanded or corrected:
|
|
9
|
+
- The sessions/identity model is reframed around **projects** — push one schema
|
|
10
|
+
to a project, mint an `ek_` per user (your users need no Ablo account), and
|
|
11
|
+
all of them commit to that one schema. Per-customer org isolation
|
|
12
|
+
(`schemaProject`) is presented as the add-on it is, not the default.
|
|
13
|
+
- The declarative `conflict` schema axis (Axis 3) is now documented.
|
|
14
|
+
- The agent docs were corrected to the current claim vocabulary
|
|
15
|
+
(`reason`/`queue`, not the pre-0.12.0 `action`/`wait`).
|
|
16
|
+
|
|
17
|
+
No code changes.
|
|
18
|
+
|
|
19
|
+
## 0.16.2
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- **`mintUserSessionKey`: name the shared-schema binding around the project.** The
|
|
24
|
+
two flat options added in 0.16.0 (`schemaOwnerOrgId` + `schemaProjectId`) are
|
|
25
|
+
replaced by one project-centric option — `schemaProject: { organizationId, projectId }` —
|
|
26
|
+
naming "the project that owns the schema" as a single concept. The wire format
|
|
27
|
+
is unchanged (the SDK still sends the same keys), so no server redeploy is
|
|
28
|
+
needed. Released as a patch: the replaced options shipped in 0.16.0 and have no
|
|
29
|
+
external consumers yet.
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// before
|
|
33
|
+
mintUserSessionKey({ organizationId, schemaOwnerOrgId, schemaProjectId, ... });
|
|
34
|
+
// after
|
|
35
|
+
mintUserSessionKey({
|
|
36
|
+
organizationId, // data org
|
|
37
|
+
schemaProject: { organizationId, projectId }, // the project that owns the schema
|
|
38
|
+
...
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
3
42
|
## 0.16.1
|
|
4
43
|
|
|
5
44
|
### Patch Changes
|
|
@@ -10,7 +49,7 @@
|
|
|
10
49
|
`ABLO_AUTH_URL`), while the human approval page (`/cli`), sign-up, and the
|
|
11
50
|
key-handoff route (`/api/cli/provision-key`) go to the dashboard host
|
|
12
51
|
(`www.abloatai.com`, new override `ABLO_DASHBOARD_URL`). Previously every step
|
|
13
|
-
ran against `www`, where the
|
|
52
|
+
ran against `www`, where the device endpoints no longer resolve —
|
|
14
53
|
producing "Couldn't start login… Is the dashboard reachable?". The CLI now also
|
|
15
54
|
builds the approval URL itself rather than trusting the server's
|
|
16
55
|
`verification_uri`, which (being a relative `/cli`) resolved against the auth
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -38,13 +38,18 @@ export interface MintUserSessionRequest {
|
|
|
38
38
|
* `Stripe-Account` analogue. Requires the `sk_` to carry
|
|
39
39
|
* `ephemeral:mint-any-org`; omit to mint into the key's own org. */
|
|
40
40
|
readonly organizationId?: string;
|
|
41
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
47
|
-
readonly
|
|
41
|
+
/** SHARED SCHEMA — point this session's SCHEMA at the project that owns it,
|
|
42
|
+
* while its DATA stays scoped to `organizationId`. Use this for org-per-customer
|
|
43
|
+
* isolation: keep one schema project, and every customer's session resolves its
|
|
44
|
+
* schema from it instead of re-pushing the schema into each customer's org.
|
|
45
|
+
* Requires the `sk_` to carry `ephemeral:mint-any-org`. Omit for the default
|
|
46
|
+
* (the session resolves its schema from its own org). */
|
|
47
|
+
readonly schemaProject?: {
|
|
48
|
+
/** The org that owns the schema project. */
|
|
49
|
+
readonly organizationId: string;
|
|
50
|
+
/** The project the schema was pushed under. */
|
|
51
|
+
readonly projectId: string;
|
|
52
|
+
};
|
|
48
53
|
readonly syncGroups?: readonly string[];
|
|
49
54
|
readonly ttlSeconds: number;
|
|
50
55
|
readonly label?: string;
|
package/dist/auth/index.js
CHANGED
|
@@ -108,8 +108,14 @@ export async function mintUserSessionKey(options) {
|
|
|
108
108
|
body: JSON.stringify({
|
|
109
109
|
user: { id: options.userId },
|
|
110
110
|
...(options.organizationId ? { organizationId: options.organizationId } : {}),
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
// Flattened to the existing wire keys — the public param is project-centric,
|
|
112
|
+
// the transport contract is unchanged (no coordinated server deploy needed).
|
|
113
|
+
...(options.schemaProject
|
|
114
|
+
? {
|
|
115
|
+
schemaProjectId: options.schemaProject.projectId,
|
|
116
|
+
schemaOwnerOrgId: options.schemaProject.organizationId,
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
113
119
|
...(options.syncGroups ? { syncGroups: options.syncGroups } : {}),
|
|
114
120
|
ttlSeconds: options.ttlSeconds,
|
|
115
121
|
...(options.label ? { label: options.label } : {}),
|
package/dist/cli.cjs
CHANGED
|
@@ -280564,7 +280564,8 @@ function parseProjectFlag(argv) {
|
|
|
280564
280564
|
const eq = argv.find((a) => a.startsWith("--project="));
|
|
280565
280565
|
return eq ? eq.slice("--project=".length) || void 0 : void 0;
|
|
280566
280566
|
}
|
|
280567
|
-
async function deviceLogin(argv) {
|
|
280567
|
+
async function deviceLogin(argv, deps = {}) {
|
|
280568
|
+
const openUrl = deps.openUrl ?? openBrowser;
|
|
280568
280569
|
Ie(`${brand("ablo")} login`);
|
|
280569
280570
|
const requested = parseProjectFlag(argv) ?? getActiveProject()?.slug;
|
|
280570
280571
|
const targetProject = requested === DEFAULT_PROFILE ? void 0 : requested;
|
|
@@ -280599,7 +280600,7 @@ async function deviceLogin(argv) {
|
|
|
280599
280600
|
Me(`${import_picocolors7.default.bold(code.user_code)}
|
|
280600
280601
|
|
|
280601
280602
|
${import_picocolors7.default.dim(url)}`, "Approve in your browser");
|
|
280602
|
-
|
|
280603
|
+
openUrl(url);
|
|
280603
280604
|
const s = Y2();
|
|
280604
280605
|
s.start("Waiting for approval\u2026");
|
|
280605
280606
|
let pollMs = (code.interval ?? 5) * 1e3;
|
|
@@ -280690,8 +280691,8 @@ ${import_picocolors7.default.dim(url)}`, "Approve in your browser");
|
|
|
280690
280691
|
`${import_picocolors7.default.green("\u2713")} Logged in ${import_picocolors7.default.dim("(sandbox)")}${where}. Run ${import_picocolors7.default.bold("npx ablo push")} to push your schema.`
|
|
280691
280692
|
);
|
|
280692
280693
|
}
|
|
280693
|
-
async function login(argv = []) {
|
|
280694
|
-
await deviceLogin(argv);
|
|
280694
|
+
async function login(argv = [], deps = {}) {
|
|
280695
|
+
await deviceLogin(argv, deps);
|
|
280695
280696
|
}
|
|
280696
280697
|
function logout() {
|
|
280697
280698
|
const removed = clearCredential();
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Agent Messaging
|
|
2
|
+
|
|
3
|
+
Use a normal model when agents or humans need durable communication inside a
|
|
4
|
+
syncGroup. Use claim `description` and `meta` for live coordination context.
|
|
5
|
+
Use a `messages` row when the information must survive reconnects, work over
|
|
6
|
+
HTTP, or be replayed from the sync cursor.
|
|
7
|
+
|
|
8
|
+
## The rule
|
|
9
|
+
|
|
10
|
+
| Need | Use | Why |
|
|
11
|
+
| --- | --- | --- |
|
|
12
|
+
| "I am holding this row because..." | `claim({ reason, description, meta })` | Live and low-latency. Peers see it through presence while the claim exists. |
|
|
13
|
+
| "Remember this handoff/status/request" | A `messages` model | Durable row. Ordered in `sync_deltas`, replayed after reconnect, readable by HTTP agents. |
|
|
14
|
+
|
|
15
|
+
Claim context is ephemeral. If a participant was offline, reconnected later, or
|
|
16
|
+
only uses `transport: "http"`, it can miss claim/presence frames. Message rows
|
|
17
|
+
are facts in your database and in the sync log.
|
|
18
|
+
|
|
19
|
+
## Schema
|
|
20
|
+
|
|
21
|
+
Add a `messages` model to the same schema as the work it discusses. Scope it
|
|
22
|
+
with the same field that scopes the work row.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import Ablo from "@abloatai/ablo";
|
|
26
|
+
import { defineSchema, entityRole, model, z } from "@abloatai/ablo/schema";
|
|
27
|
+
|
|
28
|
+
export const schema = defineSchema({
|
|
29
|
+
workItems: model(
|
|
30
|
+
{
|
|
31
|
+
title: z.string(),
|
|
32
|
+
status: z.string(),
|
|
33
|
+
teamId: z.string(),
|
|
34
|
+
},
|
|
35
|
+
{},
|
|
36
|
+
{ entityRoles: [entityRole({ kind: "team", source: "teamId" })] },
|
|
37
|
+
),
|
|
38
|
+
|
|
39
|
+
messages: model(
|
|
40
|
+
{
|
|
41
|
+
body: z.string(),
|
|
42
|
+
kind: z.enum(["status", "request", "handoff"]),
|
|
43
|
+
teamId: z.string(),
|
|
44
|
+
|
|
45
|
+
// Causal link back to coordination or the row being discussed.
|
|
46
|
+
aboutEntityType: z.string().optional(),
|
|
47
|
+
aboutEntityId: z.string().optional(),
|
|
48
|
+
aboutIntentId: z.string().optional(),
|
|
49
|
+
},
|
|
50
|
+
{},
|
|
51
|
+
{ entityRoles: [entityRole({ kind: "team", source: "teamId" })] },
|
|
52
|
+
),
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Push the schema before agents call it:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npx ablo push
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Server-side agent
|
|
63
|
+
|
|
64
|
+
Most server-side agents use the direct database path. Pass the schema, API key,
|
|
65
|
+
database URL, and transport selector:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const ablo = Ablo({
|
|
69
|
+
schema,
|
|
70
|
+
apiKey: process.env.ABLO_API_KEY,
|
|
71
|
+
databaseUrl: process.env.DATABASE_URL,
|
|
72
|
+
transport: "http",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await ablo.ready();
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`databaseUrl` is server-only. Browser clients must not receive it; live UIs use
|
|
79
|
+
the default WebSocket transport with a minted user/session token.
|
|
80
|
+
|
|
81
|
+
If your backend mints restricted agent tokens, register the database once from a
|
|
82
|
+
secret-key server process as above. Workers using the restricted token can then
|
|
83
|
+
construct `Ablo({ schema, authToken, transport: "http" })` because the project
|
|
84
|
+
already has a registered data plane.
|
|
85
|
+
|
|
86
|
+
## Link a message to a claim
|
|
87
|
+
|
|
88
|
+
The claim id is the causal id. Store it in `aboutIntentId` when the message is
|
|
89
|
+
about work currently protected by a claim.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const claim = await ablo.workItems.claim({
|
|
93
|
+
id: workItemId,
|
|
94
|
+
reason: "reformatting",
|
|
95
|
+
description: "normalizing the pricing table",
|
|
96
|
+
meta: { estimateSeconds: 120 },
|
|
97
|
+
queue: false,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await ablo.messages.create({
|
|
102
|
+
id: crypto.randomUUID(),
|
|
103
|
+
data: {
|
|
104
|
+
body: "Taking the pricing table for about two minutes.",
|
|
105
|
+
kind: "status",
|
|
106
|
+
teamId,
|
|
107
|
+
aboutEntityType: "workItems",
|
|
108
|
+
aboutEntityId: workItemId,
|
|
109
|
+
aboutIntentId: claim.claimId,
|
|
110
|
+
},
|
|
111
|
+
wait: "confirmed",
|
|
112
|
+
});
|
|
113
|
+
} finally {
|
|
114
|
+
await claim.release();
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Peers in `team:${teamId}` receive the message through the normal delta stream.
|
|
119
|
+
Peers outside that syncGroup do not.
|
|
120
|
+
|
|
121
|
+
## Reading messages
|
|
122
|
+
|
|
123
|
+
Live clients read locally and update when deltas arrive:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const rows = ablo.messages.getAll({
|
|
127
|
+
where: { teamId },
|
|
128
|
+
orderBy: { createdAt: "asc" },
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
HTTP agents read by request:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
const rows = await ablo.messages.list({
|
|
136
|
+
where: { teamId },
|
|
137
|
+
orderBy: { createdAt: "asc" },
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Because messages are ordinary synced rows, reconnecting clients catch up from
|
|
142
|
+
their cursor and see the rows they missed while offline.
|
|
143
|
+
|
|
144
|
+
## Retention
|
|
145
|
+
|
|
146
|
+
Deleting or archiving old `messages` rows is your app's policy. The sync log is
|
|
147
|
+
still durable audit/history: `sync_deltas` has no message-specific TTL. That is
|
|
148
|
+
useful for coordination and compliance, but a chat-scale product should plan
|
|
149
|
+
retention before writing high-volume conversation traffic.
|
package/docs/agents.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Agents
|
|
2
|
+
|
|
3
|
+
An agent is a **reactive** participant: it wakes on something happening, reads
|
|
4
|
+
what it needs, writes a result, and goes idle. That's a request/response
|
|
5
|
+
workload — so agents talk to Ablo over **plain HTTP**, holding no WebSocket. The
|
|
6
|
+
credential *is* the identity; the server resolves the org, scope, and actor from
|
|
7
|
+
the key on every request (the Stripe server-SDK / Liveblocks-node shape).
|
|
8
|
+
|
|
9
|
+
Humans get the live plane (WebSocket: presence, optimistic, sub-100ms). Agents
|
|
10
|
+
get the stateless plane (HTTP). **Both operate on the same typed, coordinated
|
|
11
|
+
state — and coordinate *with each other*.**
|
|
12
|
+
|
|
13
|
+
<Note>
|
|
14
|
+
Agents transact against your **pushed schema**, same as everyone — `ablo.tasks`
|
|
15
|
+
exists because you defined a `task` model and ran `ablo push`. The key
|
|
16
|
+
authenticates; the [schema](/quickstart) defines what you can call.
|
|
17
|
+
</Note>
|
|
18
|
+
|
|
19
|
+
## The agent client
|
|
20
|
+
|
|
21
|
+
Same `Ablo()` entry point as everywhere else — pass `transport: 'http'`. No
|
|
22
|
+
socket, no connection state — just your schema (for types) and an API key.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import Ablo from "@abloatai/ablo";
|
|
26
|
+
import { schema } from "./schema";
|
|
27
|
+
|
|
28
|
+
const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, transport: "http" });
|
|
29
|
+
|
|
30
|
+
// Reads + writes, fully typed off your schema — each is one HTTP round-trip:
|
|
31
|
+
const open = await ablo.tasks.list({ where: { status: "todo" } });
|
|
32
|
+
const { data } = await ablo.tasks.retrieve({ id: open[0].id });
|
|
33
|
+
await ablo.tasks.update({ id: open[0].id, data: { status: "done" } });
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
It exposes `retrieve` / `list` / `create` / `update` / `delete`, plus `commits`
|
|
37
|
+
and `claim`. It does **not** expose the stateful-only surface (`get` /
|
|
38
|
+
`getAll` / `getCount` local reads, `onChange` live subscription) — those need a
|
|
39
|
+
live connection, so with `transport: 'http'` the return type narrows and they
|
|
40
|
+
are a *compile error*, not a runtime surprise.
|
|
41
|
+
|
|
42
|
+
## Coordination — claim, queue, reorder
|
|
43
|
+
|
|
44
|
+
The differentiator. A claim is a **durable lease + FIFO wait-line** on a row —
|
|
45
|
+
"who's working on this, who's waiting" — and it's request/response, so an agent
|
|
46
|
+
holds it over HTTP. This is how two agents (or an agent and a human) don't
|
|
47
|
+
clobber the same record.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// Acquire a lease, do work with the held row, release on scope exit:
|
|
51
|
+
await using claim = await ablo.tasks.claim({ id: taskId });
|
|
52
|
+
const task = claim.data;
|
|
53
|
+
// …no one else can hold this row while you work…
|
|
54
|
+
await ablo.tasks.update({ id: task.id, data: { status: "in_review" } });
|
|
55
|
+
|
|
56
|
+
await ablo.tasks.claim.state({ id: taskId }); // who holds it now (or null)
|
|
57
|
+
await ablo.tasks.claim.queue({ id: taskId }); // the FIFO wait-line behind the holder
|
|
58
|
+
await ablo.tasks.claim.reorder({ id: taskId, order: line }); // re-rank the line (privileged)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Think of it as a queue per row — a durable, inspectable, reorderable lease line
|
|
62
|
+
("SQS for entity contention"). Use `{ queue: false }` for fail-fast dedup: *if
|
|
63
|
+
someone else has this job, skip it.*
|
|
64
|
+
|
|
65
|
+
## Messaging between agents
|
|
66
|
+
|
|
67
|
+
Use claim `description` and `meta` for live "what I am doing now" context. Use
|
|
68
|
+
ordinary synced rows for handoffs, status notes, and requests that must survive
|
|
69
|
+
reconnects or be readable by HTTP agents. The recipe is a `messages` model
|
|
70
|
+
scoped by the same syncGroup field as the work row, with `aboutIntentId` linking
|
|
71
|
+
a message back to the claim it discusses.
|
|
72
|
+
|
|
73
|
+
See [Agent Messaging](/agent-messaging) for the schema and direct
|
|
74
|
+
`databaseUrl` setup.
|
|
75
|
+
|
|
76
|
+
## Humans + agents on one state
|
|
77
|
+
|
|
78
|
+
There's no separate "agent mode." A human editing a record over their WebSocket
|
|
79
|
+
and an agent acting over HTTP share the same typed state and the same coordination
|
|
80
|
+
plane: the agent can claim the row a human is editing (and wait in line), and the
|
|
81
|
+
human sees the agent's committed changes stream in **live** — over the human's
|
|
82
|
+
own socket, even though the agent committed over HTTP. You write the agent once;
|
|
83
|
+
it's a first-class participant, not a bolt-on.
|
|
84
|
+
|
|
85
|
+
## How an agent runs
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
something happens ──▶ your agent (HTTP, no socket)
|
|
89
|
+
(a job, a webhook, read context (list/retrieve)
|
|
90
|
+
a queue message) claim → work → commit
|
|
91
|
+
done — no held connection
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Because it holds nothing open, an agent is a **stateless worker**: deploys and
|
|
95
|
+
restarts are free, and you scale by adding workers. A long-running fleet of idle
|
|
96
|
+
agents costs nothing on the live plane — that capacity stays for humans.
|
|
97
|
+
|
|
98
|
+
## What stays on the live (human) plane
|
|
99
|
+
|
|
100
|
+
`onChange` (live subscriptions) and `get`/`getAll`/`getCount` (local synced-pool
|
|
101
|
+
reads) require a WebSocket and a local store — they're for interactive UIs, not
|
|
102
|
+
stateless agents. An agent reacts to an external trigger (a job/queue/webhook),
|
|
103
|
+
then reads with `list`/`retrieve`. See [client behavior](/client-behavior) for
|
|
104
|
+
the full surface and [guarantees](/guarantees) for the coordination semantics.
|
package/docs/coordination.md
CHANGED
|
@@ -67,6 +67,73 @@ anything extra.
|
|
|
67
67
|
|
|
68
68
|
---
|
|
69
69
|
|
|
70
|
+
## Declaring conflict behaviour in the schema (Axis 3)
|
|
71
|
+
|
|
72
|
+
The two enforcement layers above are decided **per write** (claim a row, or pass
|
|
73
|
+
`readAt` + `onStale`). You can also declare a model's **default** conflict
|
|
74
|
+
disposition once, in the schema, so every commit to that model is governed
|
|
75
|
+
without per-call wiring. This is the third coordination axis — orthogonal to
|
|
76
|
+
`policy` (who may read a row) and `groups` (which delta channels it fans into).
|
|
77
|
+
|
|
78
|
+
Add a `conflict` map to `model(...)`, keyed by the **committer's** participant
|
|
79
|
+
kind (`user` / `agent` / `system`), with the same `onStale` vocabulary as values:
|
|
80
|
+
|
|
81
|
+
| value | meaning |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `'overwrite'` | the write wins; that committer is never blocked. |
|
|
84
|
+
| `'reject'` | the write is refused; that committer yields to a held claim / stale snapshot. |
|
|
85
|
+
| `'notify'` | hold the write and hand back the current value so the committer re-reads and re-applies (stale writes only). |
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { model, field } from '@abloatai/ablo/schema';
|
|
89
|
+
|
|
90
|
+
export const card = model('card', {
|
|
91
|
+
title: field.string(),
|
|
92
|
+
}, {
|
|
93
|
+
// "a human's edit always wins (never blocked); an agent yields"
|
|
94
|
+
conflict: { user: 'overwrite', agent: 'reject' },
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Composable helpers
|
|
99
|
+
|
|
100
|
+
For the same map with an authoring surface that reads like the rest of the DSL,
|
|
101
|
+
use the disposition helpers and the `coordination(...)` combinator (a `cn`/`cx`
|
|
102
|
+
for conflict policy — later rules win on key collisions):
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { model, field, coordination, humansOverwrite, agentsReject } from '@abloatai/ablo/schema';
|
|
106
|
+
|
|
107
|
+
export const card = model('card', {
|
|
108
|
+
title: field.string(),
|
|
109
|
+
}, {
|
|
110
|
+
conflict: coordination(humansOverwrite(), agentsReject()),
|
|
111
|
+
// → { user: 'overwrite', agent: 'reject' }
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Helpers, one per disposition: `humansOverwrite` / `humansReject` / `humansNotify`,
|
|
116
|
+
`agentsOverwrite` / `agentsReject` / `agentsNotify`,
|
|
117
|
+
`systemOverwrite` / `systemReject` / `systemNotify`.
|
|
118
|
+
|
|
119
|
+
### How it relates to per-write coordination
|
|
120
|
+
|
|
121
|
+
- The `conflict` map is **pure, serializable data**: it ships in your pushed
|
|
122
|
+
schema (`npx ablo push`) and the engine interprets it at the commit
|
|
123
|
+
chokepoint — there is no per-model code on the server.
|
|
124
|
+
- An **omitted committer kind falls through to the engine default**: reject, and
|
|
125
|
+
honor a per-write `onStale: 'notify'`. Declaring `conflict` is purely
|
|
126
|
+
additive — existing schemas behave exactly as before.
|
|
127
|
+
- It sets the **default** disposition; a per-write `onStale` and a held `claim`
|
|
128
|
+
still apply on top as described above. Think of `conflict` as "the house rule
|
|
129
|
+
for this model," and `claim` / `onStale` as what an individual write does
|
|
130
|
+
within it.
|
|
131
|
+
|
|
132
|
+
The `ConflictAxis` type (also available as `Ablo.Conflict.Axis`) and the
|
|
133
|
+
`interpretConflictAxis` interpreter are exported for composing custom policies.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
70
137
|
## The claim state object
|
|
71
138
|
|
|
72
139
|
The claim state object is the live record that a participant is coordinating work on
|
package/docs/identity.md
CHANGED
|
@@ -156,6 +156,40 @@ The identity is a **participant** — and a participant is either a human
|
|
|
156
156
|
[Agents are participants too](#agents-are-participants-too) below. Everything in
|
|
157
157
|
the next two sections applies to both.
|
|
158
158
|
|
|
159
|
+
## Your schema lives in a project; your users commit to it
|
|
160
|
+
|
|
161
|
+
The default is simple: your schema lives in a **project**, you push it once, and
|
|
162
|
+
every session you mint resolves against it. Your end-users **don't have Ablo
|
|
163
|
+
accounts** — your server's `sk_` mints an `ek_` per user, and by default that
|
|
164
|
+
session lands in your project's own org. All your users share one schema, one
|
|
165
|
+
data tenant, isolated from each other by sync-groups. That's the whole story for
|
|
166
|
+
most apps.
|
|
167
|
+
|
|
168
|
+
**Add-on — org-per-customer isolation.** If you need each customer to be its own
|
|
169
|
+
hard tenant (separate row-level isolation, optionally a separate database) you'd
|
|
170
|
+
otherwise have to re-push your schema into every customer's org. Instead, keep one
|
|
171
|
+
project as the home of your schema and point each customer's session's *schema*
|
|
172
|
+
at it while its *data* stays in the customer's org:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
await mintUserSessionKey({
|
|
176
|
+
apiKey: platformKey, // sk_ with the `ephemeral:mint-any-org` scope
|
|
177
|
+
userId,
|
|
178
|
+
organizationId, // DATA → this customer's org (RLS-isolated tenant)
|
|
179
|
+
schemaProject: { // SCHEMA → the project that owns your schema
|
|
180
|
+
organizationId: schemaOwnerOrgId,
|
|
181
|
+
projectId: schemaProjectId,
|
|
182
|
+
},
|
|
183
|
+
ttlSeconds: 3600,
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Server-side, the model **shape** loads from your schema project but column
|
|
188
|
+
enrichment and the tenant connection still target `organizationId` — so the
|
|
189
|
+
shared schema only *describes* the shape; the data plane stays the customer's and
|
|
190
|
+
can't cross-leak. Omit these fields for the default above. Requires a platform
|
|
191
|
+
`sk_` with `ephemeral:mint-any-org`.
|
|
192
|
+
|
|
159
193
|
## The two halves of scoping
|
|
160
194
|
|
|
161
195
|
Scoping is two declarations that meet in the middle. One describes the
|
package/docs/index.md
CHANGED
|
@@ -86,7 +86,6 @@ Three things stay true no matter how you use Ablo:
|
|
|
86
86
|
- [AI SDK Tool](./examples/ai-sdk-tool.md) — Put Ablo inside an AI SDK tool call.
|
|
87
87
|
- [Existing Python Backend](./examples/existing-python-backend.md) — Add multiplayer and future agent writes without replacing a Python API server.
|
|
88
88
|
- [Agent + Human](./examples/agent-human.md) — Yield when a human is editing the same report.
|
|
89
|
-
- [Agent Scoped to One Deck](./examples/scoped-agent.md) — Scope an agent to one entity with `scope` / `parent`; realtime for just that deck.
|
|
90
89
|
- [Server Agent](./examples/server-agent.md) — Schema-backed worker.
|
|
91
90
|
- [Next.js](./examples/nextjs.md) — App-router setup with React bindings.
|
|
92
91
|
|
package/docs/projects.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Projects
|
|
2
|
+
|
|
3
|
+
A **project** is the isolation unit inside your organization — the shape you
|
|
4
|
+
know from Neon or Supabase. Each app you build gets its own project, and each
|
|
5
|
+
project gets its own schema, its own sandbox/production data planes, and its
|
|
6
|
+
own API keys. Two teams in one org can ship two apps that never see each
|
|
7
|
+
other's models, keys, or rows.
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
organization
|
|
11
|
+
├── project: default ← every org has one; pre-project apps live here
|
|
12
|
+
│ ├── schema (sandbox + production artifacts)
|
|
13
|
+
│ ├── data planes (registered databases)
|
|
14
|
+
│ └── keys (sk_/rk_/ek_/pk_)
|
|
15
|
+
└── project: my-app ← npx ablo init creates this for a new app
|
|
16
|
+
├── schema
|
|
17
|
+
├── data planes
|
|
18
|
+
└── keys
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## The default project
|
|
22
|
+
|
|
23
|
+
Every organization has a **default** project. If you never create another
|
|
24
|
+
one, everything works exactly as if projects didn't exist — your keys, schema
|
|
25
|
+
pushes, and registered databases all belong to it. Keys minted before
|
|
26
|
+
projects existed are default-project keys automatically.
|
|
27
|
+
|
|
28
|
+
## Keys belong to exactly one project
|
|
29
|
+
|
|
30
|
+
A key's project is fixed at mint and can never be changed or overridden —
|
|
31
|
+
the same discipline as its sandbox/production mode. Everything a key mints
|
|
32
|
+
inherits its project: the short-lived session credentials (`ek_`), agent
|
|
33
|
+
keys (`rk_`), everything. There is no way to "switch projects" with an
|
|
34
|
+
existing key; you use a key minted for the project you mean.
|
|
35
|
+
|
|
36
|
+
Schema pushes, database registrations, reads, and writes all act on the
|
|
37
|
+
**key's** project:
|
|
38
|
+
|
|
39
|
+
- `npx ablo push` activates the schema for the pushing key's project — it
|
|
40
|
+
can never demote another project's schema.
|
|
41
|
+
- Registering a database (`DATABASE_URL`) attaches it to the key's project
|
|
42
|
+
and environment.
|
|
43
|
+
- A write or read against a model that belongs to **another** project in
|
|
44
|
+
your org fails with a typed `project_scope_denied` — never a silent empty
|
|
45
|
+
result, and never the misleading "unknown model, run ablo push".
|
|
46
|
+
|
|
47
|
+
## Sandboxes belong to a project
|
|
48
|
+
|
|
49
|
+
Production is singular per project; sandboxes are many. Each sandbox of a
|
|
50
|
+
project gets its **own data plane** (its own registered dev database) but
|
|
51
|
+
they all share the project's **one** sandbox schema — pushing the same
|
|
52
|
+
schema from a second sandbox doesn't create a second artifact, it just
|
|
53
|
+
provisions that sandbox's database.
|
|
54
|
+
|
|
55
|
+
## CLI
|
|
56
|
+
|
|
57
|
+
`npx ablo init` creates a project for your app automatically (slug derived
|
|
58
|
+
from your `package.json` name; `--project <slug>` to choose, `--no-project`
|
|
59
|
+
to stay on the org default). Manage projects any time:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx ablo projects list # all projects (default first)
|
|
63
|
+
npx ablo projects create my-app # create one (--name "Display Name")
|
|
64
|
+
npx ablo projects use my-app # set the ACTIVE project locally
|
|
65
|
+
npx ablo projects use default # back to the org default
|
|
66
|
+
npx ablo status # shows the active project
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The active project is a local targeting preference (stored next to `mode` in
|
|
70
|
+
your CLI config): new keys you mint pick it up. It never changes what an
|
|
71
|
+
existing key can reach — a key's project scope is decided server-side at
|
|
72
|
+
mint.
|
|
73
|
+
|
|
74
|
+
## API
|
|
75
|
+
|
|
76
|
+
Projects are a Stripe-shaped control-plane resource, authenticated with a
|
|
77
|
+
secret (`sk_`) key:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
curl https://api.abloatai.com/api/v1/projects \
|
|
81
|
+
-H "Authorization: Bearer $ABLO_API_KEY" # list
|
|
82
|
+
|
|
83
|
+
curl https://api.abloatai.com/api/v1/projects \
|
|
84
|
+
-H "Authorization: Bearer $ABLO_API_KEY" \
|
|
85
|
+
-H "content-type: application/json" \
|
|
86
|
+
-d '{"slug": "my-app", "name": "My App"}' # create
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A create with a taken slug fails with `project_slug_taken` (409). Reads of
|
|
90
|
+
another org's project ids 404 — never confirm existence across orgs.
|
|
91
|
+
|
|
92
|
+
## Errors
|
|
93
|
+
|
|
94
|
+
| Code | Status | Meaning |
|
|
95
|
+
|------|--------|---------|
|
|
96
|
+
| `project_scope_denied` | 403 | The model/resource belongs to another project in your org — use a key minted for that project. |
|
|
97
|
+
| `project_slug_taken` | 409 | A project with this slug already exists in the organization. |
|
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.16.
|
|
3
|
+
"version": "0.16.3",
|
|
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",
|