@abloatai/ablo 0.59.2 → 0.61.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.
@@ -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 `authEndpoint`.
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 { ablo } from '@/lib/ablo';
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
- async function isSameOrigin(request: Request): Promise<boolean> {
129
- const origin = request.headers.get('origin');
130
- if (!origin) return request.headers.get('sec-fetch-site') !== 'cross-site';
131
- const host = (await headers()).get('host');
132
- return host !== null && new URL(origin).host === host;
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 sync groups. If fifteen-minute token
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 `authEndpoint` at that route and is handed to
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
- authEndpoint: '/api/ablo-session',
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 server = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
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 server.sessions.create({
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
- syncGroups: [syncGroup('workspace', workspaceId)], // narrowed to just this workspace
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 control = Ablo({
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 { token } = await control.sessions.create({
39
+ const session = await sessions.create({
39
40
  agent: { id: workerId },
40
41
  can: { records: ['read', 'update'] },
41
42
  });
42
- return Ablo({ schema, apiKey: token, transport: 'http' });
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 carry "something changed", and they answer three different
42
- questions. Pick by the question you have.
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
- // Who else is in here, and what are they holding.
49
- await using room = await ablo.records.join(documentIds, { ttl: '5m' });
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? | `join`, then `room.peers` and `room.claims` | As participants come and go, on the socket |
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 `join` need a live socket, so they are available on the default
65
- WebSocket client. `reads` rides the commit, so it reaches a socketless actor over
66
- HTTP too. The row returned by `read` privately carries its model, id, and
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
- - **`join` and presence**, the participant half of the table above, are
188
- [`coordination.md`](./coordination.md) for the claim stream and
189
- [`react.md`](./react.md) for `useJoin`.
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
- const session = await control.sessions.create({
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
- syncGroups: [syncGroup('workspace', workspaceId)],
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 `syncGroups` at session-mint time (narrowing). Read the three blocks first —
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 `syncGroups`, built from each model's scope via the
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 server.sessions.create({
144
+ const session = await sessions.create({
142
145
  agent: { id: agentId },
143
146
  can: { Workspace: ['read', 'update'] },
144
- syncGroups: [syncGroup('workspace', workspaceId)], // floor: just the workspace it's working on
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, apiKey: session.token });
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 `syncGroups` **when your backend mints the agent session**
184
- (`sessions.create({ agent, can, syncGroups })`). The schema's
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 server.sessions.create({
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 `syncGroups`), then hand
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 — `authEndpoint` points at the route that
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
- authEndpoint: '/api/ablo-session',
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
- | `syncGroups` (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, syncGroups })`; 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')]`. |
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 server.sessions.create({
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
- syncGroups: [syncGroup('document', recordId), syncGroup('workspace', workspaceId)],
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, apiKey: session.token });
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 — `syncGroups`.** When your backend mints a session, pass the
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 server.sessions.create({
567
+ const session = await sessions.create({
565
568
  agent: { id: agentId },
566
569
  can: { Workspace: ['read', 'update'], Document: ['read'] },
567
- syncGroups: [
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, apiKey: session.token });
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. **Explicitly, for presence `join`.** To hold presence on a known set of rows
582
- and react to peers, use the WebSocket-only `ablo.<model>.join(ids, { ttl })`
583
- (it returns a participant handle with `.peers`). See
584
- [Coordination](./coordination.md).
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
- > `syncGroups` with what the identity is actually allowed (`requested ∩ allowed`).
597
- > So `syncGroups` only ever *narrows* within a participant's ceiling — an agent
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 `syncGroups` is the
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
- `syncGroups`, minted server-side — are the *trusted* claims that set scope; the
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
- authEndpoint: '/api/ablo-session',
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 Ablo from '@abloatai/ablo';
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 sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
211
+ const sessions = Sessions({ schema, apiKey: process.env.ABLO_API_KEY });
213
212
 
214
- export async function POST() {
215
- const session = await auth(); // your own auth — returns the signed-in user
216
- const { token, expiresAt } = await sync.sessions.create({
217
- user: { id: session.userId },
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
- return Response.json(
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 stateless HTTP client exported by the package root.
16
- For a live human interface, use the [React guide](./react.md).
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 pass both
34
- `apiKey` and `authEndpoint`.
35
+ ended; throw when credential resolution failed temporarily. Do not combine it
36
+ with another credential option.
35
37
 
36
- ## authEndpoint
38
+ ## session
37
39
 
38
- A same-origin URL that mints a short-lived credential, or an async credential
39
- resolver. The client sends a `POST` with cookies included and renews the token
40
- when needed.
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
- const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
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
- Use this instead of placing a private API key in browser code.
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
- `authEndpoint`.
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 `authEndpoint`; enable this
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 `apiKey`, `authEndpoint`, or `authToken`.
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
- The package-root client defaults to request/response HTTP. Select
164
- `transport: 'websocket'` for a resident agent that needs pushed coordination;
165
- the model, commit, claim, and context vocabulary stays unchanged.
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.