@abloatai/ablo 0.55.0 → 0.56.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 CHANGED
@@ -1,5 +1,81 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.56.0
4
+
5
+ ### One customer no longer sees another's live work
6
+
7
+ A platform's customers are rows in its own schema, so they share one
8
+ organization and one plane. The claim listing and the presence read were scoped
9
+ to exactly that pair and nothing finer. One customer could therefore see which
10
+ rows another had claimed, who held them, what the work was called, and who was
11
+ online. Row contents were never exposed; everything around them was.
12
+
13
+ Both reads now apply the same cut the delivery path already applies, taken from
14
+ the groups each side already carries. A customer sees the coordination for the
15
+ rows it can see, and nothing else. If you serve many customers from one
16
+ organization, this closes the gap without any change on your side.
17
+
18
+ ### A client converges on the head it was measured against
19
+
20
+ Catch-up measured the plane head, paged the log under the client's own scope, and
21
+ then set the cursor to the last row that scope happened to contain. On a plane
22
+ carrying traffic the client cannot see, that row sits below the head. Where the
23
+ scope held nothing at all, the cursor never moved.
24
+
25
+ The client half was the mirror image: it reconciled in one direction only and
26
+ could not adopt a head above its own. Together those left a client permanently
27
+ behind, and the catch-up poll turned that into standing load, taking the plane's
28
+ advisory lock every thirty seconds to find the same gap and serve the same
29
+ nothing. The head reads a global sequence, so on any deployment with more than
30
+ one active writer plane this was every client rather than an edge case.
31
+
32
+ The server now advances to the head it measured, and the client adopts a head
33
+ above its cursor when the response carries no deltas, because an empty response
34
+ is proof rather than a hint.
35
+
36
+ ### A replicated array column arrives as an array
37
+
38
+ Every array column read through replication arrived one level too deep: `{a,b}`
39
+ as `[["a","b"]]`, and `{}` as `[[]]`. The driver's array parsers expect the
40
+ literal without its leading brace, and given the whole literal they read that
41
+ brace as the start of a nested array. The control plane refused such a value
42
+ outright; a `text[]` column in your own database would have carried it into the
43
+ log silently.
44
+
45
+ ### `ablo doctor` separates two different failures
46
+
47
+ A plane where nothing routed at all and a plane where some changes did not have
48
+ different causes, so they no longer read the same:
49
+
50
+ ```
51
+ ✗ delivery no change reached anyone (41 in the last hour)
52
+ → run `ablo check`. When nothing routes, the tenancy value is usually missing for the whole plane rather than for particular rows.
53
+ ```
54
+
55
+ ### `LogPosition` is the one name for a position in the log
56
+
57
+ `DeltaPosition`, `deltaPositionSchema`, `ReadSetWatermark`, and
58
+ `readSetWatermarkSchema` still resolve to it and are removed in 0.57.0. Where a
59
+ position needs an owner, the owner goes in the field name rather than into a
60
+ second type.
61
+
62
+ `ABLO_DOCS_BASE_URL` and `ABLO_SITE_BASE_URL` are exported for tools that link
63
+ back to the documentation.
64
+
65
+ ### What an organization is, and what your customers are
66
+
67
+ The customer-organizations guide is rewritten around the distinction it kept
68
+ blurring. An organization is a team account: people join it with their own
69
+ logins, and share what it owns and is billed for. Nobody invites their customers
70
+ into that.
71
+
72
+ So a platform's customers are not organizations, and they are not projects
73
+ either, since a project is bound one to one to a database schema and an account
74
+ with four applications could no longer say which of the four a customer belonged
75
+ to. They are rows in the platform's own schema, reached by the sync groups on the
76
+ session: the account is ambient and derived from the key, the customer is a plain
77
+ row, and the session is minted against one of them.
78
+
3
79
  ## 0.55.0
4
80
 
5
81
  ### `ablo doctor` says whether the writes reached anyone
@@ -1,71 +1,49 @@
1
- # Customer Organizations
1
+ # Serving Many Customers
2
2
 
3
- > Serve many customer organizations from one backend and one shared schema without weakening tenant isolation.
3
+ > One account, one schema, and a session scoped to the customer whose data it may read.
4
4
 
5
- An application serving many customers has two independent choices: where
6
- customer data is isolated and where the schema is authored. Ablo lets every
7
- customer keep a hard organization boundary while all of them use the schema
8
- pushed once by the owning project.
9
-
10
- ## Choose the customer boundary first
11
-
12
- | Customer model | Isolation guarantee | Choose it when |
13
- |---|---|---|
14
- | One Ablo organization with customer scope roots | Whatever read boundary every model declares in `policy` | Cross-customer reads are intentional, or every model is explicitly and continuously policy-partitioned |
15
- | One Ablo organization per customer | Structural organization filtering and RLS on every row | Customers must stay isolated even when a model has no customer-specific policy |
16
-
17
- Sync-groups decide which changes are delivered. They do not, by themselves,
18
- authorize HTTP reads. If you cannot audit matching policies across every model,
19
- use one Ablo organization per customer.
20
-
21
- The rest of this guide uses organization-per-customer: one trusted backend
22
- holds one dedicated mint key, every customer has an `organizationId`, and each
23
- user session names that customer organization.
24
-
25
- ## What you need
26
-
27
- - An owning project containing the schema every customer uses.
28
- - The schema pushed to that project's production root.
29
- - A server-side `sk_` carrying only `organization:act-as`.
30
- - A customer `organizationId` resolved from your authenticated application
31
- membership, never accepted unchecked from the browser.
32
- - A model-by-model `can` grant for the UI being opened.
33
-
34
- The cross-organization key is a minting credential, not a tenant-data
35
- credential. Keep it in a secret manager and expose only your own authenticated
36
- session endpoint.
37
-
38
- ## Mint on your backend
39
-
40
- Create the Ablo client once in server-only code:
5
+ Your customers live in your database, in a table you already have. Ablo reaches
6
+ one of them by the groups a session is minted with.
41
7
 
42
8
  ```ts
43
- import Ablo from '@abloatai/ablo';
44
- import { schema } from '@/ablo/schema';
45
-
46
- export const customerSessions = Ablo({
47
- schema,
48
- apiKey: process.env.ABLO_API_KEY,
49
- });
9
+ // 1. src/ablo/schema.ts — your customer table is a scope root.
10
+ import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/schema';
11
+
12
+ export const schema = defineSchema(
13
+ {
14
+ // Its rows form the group `customer:<id>`; the kind comes from `groups.root`.
15
+ customers: model(
16
+ { name: z.string() },
17
+ { groups: { root: 'customer' } },
18
+ ),
19
+ // A child inherits its customer's group through the `parent` edge.
20
+ decks: model(
21
+ { customerId: z.string(), title: z.string() },
22
+ { relations: { customer: relation.belongsTo('customers', 'customerId', { parent: true }) } },
23
+ ),
24
+ },
25
+ {
26
+ identityRoles: [
27
+ identityRole({ kind: 'org', source: 'organizationId' }),
28
+ identityRole({ kind: 'user', source: 'userId' }),
29
+ ],
30
+ },
31
+ );
50
32
  ```
51
33
 
52
- After your application authenticates the user, resolve the customer from that
53
- trusted membership and mint the session:
54
-
55
34
  ```ts
35
+ // 2. app/api/ablo-session/route.ts — mint for one customer, on your backend.
36
+ import { syncGroup } from '@abloatai/ablo/schema';
56
37
  import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth';
57
- import { customerSessions } from '@/ablo/customer-sessions';
38
+ import { ablo } from '@/ablo/server';
58
39
 
59
40
  export async function POST() {
60
- const member = await requireSignedInCustomerMember();
41
+ const member = await requireSignedInMember();
61
42
 
62
- const session = await customerSessions.sessions.create({
43
+ const session = await ablo.sessions.create({
63
44
  user: { id: member.userId },
64
- organizationId: member.abloOrganizationId,
65
- can: {
66
- projects: ['read'],
67
- records: ['read', 'create', 'update'],
68
- },
45
+ can: { customers: ['read'], decks: ['read', 'create', 'update'] },
46
+ syncGroups: [syncGroup('customer', member.customerId)],
69
47
  });
70
48
 
71
49
  return Response.json(
@@ -79,137 +57,121 @@ export async function POST() {
79
57
  }
80
58
  ```
81
59
 
82
- Do not take `organizationId` directly from request JSON. A signed-in user could
83
- replace it with another customer's id. Derive it from the server-side membership
84
- you just authenticated.
60
+ That is the whole integration. The rest of this page is why each line is where
61
+ it is.
85
62
 
86
- ## Connect the browser
63
+ ## What each layer is
87
64
 
88
- The browser knows only your session endpoint. It never receives the
89
- cross-organization key:
65
+ Four things carry a name in this arrangement, and mixing two of them up is the
66
+ one mistake worth spending a page to prevent.
90
67
 
91
- ```tsx
92
- 'use client';
68
+ | Layer | What it is | Where it lives |
69
+ |---|---|---|
70
+ | Your account | The organization you signed up with. Colleagues join it with their own logins and share one bill. | Ablo |
71
+ | Your application | A project. One per app you run, bound to one schema in your database. | Ablo |
72
+ | Your customer | A row in your own table, with your own id on it. | Your database |
73
+ | One person's session | An `ek_` your backend mints, cut to one customer's group. | Minted per sign-in |
93
74
 
94
- import Ablo from '@abloatai/ablo';
95
- import { AbloProvider } from '@abloatai/ablo/react';
96
- import { schema } from '@/ablo/schema';
75
+ Your customers sit in the third row. They are not accounts, because an account
76
+ is something you invite colleagues into. They are not projects, because a
77
+ project binds to a Postgres schema and you run one application, not one per
78
+ customer.
97
79
 
98
- const ablo = Ablo({
99
- schema,
100
- authEndpoint: '/api/ablo-session',
101
- });
80
+ Your `sk_` already carries your account, so a session never names it. What the
81
+ session adds is which customer the person in front of it may read.
102
82
 
103
- export function Providers({ children }: { children: React.ReactNode }) {
104
- return <AbloProvider client={ablo}>{children}</AbloProvider>;
105
- }
106
- ```
83
+ ## Where the boundary is enforced
107
84
 
108
- The client re-mints before expiry. Your endpoint should return
109
- `session_expired` only when the application's own login is gone; network and
110
- server failures are transient and must not sign the user out.
85
+ Two mechanisms do different jobs, and it is worth knowing which is which before
86
+ you rely on either.
111
87
 
112
- ## Schema and data stay on different axes
88
+ **Your account is the tenant boundary.** Every row Ablo stores carries your
89
+ organization and project, and row-level security compares both against the
90
+ credential on every read and every write. A client cannot reach past it by
91
+ asking, because the values come from the key rather than the request.
113
92
 
114
- For a cross-organization mint, Ablo derives the schema binding from the owning
115
- key automatically:
93
+ **Sync groups are the cut inside your account.** They decide which of your own
94
+ rows a session is delivered and which it may read back over HTTP. This is the
95
+ boundary between one of your customers and the next, and it is the one your
96
+ schema declares.
116
97
 
117
- ```text
118
- owning key organization/project -> schema shape
119
- customer organization -> rows, RLS, database, sync groups
120
- session can -> allowed model operations
121
- model policy -> allowed reads inside that organization
122
- ```
98
+ Reads go through it. `list` and `get` are cut to the session's groups, live
99
+ delivery is cut to the same set, and the initial load is the intersection of
100
+ what the session asked for with what it was minted with.
123
101
 
124
- Most applications should not pass a schema option. An explicit override exists for
125
- migrations or advanced routing:
102
+ Coordination reads are cut more coarsely today. Listing claims and reading
103
+ presence are scoped to your account and plane rather than to the session's
104
+ groups, so one customer's session can see that a row is claimed and who is
105
+ present, though never the row's contents. Treat row ids and participant ids on
106
+ those two surfaces as visible across your customers until this page says
107
+ otherwise.
108
+
109
+ ## Naming a group
110
+
111
+ Build a group with the `syncGroup(kind, id)` helper rather than a string. The
112
+ kind is the one you declared in `groups.root`, and the id is your own
113
+ identifier for the customer.
126
114
 
127
115
  ```ts
128
- await customerSessions.sessions.create({
129
- user: { id: member.userId },
130
- organizationId: member.abloOrganizationId,
131
- schemaProject: {
132
- organizationId: schemaOwnerOrganizationId,
133
- projectId: migratingSchemaProjectId,
134
- },
135
- can: { records: ['read', 'update'] },
136
- });
116
+ syncGroups: [syncGroup('customer', member.customerId)]
137
117
  ```
138
118
 
139
- Both schema coordinates move together; customer data does not move with them.
119
+ Resolve `member.customerId` from the membership you just authenticated on the
120
+ server. A signed-in person can put any value in a request body, and the session
121
+ you mint is what decides what they can read.
140
122
 
141
- ## Customer lifecycle checklist
123
+ ## When a customer really is an account
142
124
 
143
- When onboarding a customer:
125
+ There is one shape where each of your customers should be its own Ablo
126
+ organization: when each is a separate paying business that signs in to Ablo
127
+ itself, holds its own subscription, and invites its own developers. That is what
128
+ an identity provider looks like, and it is what the `organization:act-as` scope
129
+ on a secret key exists for.
144
130
 
145
- 1. Provision or resolve its Ablo organization and store that immutable id on
146
- your customer record.
147
- 2. Connect that organization's production data source if it owns a separate
148
- database.
149
- 3. Keep the shared schema in the owning project; do not copy it into every
150
- customer organization.
151
- 4. Mint a test user session through the same backend route production uses.
152
- 5. Verify one known read and write in the customer organization before enabling
153
- the integration.
131
+ It is rare, and it is not what a platform serving customers from one product
132
+ looks like. If your customers never see Ablo, they belong in your schema.
154
133
 
155
- When offboarding, stop minting immediately, revoke active sessions when an
156
- immediate cutoff is required, and retire the customer's data-source access
157
- through the control-plane process you use for provisioning.
134
+ ## Onboarding a customer
158
135
 
159
- ## Security checklist
136
+ Insert the row. There is nothing to register with Ablo, because the group is
137
+ derived from the row's id, and the first session minted against it is delivered
138
+ its data.
160
139
 
161
- - Store the cross-organization key only in the backend secret manager.
162
- - Give it only `organization:act-as`; do not combine minting with schema or
163
- data authority.
164
- - Resolve `organizationId` from authenticated membership server-side.
165
- - Keep `can` to the smallest model/verb set the UI needs.
166
- - Use organization-per-customer when a missing model policy must not expose
167
- another customer's rows.
168
- - Rotate the cross-organization key on a schedule and after personnel or
169
- infrastructure changes.
170
- - Record target organization, user, session id, and request id without logging
171
- plaintext credentials.
140
+ Add a project only when you add an application. `npx ablo projects create` takes
141
+ a management credential from `ablo login`, and one project holds one schema.
172
142
 
173
143
  ## Troubleshooting
174
144
 
175
- ### The mint is forbidden
145
+ ### A session reads nothing
176
146
 
177
- The presenting credential must be a secret `sk_` with
178
- `organization:act-as`. A normal project key can mint users into its own
179
- organization but cannot name another one. Run `npx ablo whoami --json` in the
180
- backend environment to confirm which project and branch the configured key
181
- actually belongs to; the command never prints the full secret.
147
+ Check the groups the session was minted with against the kind in `groups.root`.
148
+ A group whose kind is not declared matches nothing, which reads as an empty
149
+ database rather than an error.
182
150
 
183
- ### The session mints but bootstrap is empty
151
+ ### A session reads another customer's rows
184
152
 
185
- Confirm that the cross-organization key belongs to the project where the schema
186
- was pushed, and that the customer's organization has its data source/root
187
- branch ready. Do not copy the schema into the customer organization. If you
188
- supplied `schemaProject`, remove it unless you intentionally override the
189
- owning-key default.
153
+ Check that the model declares a `parent` edge up to the scope root. A model with
154
+ no group of its own and no parent belongs to no group, so a group cut does not
155
+ narrow it.
190
156
 
191
- ### The mint reports an unknown model
157
+ ### The mint is refused
192
158
 
193
- Every model named by `can` must exist in the active shared schema. Check the
194
- model key spelling against the schema used to construct `customerSessions`, then
195
- push that schema to the key's branch deliberately.
159
+ Naming `organizationId` reaches into a different account and takes
160
+ `organization:act-as`. A platform serving its own customers names groups
161
+ instead, and its key needs no scope at all.
196
162
 
197
- ### Data appears under the wrong customer
163
+ ## See it yourself
198
164
 
199
- Inspect the membership-to-`organizationId` lookup in your backend route first.
200
- The schema project never selects the data tenant. The `organizationId` passed to
201
- `sessions.create` does, and it must come from trusted server-side membership.
202
-
203
- ### Realtime looks isolated but an HTTP read is too broad
165
+ ```
166
+ npx ablo whoami --json
167
+ ```
204
168
 
205
- Sync-groups route changes; they are not a read policy. Add or correct the
206
- model's `policy`, or move customers to separate Ablo organizations when the
207
- boundary must hold structurally across every model.
169
+ The `syncGroups` it reports are the cut the engine will apply. If a customer's
170
+ group is missing there, no read will show its rows.
208
171
 
209
172
  ## Related guides
210
173
 
211
- - [Sessions](./sessions.md) — session lifecycle, refresh, revocation, and grants.
212
- - [API Keys](./api-keys.md) — credential classes, scopes, inspection, and rotation.
213
- - [Identity & Sync Groups](./identity.md) — participant delivery groups versus model read policy.
214
- - [Connect Your Database](./data-sources.md) — customer-owned database setup.
215
- - [Deployment](./deployment.md) — production schema and database rollout order.
174
+ - [Identity & Sync Groups](/identity) — how groups are declared and resolved.
175
+ - [Sessions](/sessions) — session lifetime, refresh, and revocation.
176
+ - [API Keys](/api-keys) — credential classes and scopes.
177
+ - [Projects](/projects) — one project per application.
@@ -355,13 +355,11 @@ Because Ablo checks from its own network, a database your own machine can't reac
355
355
  IPv6-only, IP-allowlisted, behind a VPN — still verifies. Re-run it until every
356
356
  item is green.
357
357
 
358
- Your **app** holds only the API key never a connection string:
358
+ Your **app** holds only the API key, never a connection string:
359
359
 
360
360
  ```bash
361
- # .env server runtime only, never the browser
361
+ # .env, server runtime only, never the browser
362
362
  ABLO_API_KEY=sk_...
363
- ABLO_PROJECT_ID=proj_...
364
- ABLO_BRANCH_ID=br_...
365
363
  ```
366
364
 
367
365
  ```ts
@@ -371,17 +369,17 @@ import { schema } from './ablo/schema';
371
369
  export const ablo = Ablo({
372
370
  schema,
373
371
  apiKey: process.env.ABLO_API_KEY,
374
- projectId: process.env.ABLO_PROJECT_ID,
375
- branchId: process.env.ABLO_BRANCH_ID,
376
372
  });
377
373
  ```
378
374
 
379
- `ABLO_PROJECT_ID` and `ABLO_BRANCH_ID` are safety assertions, not routing inputs.
380
- The API key still selects the project and branch; during `ready()` Ablo asks the
381
- server what the key actually targets and refuses startup when either coordinate
382
- differs. `ablo dev` writes all three values together, so accidentally exporting
383
- a entries key into the mail app—or a mail development key into production—fails
384
- before any read, write, or subscription begins.
375
+ The key names its own project and branch, so there is nothing else to configure.
376
+
377
+ If you want a process to refuse a key you did not expect, pin `projectId` or
378
+ `branchId` (they default to `ABLO_PROJECT_ID` and `ABLO_BRANCH_ID`). Both are
379
+ assertions, never routing inputs: during `ready()` Ablo asks the server what the
380
+ key actually targets and refuses to start when a coordinate differs. That is
381
+ worth setting where one deployment can be handed keys for more than one
382
+ environment, and worth leaving out everywhere else.
385
383
 
386
384
  The Ablo schema describes **only your synced, collaborative models** — the rows
387
385
  Ablo coordinates and fans out in realtime. It is _not_ your whole-database schema
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "The public Ablo SDK for coordinated reads, commits, claims, observation, and reactive applications.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -120,6 +120,8 @@
120
120
  "lint:pricing": "tsx scripts/check-pricing-docs.mts",
121
121
  "generate:openapi": "tsx --conditions=@ablo/source scripts/generate-openapi.mts",
122
122
  "lint:openapi": "tsx --conditions=@ablo/source scripts/generate-openapi.mts --check",
123
+ "generate:discovery": "tsx --conditions=@ablo/source scripts/generate-discovery-docs.mts",
124
+ "lint:discovery": "tsx --conditions=@ablo/source scripts/generate-discovery-docs.mts --check",
123
125
  "validate:openapi": "redocly lint ../../docs/ablo/public/openapi.json --extends=recommended --skip-rule=no-server-example.com",
124
126
  "build:docs": "node scripts/build-blume-docs.mjs",
125
127
  "lint:docs-site": "node scripts/build-blume-docs.mjs --check",
@@ -137,8 +139,8 @@
137
139
  "directory": "packages/ablo"
138
140
  },
139
141
  "dependencies": {
140
- "@abloatai/humans": "^0.55.0",
141
- "@abloatai/transaction": "^0.55.0",
142
+ "@abloatai/humans": "^0.56.0",
143
+ "@abloatai/transaction": "^0.56.0",
142
144
  "zod": "^4.4.3"
143
145
  },
144
146
  "peerDependencies": {