@abloatai/ablo 0.59.2 → 0.60.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 +60 -0
- package/README.md +1 -1
- package/dist/ai-sdk.js.map +1 -1
- package/dist/sessions.d.ts +3 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +2 -0
- package/dist/sessions.js.map +1 -0
- package/docs/agent-messaging.md +3 -2
- package/docs/agents.md +49 -32
- package/docs/api-keys.md +18 -12
- package/docs/client-behavior.md +6 -4
- package/docs/coordination.md +6 -3
- package/docs/customer-organizations.md +8 -19
- package/docs/deployment.md +7 -6
- package/docs/examples/existing-python-backend.md +15 -18
- package/docs/examples/nextjs.md +33 -66
- package/docs/examples/scoped-agent.md +4 -3
- package/docs/examples/server-agent.md +4 -3
- package/docs/groups.md +11 -12
- package/docs/identity.md +33 -29
- package/docs/integration-guide.md +12 -18
- package/docs/options.md +80 -29
- package/docs/react.md +8 -52
- package/docs/security.md +1 -1
- package/docs/sessions.md +97 -69
- package/docs/transports.md +124 -0
- package/examples/README.md +7 -0
- package/examples/terminal-showcase/index.ts +219 -0
- package/examples/terminal-showcase/schema.ts +12 -0
- package/llms.txt +9 -9
- package/package.json +10 -5
package/docs/examples/nextjs.md
CHANGED
|
@@ -28,6 +28,7 @@ app/
|
|
|
28
28
|
RecordEditor.tsx # Client: live updates
|
|
29
29
|
lib/
|
|
30
30
|
ablo.ts # Server Ablo client (holds ABLO_API_KEY)
|
|
31
|
+
sessions.ts # Server-only scoped-session issuer
|
|
31
32
|
ablo.schema.ts # shared schema
|
|
32
33
|
```
|
|
33
34
|
|
|
@@ -36,7 +37,7 @@ There are **two** Ablo clients, and the split is the whole point:
|
|
|
36
37
|
- **Server** (`lib/ablo.ts`) holds the secret `apiKey` (`sk_`). Used by RSCs,
|
|
37
38
|
Server Actions, and route handlers. Never imported into a client component.
|
|
38
39
|
- **Browser** (`app/providers.tsx`) holds **no secret**. It fetches a
|
|
39
|
-
short-lived per-user token (`ek_`) from a backend route via `
|
|
40
|
+
short-lived per-user token (`ek_`) from a backend route via `session.endpoint`.
|
|
40
41
|
|
|
41
42
|
Skipping the browser half is the most common setup mistake — the client then
|
|
42
43
|
has no credential and the engine fails to initialize with `session_expired`.
|
|
@@ -57,6 +58,19 @@ export const ablo = Ablo({
|
|
|
57
58
|
});
|
|
58
59
|
```
|
|
59
60
|
|
|
61
|
+
```ts
|
|
62
|
+
// lib/sessions.ts — server-only
|
|
63
|
+
import 'server-only';
|
|
64
|
+
|
|
65
|
+
import Sessions from '@abloatai/ablo/sessions';
|
|
66
|
+
import { schema } from './ablo.schema';
|
|
67
|
+
|
|
68
|
+
export const sessions = Sessions({
|
|
69
|
+
schema,
|
|
70
|
+
apiKey: process.env.ABLO_API_KEY,
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
60
74
|
## Session Route
|
|
61
75
|
|
|
62
76
|
The browser can't hold `sk_`, so a backend route mints a scoped, short-lived
|
|
@@ -67,80 +81,33 @@ from the request body.
|
|
|
67
81
|
|
|
68
82
|
```ts
|
|
69
83
|
// app/api/ablo-session/route.ts
|
|
70
|
-
import {
|
|
84
|
+
import { sessions } from '@/lib/sessions';
|
|
71
85
|
import { getCurrentUser } from '@/auth';
|
|
72
|
-
import { headers } from 'next/headers';
|
|
73
|
-
import {
|
|
74
|
-
credentialEndpointErrorSchema,
|
|
75
|
-
credentialEndpointSuccessSchema,
|
|
76
|
-
} from '@abloatai/ablo/auth';
|
|
77
|
-
|
|
78
|
-
const noStore = { 'Cache-Control': 'no-store' };
|
|
79
|
-
|
|
80
|
-
export async function POST(request: Request) {
|
|
81
|
-
if (!(await isSameOrigin(request))) {
|
|
82
|
-
return Response.json(
|
|
83
|
-
credentialEndpointErrorSchema.parse({
|
|
84
|
-
error: { code: 'origin_mismatch', message: 'Cross-origin mint rejected' },
|
|
85
|
-
}),
|
|
86
|
-
{ status: 403, headers: noStore },
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const user = await getCurrentUser();
|
|
91
|
-
if (!user) {
|
|
92
|
-
return Response.json(
|
|
93
|
-
credentialEndpointErrorSchema.parse({
|
|
94
|
-
error: { code: 'session_expired' },
|
|
95
|
-
}),
|
|
96
|
-
{ status: 401, headers: noStore },
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Query your membership table now—not when the login session was created.
|
|
101
|
-
// The helper reads the active workspace from server-side session state and
|
|
102
|
-
// returns null when the membership is stale or revoked.
|
|
103
|
-
const scope = await authorizeActiveWorkspace(user.id);
|
|
104
|
-
if (!scope) {
|
|
105
|
-
return Response.json(
|
|
106
|
-
credentialEndpointErrorSchema.parse({
|
|
107
|
-
error: { code: 'policy_denied', message: 'Workspace membership is stale or revoked' },
|
|
108
|
-
}),
|
|
109
|
-
{ status: 403, headers: noStore },
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const { token, expiresAt } = await ablo.sessions.create({
|
|
114
|
-
user: { id: user.id },
|
|
115
|
-
syncGroups: scope.syncGroups,
|
|
116
|
-
can: { records: ['read', 'create', 'update'] },
|
|
117
|
-
});
|
|
118
|
-
return Response.json(
|
|
119
|
-
credentialEndpointSuccessSchema.parse({
|
|
120
|
-
token,
|
|
121
|
-
expiresAt,
|
|
122
|
-
credentialKind: 'ephemeral',
|
|
123
|
-
}),
|
|
124
|
-
{ headers: noStore },
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
86
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
87
|
+
export const POST = sessions.handler({
|
|
88
|
+
authenticate: () => getCurrentUser(),
|
|
89
|
+
async grant({ principal: user }) {
|
|
90
|
+
// Query your membership table now—not when the login session was created.
|
|
91
|
+
const scope = await authorizeActiveWorkspace(user.id);
|
|
92
|
+
if (!scope) return null;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
user: { id: user.id },
|
|
96
|
+
groups: scope.groups,
|
|
97
|
+
can: { records: ['read', 'create', 'update'] },
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
});
|
|
134
101
|
```
|
|
135
102
|
|
|
136
103
|
`authorizeActiveWorkspace` is application code: it must query the authoritative
|
|
137
|
-
membership store and return server-derived
|
|
104
|
+
membership store and return server-derived groups. If fifteen-minute token
|
|
138
105
|
expiry is too slow for your revocation requirements, mint a shorter
|
|
139
106
|
`ttlSeconds` and revoke active sessions when membership changes.
|
|
140
107
|
|
|
141
108
|
## Provider
|
|
142
109
|
|
|
143
|
-
The browser client points `
|
|
110
|
+
The browser client points `session.endpoint` at that route and is handed to
|
|
144
111
|
`<AbloProvider>` as an instance. Build it once at module scope so the socket
|
|
145
112
|
isn't torn down on every render.
|
|
146
113
|
|
|
@@ -154,7 +121,7 @@ import { schema } from '@/lib/ablo.schema';
|
|
|
154
121
|
|
|
155
122
|
const ablo = Ablo({
|
|
156
123
|
schema,
|
|
157
|
-
|
|
124
|
+
session: { endpoint: '/api/ablo-session' },
|
|
158
125
|
});
|
|
159
126
|
|
|
160
127
|
export function Providers({ children }: { children: React.ReactNode }) {
|
|
@@ -59,21 +59,22 @@ never does), then hand the short-lived token to the browser client:
|
|
|
59
59
|
```ts
|
|
60
60
|
// server — mints a scoped agent session for one workspace
|
|
61
61
|
import Ablo from '@abloatai/ablo';
|
|
62
|
+
import Sessions from '@abloatai/ablo/sessions';
|
|
62
63
|
import { syncGroup } from '@abloatai/ablo/schema';
|
|
63
64
|
import { schema } from './schema';
|
|
64
65
|
|
|
65
|
-
const
|
|
66
|
+
const sessions = Sessions({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
66
67
|
|
|
67
68
|
export async function mintProjectAgentSession(
|
|
68
69
|
workspaceId: string,
|
|
69
70
|
agentId: string,
|
|
70
71
|
requestingUserId: string,
|
|
71
72
|
) {
|
|
72
|
-
const { token } = await
|
|
73
|
+
const { token } = await sessions.create({
|
|
73
74
|
agent: { id: agentId },
|
|
74
75
|
onBehalfOf: { user: { id: requestingUserId } },
|
|
75
76
|
can: { records: ['read', 'update'] }, // operation allowlist for this run
|
|
76
|
-
|
|
77
|
+
groups: [syncGroup('workspace', workspaceId)], // narrowed to just this workspace
|
|
77
78
|
});
|
|
78
79
|
return token;
|
|
79
80
|
}
|
|
@@ -19,6 +19,7 @@ fresh row, then hands it to you — so two writers serialize instead of clobberi
|
|
|
19
19
|
|
|
20
20
|
```ts
|
|
21
21
|
import Ablo from '@abloatai/ablo';
|
|
22
|
+
import Sessions from '@abloatai/ablo/sessions';
|
|
22
23
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
23
24
|
|
|
24
25
|
const schema = defineSchema({
|
|
@@ -29,17 +30,17 @@ const schema = defineSchema({
|
|
|
29
30
|
}),
|
|
30
31
|
});
|
|
31
32
|
|
|
32
|
-
const
|
|
33
|
+
const sessions = Sessions({
|
|
33
34
|
schema,
|
|
34
35
|
apiKey: process.env.ABLO_API_KEY,
|
|
35
36
|
});
|
|
36
37
|
|
|
37
38
|
async function clientForWorker(workerId: string) {
|
|
38
|
-
const
|
|
39
|
+
const session = await sessions.create({
|
|
39
40
|
agent: { id: workerId },
|
|
40
41
|
can: { records: ['read', 'update'] },
|
|
41
42
|
});
|
|
42
|
-
return Ablo({ schema,
|
|
43
|
+
return Ablo({ schema, session, transport: 'http' });
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export async function completeTask(recordId: string, workerId: string) {
|
package/docs/groups.md
CHANGED
|
@@ -38,16 +38,15 @@ write. The agent never persists work built on a premise it can no longer see.
|
|
|
38
38
|
|
|
39
39
|
## How you hear about it
|
|
40
40
|
|
|
41
|
-
Three channels
|
|
42
|
-
|
|
41
|
+
Three channels answer three different coordination questions. Pick by the
|
|
42
|
+
question you have.
|
|
43
43
|
|
|
44
44
|
```ts
|
|
45
45
|
// A screen that stays current.
|
|
46
46
|
ablo.records.onChange((docs) => render(docs));
|
|
47
47
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
room.peers;
|
|
48
|
+
// In React: who else is visible on this client's scoped groups?
|
|
49
|
+
const peers = usePeers({ records: documentId });
|
|
51
50
|
|
|
52
51
|
// Stop this write if the thing I read moved while I composed it.
|
|
53
52
|
const record = await ablo.records.read({ id: 's-1' });
|
|
@@ -58,12 +57,12 @@ await ablo.blocks.update({ id, data, reads: [record] });
|
|
|
58
57
|
| Question | Channel | Arrives |
|
|
59
58
|
| --- | --- | --- |
|
|
60
59
|
| What do the rows say right now? | `onChange` | As deltas land, on the socket |
|
|
61
|
-
| Who else is working here? | `
|
|
60
|
+
| Who else is working here? | `usePeers` over the session/client groups | As participants connect, disconnect, or change activity |
|
|
62
61
|
| Did the premise for **this** write move? | `reads` on the write | On that write's receipt, before it applies |
|
|
63
62
|
|
|
64
|
-
`onChange` and `
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
`onChange` and `usePeers` use the reactive client's socket. `reads` rides the
|
|
64
|
+
commit, so it reaches a socketless actor over HTTP too. The row returned by
|
|
65
|
+
`read` privately carries its model, id, and
|
|
67
66
|
watermark; passing that row in `reads` is enough to protect a later write. Ablo
|
|
68
67
|
does not retain the row contents as read evidence.
|
|
69
68
|
|
|
@@ -184,6 +183,6 @@ them coarse everywhere else.
|
|
|
184
183
|
[`concurrency-convention.md`](./concurrency-convention.md) (§4 and §5).
|
|
185
184
|
- **The mechanics**, the three coordination blocks underneath, are
|
|
186
185
|
[`coordination.md`](./coordination.md).
|
|
187
|
-
-
|
|
188
|
-
[`
|
|
189
|
-
[`
|
|
186
|
+
- **Presence** is read with `usePeers`; active exclusions remain on the
|
|
187
|
+
`claim` namespace. See [`react.md`](./react.md) and
|
|
188
|
+
[`coordination.md`](./coordination.md).
|
package/docs/identity.md
CHANGED
|
@@ -51,10 +51,13 @@ secret mints a replacement, least-privilege agent credential with the schema-
|
|
|
51
51
|
typed grant:
|
|
52
52
|
|
|
53
53
|
```ts
|
|
54
|
-
|
|
54
|
+
import Sessions from '@abloatai/ablo/sessions';
|
|
55
|
+
|
|
56
|
+
const sessions = Sessions({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
57
|
+
const session = await sessions.create({
|
|
55
58
|
agent: { id: agentId },
|
|
56
59
|
can: { records: ['read', 'update'] },
|
|
57
|
-
|
|
60
|
+
groups: [syncGroup('workspace', workspaceId)],
|
|
58
61
|
});
|
|
59
62
|
```
|
|
60
63
|
|
|
@@ -87,7 +90,7 @@ runnable place, so the concepts below have code to attach to.
|
|
|
87
90
|
|
|
88
91
|
The entire declaration surface is: `identityRoles` (who may see what), and on
|
|
89
92
|
each model `scope` / `parent` / `grants` (which group a row fans out on), plus
|
|
90
|
-
optional `
|
|
93
|
+
optional `groups` at session-mint time (narrowing). Read the three blocks first —
|
|
91
94
|
a human gets their `org` / `team` scope, an agent gets one `workspace` — then the
|
|
92
95
|
sections after explain each.
|
|
93
96
|
|
|
@@ -135,16 +138,16 @@ export const schema = defineSchema(
|
|
|
135
138
|
```ts
|
|
136
139
|
// 3. an AGENT run inherits its user, narrowed to the entities in play.
|
|
137
140
|
// You narrow at SESSION-MINT time: your backend calls `sessions.create` with the
|
|
138
|
-
// agent's allowed
|
|
141
|
+
// agent's allowed groups, built from each model's scope via the
|
|
139
142
|
// `syncGroup(kind, id)` helper — never a hand-built `workspace:<id>` string. The agent's
|
|
140
143
|
// runtime then connects with the minted token.
|
|
141
|
-
const session = await
|
|
144
|
+
const session = await sessions.create({
|
|
142
145
|
agent: { id: agentId },
|
|
143
146
|
can: { Workspace: ['read', 'update'] },
|
|
144
|
-
|
|
147
|
+
groups: [syncGroup('workspace', workspaceId)], // floor: just the workspace it's working on
|
|
145
148
|
});
|
|
146
149
|
// the agent runtime authenticates with the minted token
|
|
147
|
-
const ablo = Ablo({ schema,
|
|
150
|
+
const ablo = Ablo({ schema, session });
|
|
148
151
|
```
|
|
149
152
|
|
|
150
153
|
That's the whole surface. The rest of this doc is the *why* behind each line.
|
|
@@ -180,8 +183,8 @@ That's why you never write per-user scope code, but you always choose an agent's
|
|
|
180
183
|
groups at the dispatch site. A user's org/team/user don't change per request, so
|
|
181
184
|
their scope is a **rule the schema derives automatically**. An agent's reach
|
|
182
185
|
depends on *what it's working on*, which is only knowable at dispatch — so you
|
|
183
|
-
pass its `
|
|
184
|
-
(`sessions.create({ agent, can,
|
|
186
|
+
pass its `groups` **when your backend mints the agent session**
|
|
187
|
+
(`sessions.create({ agent, can, groups })`). The schema's
|
|
185
188
|
only job for entities is to declare *that* a model is
|
|
186
189
|
entity-scopable and *what its group is named* (`scope: 'workspace'` → `workspace:{id}`);
|
|
187
190
|
it never declares *which* entities a given agent gets. (A human can opt into the
|
|
@@ -224,7 +227,7 @@ stays in the customer's org:
|
|
|
224
227
|
```ts
|
|
225
228
|
const server = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
226
229
|
|
|
227
|
-
await
|
|
230
|
+
await sessions.create({
|
|
228
231
|
user: { id: userId },
|
|
229
232
|
organizationId, // DATA → this customer's RLS-isolated organization
|
|
230
233
|
can: { records: ['read', 'update'] },
|
|
@@ -405,7 +408,7 @@ server, never by the browser.**
|
|
|
405
408
|
The identity your server resolved is carried by the client you build and the
|
|
406
409
|
`userId` prop. In a Next.js app, resolve the user in a Server Component and pass
|
|
407
410
|
it down. Build the client once (the schema, `teamIds`, and the `apiKey` resolver
|
|
408
|
-
live here; entity narrowing rides the minted session's `
|
|
411
|
+
live here; entity narrowing rides the minted session's `groups`), then hand
|
|
409
412
|
it to the provider:
|
|
410
413
|
|
|
411
414
|
```ts
|
|
@@ -418,10 +421,10 @@ import { schema } from '@/ablo/schema';
|
|
|
418
421
|
export function makeAblo(user: { teamIds: string[] }) {
|
|
419
422
|
return Ablo({
|
|
420
423
|
schema,
|
|
421
|
-
// The browser holds no secret — `
|
|
424
|
+
// The browser holds no secret — `session.endpoint` points at the route that
|
|
422
425
|
// mints the short-lived session token, and the client keeps it fresh
|
|
423
426
|
// before expiry.
|
|
424
|
-
|
|
427
|
+
session: { endpoint: '/api/ablo-session' },
|
|
425
428
|
teamIds: user.teamIds,
|
|
426
429
|
});
|
|
427
430
|
}
|
|
@@ -457,7 +460,7 @@ What carries identity — and just as importantly, what does *not* set the bound
|
|
|
457
460
|
| ------------ | ------------------------------------------------------------------------------------------------ |
|
|
458
461
|
| `userId` prop | 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. |
|
|
459
462
|
| `teamIds` (on the client) | Team ids expanded into team sync groups via your `identityRoles`. |
|
|
460
|
-
| `
|
|
463
|
+
| `groups` (at session mint) | Optional. **Narrows** a minted session's subscription to a subset of what auth already allows: it can never widen it. Passed to `sessions.create({ user \| agent, groups })`; build entries with `syncGroup(kind, id)`. Use it to scope an agent (or a focused page's session) to one entity, e.g. `[syncGroup('workspace', 'abc123')]`. |
|
|
461
464
|
|
|
462
465
|
Because the server is the boundary, a client that changes `userId` to another
|
|
463
466
|
user's id does not gain their data — the server resolves and enforces the real
|
|
@@ -503,15 +506,15 @@ subset of what its user could see:
|
|
|
503
506
|
// agent run triggered by `user`, working on one document + one workspace.
|
|
504
507
|
// Your backend mints the agent session narrowed to just the entities in play
|
|
505
508
|
// (the floor). Build each group from the model's scope with `syncGroup(kind, id)`.
|
|
506
|
-
const session = await
|
|
509
|
+
const session = await sessions.create({
|
|
507
510
|
agent: { id: agentId },
|
|
508
511
|
onBehalfOf: { user: { id: triggeringUser.id } },
|
|
509
512
|
can: { Document: ['read', 'update'], Workspace: ['read', 'update'] },
|
|
510
|
-
|
|
513
|
+
groups: [syncGroup('document', recordId), syncGroup('workspace', workspaceId)],
|
|
511
514
|
});
|
|
512
515
|
// identity (the ceiling) is inherited from the triggering user via your
|
|
513
516
|
// session-mint logic; the agent runtime connects with the minted token.
|
|
514
|
-
const ablo = Ablo({ schema,
|
|
517
|
+
const ablo = Ablo({ schema, session });
|
|
515
518
|
```
|
|
516
519
|
|
|
517
520
|
As the run touches more entities, claim or read them and the client auto-enrolls
|
|
@@ -555,22 +558,22 @@ an agent pointed at the entities it's working on. You **never hand-write**
|
|
|
555
558
|
`workspace:<id>`; build groups from the model's `scope` (Half 2) with the typed
|
|
556
559
|
`syncGroup(kind, id)` helper from `@abloatai/ablo/schema`.
|
|
557
560
|
|
|
558
|
-
1. **At session mint — `
|
|
561
|
+
1. **At session mint — `groups`.** When your backend mints a session, pass the
|
|
559
562
|
exact groups it may subscribe to. This is the floor for a delegated agent (and
|
|
560
563
|
the way to scope a focused page's session):
|
|
561
564
|
|
|
562
565
|
```ts
|
|
563
566
|
// an agent working across two workspaces and a document
|
|
564
|
-
const session = await
|
|
567
|
+
const session = await sessions.create({
|
|
565
568
|
agent: { id: agentId },
|
|
566
569
|
can: { Workspace: ['read', 'update'], Document: ['read'] },
|
|
567
|
-
|
|
570
|
+
groups: [
|
|
568
571
|
syncGroup('workspace', collectionA),
|
|
569
572
|
syncGroup('workspace', collectionB),
|
|
570
573
|
syncGroup('document', docId),
|
|
571
574
|
],
|
|
572
575
|
});
|
|
573
|
-
const ablo = Ablo({ schema,
|
|
576
|
+
const ablo = Ablo({ schema, session });
|
|
574
577
|
```
|
|
575
578
|
|
|
576
579
|
2. **Automatically, on read or claim.** Reading a row (`get`/
|
|
@@ -578,10 +581,11 @@ an agent pointed at the entities it's working on. You **never hand-write**
|
|
|
578
581
|
(**read-interest**), and `claim`-ing it pins a **write-intent** subscription.
|
|
579
582
|
So an agent's reachable set **accretes** as it works — no extra subscribe call.
|
|
580
583
|
|
|
581
|
-
3. **
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
584
|
+
3. **Presence follows those same groups.** A reactive client announces one
|
|
585
|
+
participant on its connection; `usePeers(scope)` filters the roster already
|
|
586
|
+
visible through the session and connection groups. There is no second
|
|
587
|
+
membership lease or participant handle. Use `claim` separately when work needs
|
|
588
|
+
exclusion. See [React](./react.md#usepeers-read-only-presence).
|
|
585
589
|
|
|
586
590
|
> **`groups.root` is the schema model option, not a client setting.**
|
|
587
591
|
> `groups: { root: 'workspace' }` in `model(...)` declares a scope root
|
|
@@ -593,8 +597,8 @@ an agent pointed at the entities it's working on. You **never hand-write**
|
|
|
593
597
|
> doesn't share the word.
|
|
594
598
|
|
|
595
599
|
> **Requested groups never grant.** At connect, the server intersects the session's
|
|
596
|
-
>
|
|
597
|
-
> So `
|
|
600
|
+
> requested groups with what the identity is actually allowed (`requested ∩ allowed`).
|
|
601
|
+
> So `groups` only ever *narrows* within a participant's ceiling — an agent
|
|
598
602
|
> can't reach a workspace its capability doesn't already permit, no matter what it
|
|
599
603
|
> passes. Smaller bootstrap, less fan-out, same server-enforced boundary.
|
|
600
604
|
|
|
@@ -619,7 +623,7 @@ how to reason about it.
|
|
|
619
623
|
the room/shape and the server signs off, as in
|
|
620
624
|
[Pusher's channel authorization endpoint](https://pusher.com/docs/channels/server_api/authorizing-users/),
|
|
621
625
|
[ElectricSQL **gatekeeper auth**](https://github.com/electric-sql/electric/blob/main/examples/gatekeeper-auth/README.md),
|
|
622
|
-
and Liveblocks **access tokens**. Ablo's session-mint `
|
|
626
|
+
and Liveblocks **access tokens**. Ablo's session-mint `groups` is the
|
|
623
627
|
*narrowing* half of this — but it can only ever shrink the server-derived set,
|
|
624
628
|
never grow it.
|
|
625
629
|
|
|
@@ -637,7 +641,7 @@ The best practices Ablo inherits from that lineage:
|
|
|
637
641
|
the line precisely: [token parameters are trusted and usable for access
|
|
638
642
|
control; client parameters are not](https://docs.powersync.com/usage/sync-rules/advanced-topics/client-parameters).
|
|
639
643
|
In Ablo terms, the identity your server vouches for — and the session's
|
|
640
|
-
`
|
|
644
|
+
`groups`, minted server-side — are the *trusted* claims that set scope; the
|
|
641
645
|
`userId` prop is *untrusted client input* — convenient for app-owned fields, but
|
|
642
646
|
never the boundary. This is why changing `userId` in the browser grants nothing.
|
|
643
647
|
|
|
@@ -30,7 +30,6 @@ The normal integration is one client:
|
|
|
30
30
|
|
|
31
31
|
```ts
|
|
32
32
|
import Ablo from '@abloatai/ablo';
|
|
33
|
-
import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth';
|
|
34
33
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
35
34
|
```
|
|
36
35
|
|
|
@@ -183,7 +182,7 @@ import { schema } from '@/ablo/schema';
|
|
|
183
182
|
// from your session route (see below) and refreshes it before expiry.
|
|
184
183
|
export const ablo = Ablo({
|
|
185
184
|
schema,
|
|
186
|
-
|
|
185
|
+
session: { endpoint: '/api/ablo-session' },
|
|
187
186
|
});
|
|
188
187
|
```
|
|
189
188
|
|
|
@@ -203,29 +202,24 @@ The session route mints the scoped token server-side, where the API key lives:
|
|
|
203
202
|
|
|
204
203
|
```ts
|
|
205
204
|
// app/api/ablo-session/route.ts
|
|
206
|
-
import
|
|
205
|
+
import Sessions from '@abloatai/ablo/sessions';
|
|
207
206
|
import { schema } from '@/ablo/schema';
|
|
208
207
|
import { auth } from '@/auth';
|
|
209
208
|
|
|
210
209
|
export const runtime = 'nodejs';
|
|
211
210
|
|
|
212
|
-
const
|
|
211
|
+
const sessions = Sessions({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
213
212
|
|
|
214
|
-
export
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
user
|
|
213
|
+
export const POST = sessions.handler({
|
|
214
|
+
async authenticate() {
|
|
215
|
+
const session = await auth();
|
|
216
|
+
return session?.user ?? null;
|
|
217
|
+
},
|
|
218
|
+
grant: ({ principal: user }) => ({
|
|
219
|
+
user: { id: user.id },
|
|
218
220
|
can: { records: ['read', 'update'] },
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
credentialEndpointSuccessSchema.parse({
|
|
222
|
-
token,
|
|
223
|
-
expiresAt,
|
|
224
|
-
credentialKind: 'ephemeral',
|
|
225
|
-
}),
|
|
226
|
-
{ headers: { 'Cache-Control': 'no-store' } },
|
|
227
|
-
);
|
|
228
|
-
}
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
229
223
|
```
|
|
230
224
|
|
|
231
225
|
### Why two credential shapes
|
package/docs/options.md
CHANGED
|
@@ -12,8 +12,10 @@ import { schema } from './ablo/schema';
|
|
|
12
12
|
export const ablo = Ablo({ schema });
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
These options configure the
|
|
16
|
-
|
|
15
|
+
These options configure the package-root coordination client. API-key clients
|
|
16
|
+
use HTTP; session clients use one multiplexed WebSocket by default. For a live
|
|
17
|
+
human interface with a local graph, use the [React guide](./react.md). See
|
|
18
|
+
[Transports](./transports.md) for the lifecycle and selection rules.
|
|
17
19
|
|
|
18
20
|
## schema
|
|
19
21
|
|
|
@@ -30,26 +32,55 @@ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
|
30
32
|
```
|
|
31
33
|
|
|
32
34
|
Use a resolver for credentials that rotate. Return `null` when the login has
|
|
33
|
-
ended; throw when credential resolution failed temporarily. Do not
|
|
34
|
-
|
|
35
|
+
ended; throw when credential resolution failed temporarily. Do not combine it
|
|
36
|
+
with another credential option.
|
|
35
37
|
|
|
36
|
-
##
|
|
38
|
+
## session
|
|
37
39
|
|
|
38
|
-
A
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
A scoped session returned by the server-only `Sessions(...).create()` issuer.
|
|
41
|
+
Pass the resource
|
|
42
|
+
directly for bounded work, or pass a provider that re-mints the same logical
|
|
43
|
+
actor for a long-running client:
|
|
41
44
|
|
|
42
45
|
```ts
|
|
43
|
-
|
|
46
|
+
import Sessions from '@abloatai/ablo/sessions';
|
|
47
|
+
|
|
48
|
+
const sessions = Sessions({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
49
|
+
const session = await sessions.create({
|
|
50
|
+
agent: { id: workerId },
|
|
51
|
+
can: { records: ['read', 'update'] },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const worker = Ablo({ schema, session });
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Do not extract `session.token` into `apiKey`; the session is the public handoff.
|
|
58
|
+
A provider is cached until its returned session approaches `expiresAt`, so HTTP
|
|
59
|
+
bootstrap and WebSocket connection setup do not mint duplicate credentials.
|
|
60
|
+
|
|
61
|
+
In a browser, name the application-owned route that mints the signed-in user's
|
|
62
|
+
session. Endpoint tuning belongs inside the same option:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const ablo = Ablo({
|
|
66
|
+
schema,
|
|
67
|
+
session: {
|
|
68
|
+
endpoint: '/api/ablo-session',
|
|
69
|
+
timeoutMs: 10_000,
|
|
70
|
+
// allowCrossOrigin: true,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
44
73
|
```
|
|
45
74
|
|
|
46
|
-
|
|
75
|
+
The client sends a cookie-bearing `POST`, validates the response, and renews
|
|
76
|
+
before expiry. Cross-origin minting is off by default and should be enabled only
|
|
77
|
+
after configuring CORS and CSRF protection.
|
|
47
78
|
|
|
48
79
|
## authToken
|
|
49
80
|
|
|
50
81
|
A bearer token the caller already holds. This is mainly for self-hosted or
|
|
51
82
|
custom authentication layers. Hosted applications normally use `apiKey` or
|
|
52
|
-
`
|
|
83
|
+
`session`.
|
|
53
84
|
|
|
54
85
|
## baseURL
|
|
55
86
|
|
|
@@ -68,7 +99,7 @@ local hosts.
|
|
|
68
99
|
|
|
69
100
|
Allows a credential-bearing client to run in a browser. Defaults to `false`.
|
|
70
101
|
|
|
71
|
-
Private API keys must not ship to browsers. Prefer `
|
|
102
|
+
Private API keys must not ship to browsers. Prefer `session.endpoint`; enable this
|
|
72
103
|
option only when the browser receives a narrowly scoped session credential or
|
|
73
104
|
all traffic passes through a controlled server proxy.
|
|
74
105
|
|
|
@@ -77,19 +108,6 @@ all traffic passes through a controlled server proxy.
|
|
|
77
108
|
A custom `fetch` implementation for tests, proxies, or runtimes without the
|
|
78
109
|
standard global implementation.
|
|
79
110
|
|
|
80
|
-
## authTimeoutMs
|
|
81
|
-
|
|
82
|
-
The deadline in milliseconds for a request to `authEndpoint`. Defaults to
|
|
83
|
-
`10000`. This is separate from `timeoutMs`, which covers ordinary Ablo API
|
|
84
|
-
requests.
|
|
85
|
-
|
|
86
|
-
## allowCrossOriginAuthEndpoint
|
|
87
|
-
|
|
88
|
-
Allows `authEndpoint` to use a different origin. Defaults to `false`.
|
|
89
|
-
|
|
90
|
-
Keep the default unless the credential-minting service intentionally lives on a
|
|
91
|
-
different trusted origin.
|
|
92
|
-
|
|
93
111
|
## bootstrapBaseUrl
|
|
94
112
|
|
|
95
113
|
Overrides the URL used for credential exchange and bootstrap. Most applications
|
|
@@ -108,7 +126,7 @@ const ablo = Ablo({
|
|
|
108
126
|
```
|
|
109
127
|
|
|
110
128
|
Do not use this option to duplicate the credential header; authentication is
|
|
111
|
-
owned by `
|
|
129
|
+
owned by `session`, `apiKey`, or `authToken`.
|
|
112
130
|
|
|
113
131
|
## defaultQuery
|
|
114
132
|
|
|
@@ -160,9 +178,10 @@ workflow or deployment lanes.
|
|
|
160
178
|
|
|
161
179
|
## transport
|
|
162
180
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
181
|
+
Transport follows the configured identity. `apiKey` and `authToken` clients use
|
|
182
|
+
request/response HTTP. `session` clients use one reconnecting WebSocket. Pass
|
|
183
|
+
`transport: 'http'` only when bounded work needs a scoped session identity but
|
|
184
|
+
must not hold a socket. The model, commit, claim, and context API stays unchanged.
|
|
166
185
|
|
|
167
186
|
Local materialized state and reactive reads still belong to the human client
|
|
168
187
|
described in the [React guide](./react.md); selecting WebSocket here adds a
|
|
@@ -172,3 +191,35 @@ carrier, not the human materializer.
|
|
|
172
191
|
|
|
173
192
|
The deadline in milliseconds for an Ablo HTTP request. Defaults to `30000`. Pass
|
|
174
193
|
`0` only when the surrounding runtime already enforces a deadline.
|
|
194
|
+
|
|
195
|
+
## groups
|
|
196
|
+
|
|
197
|
+
The initial groups observed by a WebSocket client. This can narrow what the
|
|
198
|
+
connection follows, but it cannot widen the authority granted by
|
|
199
|
+
`sessions.create({ groups })`.
|
|
200
|
+
|
|
201
|
+
## collaborationEvents
|
|
202
|
+
|
|
203
|
+
Application-defined WebSocket event names accepted by `subscribe()`.
|
|
204
|
+
|
|
205
|
+
## cursorStore
|
|
206
|
+
|
|
207
|
+
Persistent storage for the WebSocket observation cursor, used to resume after a
|
|
208
|
+
disconnect without replaying already-consumed events.
|
|
209
|
+
|
|
210
|
+
## cursorKey
|
|
211
|
+
|
|
212
|
+
The stable name used for the persisted observation cursor. Defaults to
|
|
213
|
+
`default`.
|
|
214
|
+
|
|
215
|
+
## reconnectDelay
|
|
216
|
+
|
|
217
|
+
Initial WebSocket reconnect delay in milliseconds.
|
|
218
|
+
|
|
219
|
+
## maxReconnectDelay
|
|
220
|
+
|
|
221
|
+
Maximum WebSocket reconnect delay in milliseconds after backoff.
|
|
222
|
+
|
|
223
|
+
## connectTimeoutMs
|
|
224
|
+
|
|
225
|
+
The deadline in milliseconds for establishing a WebSocket connection.
|