esoul-sdk 0.8.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +575 -162
  2. package/api-reference.md +3346 -0
  3. package/dist/assets.d.ts +72 -0
  4. package/dist/assets.js +139 -0
  5. package/dist/audience.d.ts +2 -0
  6. package/dist/audience.js +2 -0
  7. package/dist/bindings.d.ts +3 -0
  8. package/dist/bindings.js +3 -0
  9. package/dist/chart-font.d.ts +19 -0
  10. package/dist/chart-font.js +15 -0
  11. package/dist/chart.d.ts +83 -0
  12. package/dist/chart.js +247 -0
  13. package/dist/computer.d.ts +191 -0
  14. package/dist/computer.js +226 -0
  15. package/dist/db/client-core.d.ts +48 -0
  16. package/dist/db/client-core.js +85 -1
  17. package/dist/db/compile-rules.d.ts +91 -2
  18. package/dist/db/compile-rules.js +245 -30
  19. package/dist/db/custom-roles.d.ts +156 -0
  20. package/dist/db/custom-roles.js +280 -0
  21. package/dist/db/memory-client.d.ts +2 -15
  22. package/dist/db/memory-client.js +20 -28
  23. package/dist/db/schema-gen.d.ts +13 -3
  24. package/dist/db/schema-gen.js +61 -21
  25. package/dist/editor-sync.d.ts +130 -0
  26. package/dist/editor-sync.js +413 -0
  27. package/dist/failed-requests.d.ts +77 -0
  28. package/dist/failed-requests.js +127 -0
  29. package/dist/files.d.ts +70 -0
  30. package/dist/files.js +48 -0
  31. package/dist/helpers.d.ts +10 -0
  32. package/dist/helpers.js +10 -0
  33. package/dist/index.d.ts +24 -0
  34. package/dist/index.js +25 -0
  35. package/dist/labelme.d.ts +84 -0
  36. package/dist/labelme.js +118 -0
  37. package/dist/manifest.d.ts +400 -106
  38. package/dist/manifest.js +59 -5
  39. package/dist/ops.d.ts +115 -0
  40. package/dist/ops.js +120 -0
  41. package/dist/react.d.ts +286 -4
  42. package/dist/react.js +106 -3
  43. package/dist/server.d.ts +180 -13
  44. package/dist/server.js +72 -6
  45. package/dist/testing/db.d.ts +6 -0
  46. package/dist/testing/db.js +3 -8
  47. package/dist/testing/files.d.ts +22 -0
  48. package/dist/testing/files.js +175 -0
  49. package/dist/testing/index.d.ts +2 -0
  50. package/dist/testing/index.js +1 -0
  51. package/dist/types.d.ts +47 -0
  52. package/docs/02-manifest.md +2 -0
  53. package/docs/03-events-and-state.md +16 -3
  54. package/docs/04-tools.md +58 -26
  55. package/docs/05-ui.md +35 -0
  56. package/docs/06-server.md +121 -7
  57. package/docs/07-background-tasks.md +7 -9
  58. package/docs/09-files.md +126 -17
  59. package/docs/10-testing.md +6 -0
  60. package/docs/11-shipping.md +10 -2
  61. package/docs/13-people-and-access.md +136 -0
  62. package/docs/14-database.md +27 -3
  63. package/docs/15-realtime.md +3 -0
  64. package/docs/17-editing-and-merging.md +155 -0
  65. package/llms-full.txt +1281 -229
  66. package/llms.txt +3 -3
  67. package/package.json +9 -4
  68. package/schemas/plugin.schema.json +134 -15
  69. package/scripts/build-api-reference.mjs +104 -0
  70. package/scripts/build-llms.mjs +1 -2
package/README.md CHANGED
@@ -1,192 +1,605 @@
1
1
  # esoul-sdk
2
2
 
3
- Build a **full product** on ExternalSoul — not a widget. An app you write with this SDK gets its
4
- own database tables, its own server, its own background jobs, its own realtime, its own words for
5
- the people who use it, and the same agent tools that chat, voice and MCP already call. Once it
6
- ships it is indistinguishable from the platform's own apps.
3
+ esoul-sdk is a toolkit for building applications on [ExternalSoul](https://externalsoul.com). An
4
+ application written with it is compiled into the platform and runs as a native app: it has its own
5
+ database tables, its own server endpoints, its own background tasks, its own realtime channel, its
6
+ own vocabulary of roles, and a set of tools that agents call from chat, voice, the agent builder
7
+ and MCP. Access control is declared in the application's manifest and enforced by the platform at
8
+ every entry point; application code does not check permissions itself.
9
+
10
+ The package provides the types, the manifest schema and validator, the rule compiler, the request
11
+ helpers and an in-memory test harness. Inside ExternalSoul the same import names resolve to the
12
+ platform's implementations.
13
+
14
+ ## Useful links
15
+
16
+ - [Getting started](docs/01-getting-started.md)
17
+ - [Manifest reference](docs/02-manifest.md)
18
+ - [Documentation index](#documentation)
19
+ - `llms.txt` and `llms-full.txt` — the documentation in one file, for coding models
20
+
21
+ ## Features
22
+
23
+ Data model
24
+
25
+ - Tables are declared in the manifest (`db`) and created on install; migrations are additive-only
26
+ - Every query is scoped to the application instance and filtered for the caller by compiled rules
27
+ - Rows can be owned by their creator, by the workspace, or by a user across instances
28
+ - Fields can be sealed (encrypted at rest, readable only by the row's owner)
29
+ - Index kinds for equality, list containment and case-insensitive text search, with cursor paging
30
+ - Application state on the workspace timeline as a fold of typed events (replayable, scrubbable)
31
+
32
+ Access control
33
+
34
+ - A `viewer` on every entry point: owner, member, visitor, anonymous, agent, or the app itself
35
+ - Roles as the application's own words (`customer`, `staff`, …), mapped from the platform's kinds
36
+ - Per-surface access levels: `write`, `read`, `public`, `token`, or a list of roles
37
+ - Rules scoped by a person's typed attributes — a customer linked to a number sees only rows carrying it
38
+ - Roles composed by the workspace owner at runtime, inside an envelope the manifest declares
39
+ - Refusals distinguish `login-required` from `forbidden`, so a UI can show a sign-in wall
40
+
41
+ Server
42
+
43
+ - Ops: server-side functions with a declared input schema, shared with the derived tools
44
+ - Routes: HTTP endpoints mounted per instance, including server-sent event streams
45
+ - Token routes: endpoints a machine reaches with a bearer token the application minted
46
+ - Background tasks on a durable executor, with steps, retries and concurrency limits
47
+ - Webhooks, OAuth connections held by the platform, workspace files and file providers
48
+ - Access to other applications through bindings (typed contracts) and workspace tools
49
+
50
+ Realtime
51
+
52
+ - Per-instance channels with topics; each topic declares its audience (everyone, one person, one role)
53
+ - Tokens are issued per channel, so a subscriber never receives another audience's messages
54
+
55
+ Tools
56
+
57
+ - One declaration per op is used by the server, the derived tool and the UI
58
+ - Tools run on every surface: typed chat, voice, agent builder, MCP, and the browser
59
+
60
+ Development
61
+
62
+ - The Forge: a cloud workbench with a live preview, a persona switcher (*view as*), the application's
63
+ tools callable before installation, tests and the full checks
64
+ - An in-memory database built from the manifest, with the same compiled rules production uses
65
+ - A validator for the package folder (`esoul-app validate`)
66
+
67
+ Deployment
68
+
69
+ - Installed from the application's own GitHub repository at a commit
70
+ - Additive migrations applied on install; the install card lists every public surface
71
+
72
+ ## Installation
7
73
 
8
74
  ```bash
9
75
  npm install --save-dev esoul-sdk
10
76
  ```
11
77
 
12
- Inside ExternalSoul the package name resolves to the platform's real implementations. Outside it
13
- (your editor, your tests) it gives you the types, the manifest validator, the rule compiler and
14
- the test helpers; the server functions throw "host only" if called, because they run in the
15
- platform.
78
+ Outside ExternalSoul the package gives you types, the manifest validator, the rule compiler and the
79
+ test helpers. Functions that need the platform (`pluginDb`, `viewerProfile`, `mintRouteToken`, …)
80
+ throw `host only` when called outside it; they are exercised through the test harness or in a
81
+ Forge workbench.
16
82
 
17
- ---
83
+ Requirements: Node.js 20 or newer, TypeScript 5, React 19 for the UI entry point.
18
84
 
19
- ## What you can build with it
85
+ ## Concepts
20
86
 
21
- Take a help desk. Strangers may read the published articles. Someone signed in may raise a ticket
22
- and see their own. The people on duty see every ticket. The owner decides who is on duty. A
23
- confirmation must go out exactly once, even if the mail service is down for a minute. That is
24
- every hard thing at once, and it is the shape most real products have.
87
+ **Application.** A folder containing a manifest (`plugin.json`), a schema (`app.tsx`), a UI, and
88
+ optionally a server module (`server.ts`) and tests. The folder name is the application id. The
89
+ application type is `plugin_` followed by the id with underscores.
25
90
 
26
- An app like that, written with this SDK, contains **no access checks at all**. They are
27
- declarations, and the platform enforces them at the seam.
91
+ **Manifest.** `plugin.json` declares everything the platform enforces: tables, roles, access levels
92
+ of ops, routes and tasks, realtime topics, bindings, connections and dependencies. It is validated
93
+ against `schemas/plugin.schema.json`.
28
94
 
29
- | You want | You declare | You never write |
30
- |---|---|---|
31
- | Your own tables | `db` in the manifest | migrations, a Prisma schema, an ORM |
32
- | Per-person data | `owner: "creator"` + `rules` | a `WHERE userId = …` anywhere |
33
- | A public page | `"access": "public"` on an op | an auth check in the handler |
34
- | A sign-in wall | `"requires": "account"` | the difference between "sign in" and "never" |
35
- | Words for your people | `roles` | a permissions table |
36
- | One person's live updates | `"audience": "viewer"` on a topic | a filter on arrival |
37
- | Durable follow-up work | a `task` | a queue, retries, idempotency plumbing |
38
- | Another app's help | `uses` + a contract | an integration |
39
-
40
- ---
41
-
42
- ## The seven capabilities
43
-
44
- **1. Viewer — every seam knows who is calling.** Ops, routes, tasks, tools and the UI all receive
45
- a `viewer`: the owner, a member, a signed-in visitor, an anonymous one, an agent acting for
46
- someone, or your app's own server code. The platform resolves it; an app cannot supply, widen or
47
- forge one. `useViewer()` in the UI, `ctx.viewer` on the server. When you need the person's name
48
- or email — to address them, or to fill a form in — `viewerProfile(ctx.viewer)` reads the esoul
49
- account of the CALLER and nobody else, so people use your app with the account they already have.
50
-
51
- **2. Roles — your app's own vocabulary.** Declare `roles: { vocabulary: ["requester", "agent",
52
- "supervisor"] }` and a mapping from the platform's kinds. The workspace owner can then assign a role
53
- per app, per person, so an organisation sees the pages their job needs. A role is a WORD, never a
54
- permission: it decides what your rules mean, not what the platform allows.
55
-
56
- **3. Access levels — nothing opens by default.** Every op, route and task is `write` unless you
57
- say otherwise. `read` admits a read-only collaborator; `public` admits someone on a share link
58
- who has no workspace access at all; `public` + `requires: "account"` answers `login-required`
59
- instead of `forbidden`, which is the difference between showing a sign-in wall and showing an
60
- error. Every public door is listed on the install card before anyone installs your app.
61
-
62
- **4. The database — tables you declare, scoped by the platform.** A `db` block becomes real
63
- tables, generated typings, and rules compiled once and enforced everywhere. `pluginDb(ctx)` hands
64
- you a typed client whose every query is already scoped to this instance and filtered for this
65
- caller: one person's `findMany` returns their own rows, someone on duty gets the whole queue, and
66
- neither had to ask.
67
- Fields can be `sealed` (encrypted at rest, readable only by their owner). Migrations are
68
- additive-only and applied on install; a change that would drop or retype a column is refused with
69
- the column named.
70
-
71
- **5. Realtime with an audience.** A topic declares who hears it: everyone on the app, only the
72
- person it concerns, or only a role. The platform mints a token per channel, so one person is never
73
- handed another's messages — not filtered out on arrival, never issued. Aiming a message is a
74
- separate permission from hearing one.
75
-
76
- **6. Background tasks.** Durable work on the platform's own scheduler: retried, replay-safe,
77
- steps on the timeline. This is where the slow and failure-prone things go — sending the
78
- confirmation, calling somebody else's API — so the request that changed the record stays fast and
79
- honest.
80
-
81
- **7. Bindings.** Declare a SLOT and a CONTRACT (`uses: { rooms: { contract: "rooms/v1" } }`) and
82
- the owner picks which app fills it. The platform checks at bind time that the provider really has
83
- every tool, event and table the contract names. Your app reaches it through `ctx.apps.rooms` —
84
- the contract's tools only, with the caller's identity travelling along.
85
-
86
- ---
87
-
88
- ## Where to start
89
-
90
- - **Build it in the Forge, not on your laptop.** Open a Forge board in your workspace and ask it
91
- to open a workbench for your app: a cloud machine with the platform on it, a live preview in
92
- your frame, your app's tools callable before it is installed, and **VIEW AS** — the switcher
93
- that shows you your app as each kind of person who will use it, including the stranger. Read
94
- [docs/01-getting-started.md](docs/01-getting-started.md).
95
- - **The contract, one page per part:**
96
- 1. [Getting started](docs/01-getting-started.md) — the loop, the package layout
97
- 2. [The manifest](docs/02-manifest.md) — `plugin.json`, every field
98
- 3. [Events and state](docs/03-events-and-state.md) — the heart: dataCreator, processor, replay
99
- 4. [Tools](docs/04-tools.md) — what agents call, on every surface
100
- 5. [The UI](docs/05-ui.md) — React, hooks, theme, responsive rules
101
- 6. [The server half](docs/06-server.md) — ops, routes, webhooks, calling other apps
102
- 7. [Background tasks](docs/07-background-tasks.md) — durable work, polling, the replay model
103
- 8. [Connections and OAuth](docs/08-connections.md) — tokens the platform holds for you
104
- 9. [Files](docs/09-files.md) — workspace files, Drive, your own provider
105
- 10. [Testing](docs/10-testing.md) — the fold contract, and your rules, as tests
106
- 11. [Shipping](docs/11-shipping.md) — submit, review, release, install; the import wall
107
- 12. [Rules and failures](docs/12-rules.md) — every rule with the failure that earned it
108
- 13. [People and access](docs/13-people-and-access.md) — viewer, roles, levels, the sign-in wall
109
- 14. [Your own tables](docs/14-database.md) — `db`, rules, scopes, sealed fields, migrations
110
- 15. [Realtime](docs/15-realtime.md) — topics, audiences, who may address whom
111
- 16. [Bindings](docs/16-bindings.md) — slots, contracts, reaching another app
112
- - **For a coding model:** `llms.txt` (short) and `llms-full.txt` (the whole contract in one file).
113
-
114
- ## The one rule that explains the others
115
-
116
- **Events are the truth.** Your app's fold is its events re-run on every replay, scrub and sync. So
117
- a reducer is pure and idempotent, ids and timestamps are minted in the `dataCreator` (never in a
118
- reducer), whole-replace events carry a collapse key, and a state description never claims
119
- something it could not read.
120
-
121
- The second rule, for everything that is not in the fold: **the platform decides who sees what.**
122
- Your rules are declarations the platform enforces at the seam. An app that checks access in its
123
- own handler has two answers to one question, and one of them will be wrong.
124
-
125
- ## What is in the package
126
-
127
- | Entry | What it gives you |
95
+ **Events and state.** Application state is the result of folding typed events from the workspace
96
+ timeline. Each event has a `dataCreator` (mints ids and timestamps) and a `processor` (a pure
97
+ reducer). The same events are dispatched by the UI, by tools and by tasks.
98
+
99
+ **Viewer.** The identity of the caller, resolved by the platform for every op, route, task, tool and
100
+ UI render. A viewer has a kind, an account id, a set of ids it has acted under, the application's
101
+ role word for it, and whether it may write.
102
+
103
+ **Op.** A server-side function of the application, called from the UI or from a tool. Its input
104
+ schema is declared once with `defineOps`; the server parses with `handleOp` and the tool is derived
105
+ with `opTool`.
106
+
107
+ **Route.** An HTTP endpoint the application mounts at `/api/plugins/<id>/route/<name>`.
108
+
109
+ **Task.** A background job on the platform's durable executor. A task is kicked by an event, runs
110
+ in steps that are memoised across retries, and may dispatch events and publish realtime messages.
111
+
112
+ **Topic.** A named message stream on the application's realtime channel. Its audience is declared in
113
+ the manifest.
114
+
115
+ **Binding.** A slot the application declares (`uses`) and the workspace owner fills with another
116
+ application that provides the named contract.
117
+
118
+ ## Package entry points
119
+
120
+ | Import | Contents |
128
121
  |---|---|
129
- | `esoul-sdk` | `ApplicationSchema`, `EventDefinition`, `EventTypes`, `ApplicationIdentifier`, `definePluginChannel`, `defineBindingEvent`, `checkBinding`, `subscriptionsFor`, `resolveAppRole`, `incompleteStateNotice`, `deterministicReducerId`, `timingSafeEqual`, `nanoid`, `callPluginOp`, `kickPluginTask`, `pluginRouteUrl`, the manifest schema |
130
- | `esoul-sdk/react` | `useViewer`, `useSignInWall`, `useAppCanEdit`, `usePluginEventDispatch`, `usePluginRealtime`, `useWorkspaceTools`, file hooks |
131
- | `esoul-sdk/server` | `pluginDb`, `viewerProfile`, `PluginServerModule` (ops, routes, webhooks), `sseStream`, `readAppState`, `callWorkspaceTool`, `emitPluginAppEvent`, `getPluginConnectionCredentials`, file provider types |
132
- | `esoul-sdk/testing` | `memoryDb`, `fakeViewer`, `runOp`, `fakeApps`, `capture`, `startMockOAuth` — your rules run against the client the platform compiles from your own manifest |
133
- | `esoul-app validate <dir>` | validates a package folder against the manifest schema |
122
+ | `esoul-sdk` | Schema and event types, `defineOps`, `handleOp`, `opTool`, `definePluginChannel`, `defineBindingEvent`, `checkBinding`, `resolveAppRole`, `chartSvg`, `incompleteStateNotice`, `deterministicReducerId`, `callPluginOp`, `kickPluginTask`, `pluginRouteUrl`, `nanoid`, the manifest schema, `compileEnvelope`, `compileCustomRole` |
123
+ | `esoul-sdk/server` | `pluginDb`, `viewerProfile`, `sseStream`, `mintRouteToken`, `readAppState`, `callWorkspaceTool`, `computer`, `emitPluginAppEvent`, `generateAppImage`, `renderChartImage`, `setAppRole`, `listAppRoles`, `defineAppRole`, `removeAppRole`, `getPluginConnectionCredentials`, `pluginFiles`, and the context types (`PluginOpContext`, `PluginRouteContext`, `PluginViewer`, `PluginServerModule`) |
124
+ | `esoul-sdk/react` | `useViewer`, `useSignInWall`, `useAppCanEdit`, `usePluginEventDispatch`, `usePluginRealtime`, `useWorkspaceTools`, `usePluginWorkspaceFiles`, `usePluginFileUpload`, `useFileSources`, `useFileSourceEntries`; the types `PluginViewerPublic`, `SignInWall` |
125
+ | `esoul-sdk/testing` | `memoryDb`, `fakeViewer`, `runOp`, `fakeApps`, `capture`, `startMockOAuth` |
126
+ | `esoul-sdk/schemas/plugin.schema.json` | The JSON Schema of the manifest |
127
+ | `esoul-app validate <dir>` | Command-line validator for a package folder |
128
+
129
+ ## Getting started
130
+
131
+ The recommended way to build an application is the Forge, a workbench inside ExternalSoul. It does
132
+ not require a checkout of the platform.
133
+
134
+ 1. Add a **Forge** board to a workspace.
135
+ 2. Ask the assistant to open a workbench for your application. The platform starts a cloud machine
136
+ with the platform's source, scaffolds the application folder, and shows a live preview on the
137
+ board.
138
+ 3. Edit files with the board's tools (`write_app_file`, `edit_app_file`). Each change reloads the
139
+ preview and reports its health.
140
+ 4. Inspect the result: `look_at_app` renders the application on desktop and phone, light and dark,
141
+ as any persona (`owner`, `member`, `visitor-a`, `anonymous`, or a composed role such as
142
+ `role:packer`). `call_app_tool` runs the application's tools before installation. `test_app`
143
+ runs the tests; `check_app` runs the full gate.
144
+ 5. Push the application to its own GitHub repository from the board, and install it from
145
+ Settings → Apps in any workspace.
146
+
147
+ From your own editor, `npm install --save-dev esoul-sdk` gives you the same types and the
148
+ validator, and lets you run tests locally with `esoul-sdk/testing`.
149
+
150
+ ### Package layout
151
+
152
+ ```
153
+ <id>/
154
+ plugin.json the manifest
155
+ ops.ts op input schemas (defineOps)
156
+ app.tsx the schema: events, state, tools, state description
157
+ ui/<id>-ui.tsx the React UI ("use client")
158
+ server.ts ops, routes, webhooks
159
+ <id>.test.ts tests
160
+ .esoul/ generated: db.d.ts, rules.json, migration.sql
161
+ ```
162
+
163
+ ## Example: a shop
164
+
165
+ The reference application is a shop. Anyone with the link may browse the catalogue. A signed-in
166
+ customer places orders and sees only their own. Staff see every order of the shop. The owner sets
167
+ prices and decides who is staff, and may compose narrower roles at runtime — for example a
168
+ *packer* who sees only orders being prepared, never the customer's note, and may only mark them
169
+ shipped.
170
+
171
+ ### Manifest
172
+
173
+ ```json
174
+ {
175
+ "manifestVersion": 1,
176
+ "id": "shop",
177
+ "name": "Shop",
178
+ "version": "1.0.0",
179
+ "applicationType": "plugin_shop",
180
+ "entry": "app",
181
+ "roles": {
182
+ "vocabulary": ["customer", "staff", "owner"],
183
+ "default": {
184
+ "owner": "owner",
185
+ "member-edit": "staff",
186
+ "member-readonly": "staff",
187
+ "visitor": "customer",
188
+ "anonymous": "customer",
189
+ "agent": "inherit"
190
+ },
191
+ "custom": {
192
+ "models": {
193
+ "Order": { "where": ["status"], "hide": ["note"], "update": { "transitions": "status" } }
194
+ },
195
+ "ops": ["set-order-status"]
196
+ }
197
+ },
198
+ "ops": {
199
+ "browse": { "access": "public" },
200
+ "place-order": { "access": "public", "requires": "account" },
201
+ "list-orders": { "access": "public", "requires": "account" },
202
+ "set-order-status": { "access": ["staff", "owner"] },
203
+ "add-product": {}
204
+ },
205
+ "routes": {
206
+ "order-updates": { "access": "read" }
207
+ },
208
+ "channel": {
209
+ "topics": {
210
+ "catalogue": {},
211
+ "order-status": { "audience": "viewer", "mayAddress": ["staff", "owner"] },
212
+ "new-order": { "audience": "role:staff", "mayAddress": ["customer", "staff", "owner"] }
213
+ }
214
+ },
215
+ "db": {
216
+ "Product": {
217
+ "scope": "instance",
218
+ "fields": { "name": "string", "priceCents": "int", "active": "boolean=true", "tags": "string[]" },
219
+ "indexes": [["active", "createdAt"], { "fields": ["tags"], "kind": "contains" }, { "fields": ["name"], "kind": "text" }],
220
+ "rules": { "read": "anyone", "write": ["owner"] }
221
+ },
222
+ "Order": {
223
+ "scope": "instance",
224
+ "owner": "creator",
225
+ "fields": { "status": "string=new", "totalCents": "int", "lines": "json", "shipTo": "json", "note": "text?" },
226
+ "indexes": [["status"], ["createdAt"]],
227
+ "rules": {
228
+ "read": ["creator", "staff", "owner"],
229
+ "create": { "roles": ["customer", "staff", "owner"], "requires": "account" },
230
+ "update": { "roles": ["staff", "owner"], "creatorMay": ["note"] },
231
+ "delete": ["owner"]
232
+ }
233
+ },
234
+ "Address": {
235
+ "scope": "user",
236
+ "fields": { "label": "string", "lines": "json" },
237
+ "sealed": ["lines"]
238
+ }
239
+ }
240
+ }
241
+ ```
242
+
243
+ An op with no `access` is `write`: only the owner and edit members may call it. `"access":
244
+ "public"` admits anyone who can reach the application, including through a share link;
245
+ `"requires": "account"` makes an anonymous call fail with `login-required` instead of `forbidden`.
246
+
247
+ ### Op inputs
248
+
249
+ ```ts
250
+ // ops.ts
251
+ import { z } from "zod";
252
+ import { defineOps } from "esoul-sdk";
253
+
254
+ export const ORDER_STATUSES = ["new", "preparing", "shipped", "fulfilled", "refunded"] as const;
255
+
256
+ export const ops = defineOps({
257
+ browse: z.object({}),
258
+ "add-product": z.object({ name: z.string().min(1).max(80), priceCents: z.number().int().min(0) }),
259
+ "place-order": z.object({
260
+ lines: z.array(z.object({ productId: z.string().min(1), qty: z.number().int().min(1).max(99) })).min(1),
261
+ shipTo: z.object({ name: z.string().min(1), street: z.string().min(1), city: z.string().min(1) }),
262
+ note: z.string().max(280).optional(),
263
+ }),
264
+ "list-orders": z.object({}),
265
+ "set-order-status": z.object({
266
+ orderId: z.string().min(1).describe("The order id, as list_orders shows it"),
267
+ status: z.enum(ORDER_STATUSES),
268
+ }),
269
+ });
270
+ ```
271
+
272
+ ### Server
273
+
274
+ ```ts
275
+ // server.ts
276
+ import "server-only";
277
+ import { handleOp } from "esoul-sdk";
278
+ import { pluginDb, type PluginOpContext, type PluginServerModule } from "esoul-sdk/server";
279
+ import type { ShopDb } from "./.esoul/db";
280
+ import { ops } from "./ops";
281
+
282
+ const db = (ctx: PluginOpContext) => pluginDb<ShopDb>(ctx);
283
+
284
+ export const pluginServer: PluginServerModule = {
285
+ ops: {
286
+ browse: handleOp(ops, "browse", async (ctx) => {
287
+ const d = await db(ctx);
288
+ return d.product.findMany({ where: { active: true }, orderBy: { createdAt: "asc" }, take: 200 });
289
+ }),
290
+
291
+ "add-product": handleOp(ops, "add-product", async (ctx, input) => {
292
+ const d = await db(ctx);
293
+ return d.product.create({ data: { name: input.name, priceCents: input.priceCents, tags: [] } });
294
+ }),
295
+
296
+ "place-order": handleOp(ops, "place-order", async (ctx, input) => {
297
+ const d = await db(ctx);
298
+ // Price on the server, from the catalogue.
299
+ const products = await d.product.findMany({ where: { id: { in: input.lines.map((l) => l.productId) } } });
300
+ const totalCents = input.lines.reduce((sum, l) => sum + l.qty * (products.find((p) => p.id === l.productId)?.priceCents ?? 0), 0);
301
+ const order = await d.order.create({ data: { status: "new", totalCents, lines: input.lines, shipTo: input.shipTo, note: input.note } });
302
+ await ctx.notify("new-order", { orderId: order.id });
303
+ return { orderId: order.id, totalCents };
304
+ }),
305
+
306
+ "list-orders": handleOp(ops, "list-orders", async (ctx) => {
307
+ const d = await db(ctx);
308
+ return d.order.findMany({ orderBy: { createdAt: "desc" } });
309
+ }),
310
+
311
+ "set-order-status": handleOp(ops, "set-order-status", async (ctx, input) => {
312
+ const d = await db(ctx);
313
+ const order = await d.order.update({ where: { id: input.orderId }, data: { status: input.status } });
314
+ await ctx.notify("order-status", { orderId: order.id, status: order.status }, { to: { viewerIds: [order.ownerId] } });
315
+ return order;
316
+ }),
317
+ },
318
+ };
319
+ ```
134
320
 
135
- ## Testing your app
321
+ There is no access check in this file. `pluginDb(ctx)` returns a client scoped to the instance and
322
+ filtered for `ctx.viewer`. A customer's `order.findMany` returns their own rows; a staff member's
323
+ returns every row of the shop. A customer calling `set-order-status` is refused before the handler
324
+ runs, because the manifest opens it to `staff` and `owner` only. A packer — a composed role scoped
325
+ to `status: ["preparing"]` — receives only those rows, with `note` blanked, and an update from
326
+ `preparing` to anything other than `shipped` is refused with the allowed move named.
136
327
 
137
- The helpers run your REAL server code against an in-memory database built from your own
138
- `plugin.json`, with the same compiled rules the production client uses. What passes here is what
139
- the real database will do.
328
+ ### Tools
140
329
 
141
330
  ```ts
331
+ // app.tsx (excerpt)
332
+ import { opTool } from "esoul-sdk";
333
+ import { ops } from "./ops";
334
+
335
+ toolkitCreator: (identifier) => {
336
+ const base = identifier.instanceName.replace(/[^a-zA-Z0-9]/g, "_");
337
+ const cfg = { pluginId: "shop", nodeId: identifier.nodeId };
338
+ return {
339
+ [`browse_${base}`]: opTool(ops, "browse", {
340
+ ...cfg,
341
+ description: `List the products of "${identifier.instanceName}".`,
342
+ readOnly: true,
343
+ publicSafe: true,
344
+ say: (rows) => ({ text: rows.map((p) => `${p.name} — ${(p.priceCents / 100).toFixed(2)} (id ${p.id})`).join("\n") || "No products." }),
345
+ }),
346
+ [`set_order_status_${base}`]: opTool(ops, "set-order-status", {
347
+ ...cfg,
348
+ description: `Move an order of "${identifier.instanceName}" to a new status.`,
349
+ say: (o) => ({ text: `Order ${o.id} is now ${o.status}.` }),
350
+ }),
351
+ };
352
+ },
353
+ ```
354
+
355
+ The tool's parameters are the op's input schema; a field the op does not declare is refused with
356
+ the field named. The tool runs as the person who invoked it, on every surface.
357
+
358
+ ### UI
359
+
360
+ ```tsx
361
+ // ui/shop-ui.tsx (excerpt)
362
+ "use client";
363
+ import { callPluginOp } from "esoul-sdk";
364
+ import { useSignInWall, useViewer } from "esoul-sdk/react";
365
+
366
+ export function ShopUi({ nodeId }: { nodeId: string }) {
367
+ const viewer = useViewer(); // { kind, userId, role, canEdit, signedIn, customRole, can }
368
+ const wall = useSignInWall();
369
+ const order = async (lines, shipTo) => {
370
+ try {
371
+ return await callPluginOp("shop", "place-order", nodeId, { lines, shipTo });
372
+ } catch (err) {
373
+ wall.raise(err); // shows the sign-in wall on `login-required`, rethrows otherwise
374
+ }
375
+ };
376
+ // viewer.can.models.Order.moves tells a packer's screen which buttons to show;
377
+ // the server enforces the same rule regardless.
378
+ …
379
+ }
380
+ ```
381
+
382
+ ### Test
383
+
384
+ ```ts
385
+ // shop.test.ts
142
386
  import { fakeViewer, memoryDb, runOp } from "esoul-sdk/testing";
143
387
  import manifest from "./plugin.json";
144
388
  import { pluginServer } from "./server";
145
389
 
146
- const ada = fakeViewer("visitor", { userId: "u_ada", role: "requester" });
147
- const lin = fakeViewer("visitor", { userId: "u_lin", role: "requester" });
390
+ const owner = fakeViewer("owner");
391
+ const ada = fakeViewer("visitor", { userId: "u_ada", role: "customer" });
392
+ const lin = fakeViewer("visitor", { userId: "u_lin", role: "customer" });
148
393
 
149
- it("does not let the OTHER one see it", async () => {
394
+ it("a customer sees only their own orders", async () => {
150
395
  const db = memoryDb(manifest);
151
- await runOp(pluginServer, "raise-ticket", { viewer: ada, args: TICKET, db: db.as(ada) });
152
- const { result } = await runOp(pluginServer, "my-tickets", { viewer: lin, args: {}, db: db.as(lin) });
396
+ const { result: p } = await runOp(pluginServer, "add-product", { viewer: owner, args: { name: "Tea", priceCents: 350 }, db: db.as(owner) });
397
+ const shipTo = { name: "Ada", street: "Main 1", city: "Brno" };
398
+ await runOp(pluginServer, "place-order", { viewer: ada, args: { lines: [{ productId: p.id, qty: 1 }], shipTo }, db: db.as(ada) });
399
+ const { result } = await runOp(pluginServer, "list-orders", { viewer: lin, args: {}, db: db.as(lin) });
153
400
  expect(result).toEqual([]);
154
401
  });
402
+
403
+ it("an anonymous caller is asked to sign in", async () => {
404
+ const db = memoryDb(manifest);
405
+ const anon = fakeViewer("anonymous");
406
+ await expect(runOp(pluginServer, "list-orders", { viewer: anon, args: {}, db: db.as(anon) })).rejects.toMatchObject({ code: "login-required" });
407
+ });
408
+ ```
409
+
410
+ `memoryDb(manifest)` builds an in-memory database from the manifest with the same compiled rules the
411
+ platform applies to the real database. `runOp` calls the op with a platform-shaped context and
412
+ records what it notified and emitted.
413
+
414
+ ## API reference
415
+
416
+ ### Manifest (`plugin.json`)
417
+
418
+ | Field | Type | Description |
419
+ |---|---|---|
420
+ | `manifestVersion` | `1` | Schema version. |
421
+ | `id` | string | Application id: lower-case letters, digits, hyphens. Equals the folder name. |
422
+ | `name`, `description`, `version`, `icon` | string | Shown in the store and the install card. `version` is semver. |
423
+ | `applicationType` | string | `plugin_` + id with underscores. Never changes after release. |
424
+ | `entry` | string | The schema module (`app`). |
425
+ | `roles.vocabulary` | string[] | The application's role words. |
426
+ | `roles.default` | object | Mapping from platform kinds (`owner`, `member-edit`, `member-readonly`, `visitor`, `anonymous`, `agent`) to role words. `"inherit"` for `agent` uses the role of the person it acts for. |
427
+ | `roles.describe` | object | One line per role, for the install card. |
428
+ | `roles.attributes` | object | Name → `string`, `int` or `boolean`: what a grant may say about a person. Typed when granted; carried on `viewer.attrs`; read by scoped rules as `viewer.<name>`. |
429
+ | `roles.custom` | object | The envelope for roles the owner composes: per model, which indexed fields may scope (`where`), which fields may be hidden (`hide`), which fields and transition field may be updated (`update`); `ops` a composed role may be handed; `attributes` a grant may carry. |
430
+ | `ops` | object | Op name → `{ access, requires }`. `access`: `write` (default), `read`, `public`, or a list of role words. `requires: "account"` turns an anonymous refusal into `login-required`. |
431
+ | `routes` | object | Route name → `{ access }`, additionally `token` for routes reached with a minted bearer token. |
432
+ | `kickableTasks` | object | Tasks the browser may kick, with their access level. |
433
+ | `pollTasks` | array | `[{ task, everyMinutes }]`: tasks the platform kicks on a schedule (5 to 1440 minutes). |
434
+ | `channel.topics` | object | Topic name → `{ audience, mayAddress, description }`. `audience`: `all` (default), `viewer`, or `role:<word>`. `mayAddress`: roles that may publish on the topic. |
435
+ | `db` | object | Model name → table declaration (see below). |
436
+ | `uses` | object | Slot name → `{ contract, label, optional }`. |
437
+ | `provides` | object | Contracts this application provides, with the tools, events and tables each names. |
438
+ | `connections` | array | OAuth or API-key connections the platform holds for the application. |
439
+ | `webhooks` | string[] | Webhook names handled in `server.ts`. |
440
+ | `workspaceTools` | array | Tools of other applications the server side may call. |
441
+ | `platformApi` | object | `{ min, max? }`: the platform contract versions the application accepts. |
442
+
443
+ npm packages the application needs are declared in a `package.json` in the application folder
444
+ with a `dependencies` field only.
445
+
446
+ Table declaration:
447
+
448
+ | Field | Description |
449
+ |---|---|
450
+ | `scope` | `instance` (rows belong to this instance), `workspace`, or `user` (rows follow the account across instances). |
451
+ | `owner` | `creator` marks each row with the creator's ids so rules can name `creator`. |
452
+ | `fields` | Name → type: `string`, `text`, `int`, `float`, `boolean`, `json`, `datetime`, `string[]`, `ref:<Model>`; `?` for optional, `=value` for a default. |
453
+ | `unique`, `indexes` | Field groups. An index entry may be `{ fields, kind }` with `kind` `btree` (default), `contains` (list membership) or `text` (case-insensitive substring). |
454
+ | `sealed` | Fields encrypted at rest and readable only by the row's owner. |
455
+ | `rules` | `read`, `create`, `update`, `delete`: `"anyone"`, a list of principals (`creator`, role words, or a scoped role `{ "role": "customer", "where": { "customer": "viewer.customer" } }`), or `{ roles, requires, creatorMay }`. A scoped role reads only the rows its `where` names, creates inside them, and grants nothing to a holder without the attribute. A model without rules is writable by the owner role only. |
456
+
457
+ Platform columns on every table: `id`, `workspaceId`, `nodeId`, `ownerId`, `createdBy`,
458
+ `createdAt`, `updatedAt`, `deletedAt`.
459
+
460
+ ### `esoul-sdk`
461
+
462
+ | Export | Description |
463
+ |---|---|
464
+ | `ApplicationSchema`, `EventDefinition`, `EventTypes`, `ApplicationIdentifier`, `ApplicationPort` | The schema contract: events, `stateCreator`, `toolkitCreator`, `getStateDescription`, `tasks`, `channel`, `reconstructStateFromEventLog`. |
465
+ | `defineOps(inputs)` | Declares the input schema of every op once. |
466
+ | `handleOp(ops, name, fn)` | Wraps an op handler so it receives parsed input; an undeclared or malformed field is refused as `invalid`, naming the field. |
467
+ | `opTool(ops, name, { pluginId, nodeId, description, say, then?, readOnly?, publicSafe? })` | Derives an agent tool from an op. `say` turns the op's result into the tool's text; `readOnly` admits a read-scoped token; `publicSafe` allows use on a public storefront. |
468
+ | `definePluginChannel({ applicationType, topics })` | Declares the realtime channel placed on the schema as `channel`. |
469
+ | `defineBindingEvent`, `checkBinding`, `bindingsOf` | Bindings: the event that records a slot being filled, the check that a provider satisfies a contract, and the current bindings from state. |
470
+ | `resolveAppRole`, `roleKeyFor` | The mapping from platform kinds to role words that the platform itself uses. |
471
+ | `compileEnvelope`, `compileCustomRole`, `CustomRoleError` | The compiler for composed roles, for tests that assert on them. |
472
+ | `coerceAttr`, `coerceAttrs`, `attrProblem`, `ATTR_TYPES` | Attribute typing: what a typed value becomes, and the sentence for one that does not fit. |
473
+ | `chartSvg(spec)` | Renders a time-series chart with panels, markers and gaps to SVG; text is drawn as glyph outlines so it renders on a server without fonts. |
474
+ | `incompleteStateNotice`, `missingStateKeys` | For `getStateDescription`: reports a state that did not load instead of describing it as empty. |
475
+ | `deterministicReducerId(prefix, seed)` | A stable id for use inside a reducer. |
476
+ | `callPluginOp(pluginId, op, nodeId, args?)` | Calls an op from the browser or from a tool. Throws `PluginCallError` carrying `code` (`invalid`, `forbidden`, `login-required`), `detail` and `signInPath`. |
477
+ | `kickPluginTask(args)`, `pluginRouteUrl(...)` | Kicks a task; builds a route's URL. |
478
+ | `timingSafeEqual`, `nanoid` | Utilities. |
479
+
480
+ ### `esoul-sdk/server`
481
+
482
+ | Export | Description |
483
+ |---|---|
484
+ | `PluginServerModule` | `{ ops, routes, webhooks }` exported as `pluginServer` from `server.ts`. |
485
+ | `PluginOpContext` | `pluginId`, `opName`, `workspaceId`, `nodeId`, `instanceName`, `viewer`, `args`, `origin`, `cloudConnectionId`, `apps` (bound applications), `notify(topic, data, { to? })`, `emit(eventName, eventData)`. |
486
+ | `PluginRouteContext` | The op context plus `request`, `method`, `searchParams`, `canWrite`. Handlers return a `Response`. |
487
+ | `PluginViewer` | `kind`, `userId`, `viewerIds`, `role`, `customRole`, `attrs`, `canWrite`, `shareId`, `agent`. |
488
+ | `pluginDb<T>(ctx)` | A typed client over the application's tables, scoped and filtered for `ctx.viewer`. Per model: `findMany`, `findUnique`, `count`, `aggregate`, `groupBy`, `create`, `createMany`, `update`, `updateMany`, `upsert`, `delete`, `deleteMany`; `$transaction`. Refusals throw with `code` `forbidden`, `login-required` or `invalid`. |
489
+ | `viewerProfile(viewer)` | The caller's own account (`name`, `email`, `picture`), or `null`. |
490
+ | `sseStream(fn)` | A server-sent event response for a route. |
491
+ | `mintRouteToken(ctx, { route, ttlSeconds?, label? })` | Mints a bearer token for a route declared `"access": "token"`. Returns `{ token, url, expiresAt }`; `url` is built on `ctx.origin`. Expiry is the only revocation; 30 days at most. |
492
+ | `readAppState(nodeId)` | The folded state of another application in the workspace. |
493
+ | `callWorkspaceTool({ ... })` | Calls a tool of another application, gated by the manifest's `workspaceTools`. |
494
+ | `computer(ctx, machineNodeId)` | A paired machine: `status`, `run`, `runToEnd`, `claude`, `claudeToEnd`, `readFile`, `fetchJson`, `python`, `runJson`. Waits are capped and the approval gate is reported. |
495
+ | `emitPluginAppEvent(args)` | Appends an event to the application's own timeline from server code. |
496
+ | `generateAppImage(ctx, args)` | Generates an image, scales it to web weight, stores it and returns its URL. Refused in a Forge preview. |
497
+ | `renderChartImage(ctx, { name, svg })` | Renders an SVG to PNG and returns `{ url }` (installed) or `{ base64 }` (preview). |
498
+ | `setAppRole(ctx, { email, role, attrs? })` | The owner gives an account one of the application's role words, or a composed role, with attribute values (`{ customer: "204" }`) validated against `roles.attributes`. Recorded as an event. |
499
+ | `listAppRoles(ctx)` | `{ people, roles, custom, envelope }`. |
500
+ | `defineAppRole(ctx, definition)`, `removeAppRole(ctx, name)` | The owner composes or removes a role inside `roles.custom`. |
501
+ | `getPluginConnectionCredentials(ctx)` | The credentials of the instance's bound connection. |
502
+ | `pluginFiles(ctx)`, `filesForOp(ctx)` | Workspace files and file sources from server code. |
503
+
504
+ ### `esoul-sdk/react`
505
+
506
+ | Hook | Description |
507
+ |---|---|
508
+ | `useViewer()` | The public viewer: `kind`, `userId`, `role`, `canEdit`, `signedIn`, `attrs`, `customRole`, `can`. `can` lists the ops a composed role may call and, per model, the hidden fields, updatable fields and allowed moves. |
509
+ | `useSignInWall()` | `{ needed, reason, signIn, raise, ask, serverSays }`. `raise(err)` shows the wall when `err.code === "login-required"` and rethrows anything else; `ask(call)` attempts a call and returns `null` (wall raised) on `login-required`; `serverSays` is the server's answer so far (`null`, `"account"`, `"no-account"`). |
510
+ | `useAppCanEdit()` | Whether the current viewer may write. |
511
+ | `usePluginEventDispatch()` | Dispatches one of the application's events from the UI. |
512
+ | `usePluginRealtime({ channel, workspaceId, nodeId, topics, enabled? })` | Subscribes to the instance's channel. Returns `{ data, latestData, error, state }`. |
513
+ | `useWorkspaceTools(identity)` | Lists and calls tools of other applications from the UI, subject to `workspaceTools`. |
514
+ | `usePluginWorkspaceFiles()`, `usePluginFileUpload()`, `useFileSources()`, `useFileSourceEntries()` | Workspace files, upload, and file sources (workspace, Google Drive, providers). |
515
+
516
+ ### `esoul-sdk/testing`
517
+
518
+ | Export | Description |
519
+ |---|---|
520
+ | `memoryDb(manifest, options?)` | An in-memory database compiled from the manifest. Starts as the application's own code; `.as(viewer)` returns the same rows as another caller. `$rules` exposes the compiled rules. |
521
+ | `fakeViewer(kind, { userId?, viewerIds?, role?, name?, email?, attrs? })` | A viewer of a kind. `name`/`email` are what `viewerProfile` returns for it; `attrs` are what a scoped rule reads. |
522
+ | `runOp(server, opName, { viewer, args?, db?, apps?, notifyFails? })` | Calls `pluginServer.ops[opName]` with a platform-shaped context. Returns `{ result, notified, emitted }`; throws the op's refusals. |
523
+ | `fakeApps(fixtures)` | Bound applications for `ctx.apps`. |
524
+ | `capture()` | A recorder for callbacks. |
525
+ | `startMockOAuth(opts?)` | A local OAuth server for connection tests. |
526
+
527
+ ### Command line
528
+
529
+ ```bash
530
+ npx esoul-app validate <dir>
155
531
  ```
156
532
 
157
- `runOp` also records what your op NOTIFIED and who it addressed, and `notifyFails` makes
158
- notifying throw — the question worth asking of any op that tells somebody after it has written
159
- something: does the write survive?
533
+ Exits 0 when the folder is a well-formed application package; otherwise prints each problem.
534
+
535
+ ## Rules
536
+
537
+ > **Note.** State is a fold of events. A processor must be a pure, idempotent function of
538
+ > `(state, event)`. Ids and timestamps are minted in `dataCreator`, never in a processor. An event
539
+ > that replaces a whole value carries a collapse key so a burst folds to one event.
540
+
541
+ > **Note.** Access is declared, not checked. An op, route or task that tests the viewer itself has
542
+ > a second answer to a question the platform already answered; the platform's answer is the one
543
+ > enforced. Use `viewer.role` and `viewer.can` to decide what a screen shows, not what a request
544
+ > may do.
545
+
546
+ > **Warning.** A task handler is re-run from the top after every step boundary. Every side effect
547
+ > — a dispatch, a fetch, an emit, a random id — belongs inside `ctx.step.run`. A side effect outside
548
+ > a step runs once per replay.
549
+
550
+ > **Warning.** Application code imports the platform only through `esoul-sdk`. A relative import
551
+ > into the platform's source is refused by the checks and by the install.
552
+
553
+ > **Note.** `viewer.signedIn` in the UI is the page's estimate. Whether a request needs an account
554
+ > is decided by the op; attempt the call and let `useSignInWall().raise` act on `login-required`.
555
+
556
+ ## Documentation
557
+
558
+ | | |
559
+ |---|---|
560
+ | [1. Getting started](docs/01-getting-started.md) | The Forge loop, package layout, the smallest complete application |
561
+ | [2. The manifest](docs/02-manifest.md) | Every field of `plugin.json` |
562
+ | [3. Events and state](docs/03-events-and-state.md) | `dataCreator`, `processor`, collapse keys, replay |
563
+ | [4. Tools](docs/04-tools.md) | Tools on every surface; deriving a tool from its op |
564
+ | [5. The UI](docs/05-ui.md) | React, hooks, theme, responsive rules |
565
+ | [6. The server](docs/06-server.md) | Ops, routes, webhooks, `computer`, charts, calling other applications |
566
+ | [7. Background tasks](docs/07-background-tasks.md) | The durable executor, the replay model, concurrency |
567
+ | [8. Connections](docs/08-connections.md) | OAuth and API-key connections held by the platform |
568
+ | [9. Files](docs/09-files.md) | Workspace files, Drive, file providers |
569
+ | [10. Testing](docs/10-testing.md) | The fold contract, ops, tasks and rules as tests |
570
+ | [11. Shipping](docs/11-shipping.md) | The repository, review, release, install, dependencies |
571
+ | [12. Rules and failures](docs/12-rules.md) | Each rule with the failure it prevents |
572
+ | [13. People and access](docs/13-people-and-access.md) | Viewer, roles, access levels, composed roles, the sign-in wall |
573
+ | [14. Your own tables](docs/14-database.md) | `db`, rules, scopes, sealed fields, indexes, migrations |
574
+ | [15. Realtime](docs/15-realtime.md) | Topics, audiences, addressing |
575
+ | [16. Bindings](docs/16-bindings.md) | Slots, contracts, reaching another application |
160
576
 
161
577
  ## Versions
162
578
 
163
- - **0.8.0** — the three things making an app LOOK like a product turned out to
164
- need: `generateAppImage` (one call — generates, brings the bytes to web
165
- weight, hands back an https URL; a 2.4 MB PNG is not a page), `setAppRole` /
166
- `listAppRoles` (an owner gives one esoul account one of your role words, by
167
- email; the platform does every check and records it on the app's timeline),
168
- and `wall.ask()` — attempt a read and let a `login-required` refusal answer,
169
- because `viewer.signedIn` is this page's guess and can disagree with the
170
- session your ops run under. An app that gated on the guess showed a
171
- signed-in shopper a sign-in wall over a full basket.
172
- - **0.7.0** — what a catalogue-sized app needs. **Index kinds**: a plain group is a btree,
173
- `{ fields: ["tags"], kind: "contains" }` answers `{ tags: { has: … } }` on a list, and
174
- `{ fields: ["title"], kind: "text" }` answers `{ title: { contains: … } }` — so a department
175
- and a search term are questions for the database, with `cursor` paging. `contains` ignores
176
- case in both clients (a search box that misses "earl grey" is broken quietly). Asking a list a
177
- scalar's question (or the reverse) is refused with the right one named. **`ctx.emit`** — an op
178
- records on its OWN timeline, so a fact reaches the fold whether a person or an agent caused it.
179
- A list field defaults to `[]` (it used to refuse every create), a rule-less model defaults to
180
- the role your manifest calls the owner, and `viewerProfile` is a real export rather than a
181
- declaration. Documented the client surface that was already there: `aggregate`, `groupBy`,
182
- `$transaction`, the `*Many` writes.
183
- - **0.6.0** — the full-stack release. Your own tables (`db`, `pluginDb`, rules, scopes, sealed
184
- fields, additive migrations applied on install). `viewer` on every seam, app `roles`, per-surface
185
- access levels with the sign-in wall. Realtime audiences: a topic says who hears it and the mint
186
- issues a token per channel. Bindings: `uses`/`provides`, contracts, `ctx.apps.<slot>`. A tool now
187
- acts for the person who invoked it rather than for the platform. `viewerProfile` — the caller's
188
- own esoul account, server-side only. Testing: `memoryDb`, `fakeViewer`, `runOp`.
189
- - 0.5.0 — server routes (`pluginServer.routes`), `sseStream`, plugin realtime.
190
- - 0.3.0 — renamed from `@externalsoul/plugin-sdk`. `readAppState`, `callWorkspaceTool`, `nanoid`
191
- on the index. The import wall: an app reaches the platform only through this package.
192
- - 0.2.0 — file sources and providers.
579
+ | Version | Changes |
580
+ |---|---|
581
+ | 0.15.0 | **Files work in a Forge box**, and do more: `FilesApi.list(…, opts)` narrowed at the source, `listAll`, `resolvePath`, `readMany`, `write` (make or replace by name — a label file beside its image; manifest `fileSources.write`), `readGrant` + `fileGrantUrl` (signed, expiring URLs for an `<img>` in a preview and for a machine). UI: `useFolderAutocomplete`, `useFileUrls`, `useFileSourceEntries(…, opts)` with `loadMore`, `listFileEntries`, `resolveFilePath`. `ImageLabeler` / `PolygonCanvas` — the Explorer's polygon editor as a component. Pure: `pairImagesWithLabels`, `serializeLabelMe`, `parseLabelMe`, `labelFileNameFor`. Testing: `memoryFiles`. |
582
+ | 0.14.0 | Attribute-scoped access: `roles.attributes` (typed; validated when granted; `viewer.attrs`); scoped rule principals `{ role, where }` with `viewer.<attribute>`, applied to reads, aggregates, updates and deletes and filling or refusing creates, in both database clients; a rule combining `creator` with a scoped role is refused. `compileManifestRules` is the one reader of a manifest's rules. VIEW AS personas carry attributes (`visitor-a?customer=204`). |
583
+ | 0.13.0 | Composed roles: `roles.custom` envelope; `defineAppRole`, `removeAppRole`; `setAppRole` with attributes; `listAppRoles` returns composed roles and the envelope. Reads, updates and deletes are narrowed by the composition in both database clients; surfaces not handed to the role are refused; `useViewer().customRole` and `can`. `access: [<roles>]` on a surface. Composed roles are personas in the Forge. |
584
+ | 0.12.0 | Token routes: `"access": "token"` and `mintRouteToken`. `ctx.origin` on ops. |
585
+ | 0.11.0 | `chartSvg`, `renderChartImage`. `computer().python()` and `runJson()` with `truncated` outputs. `APPROVAL_WAIT`. Table refusals name the constraint. |
586
+ | 0.10.0 | `computer(ctx, machineNodeId)`. Server code in a Forge preview reaches the workspace (`callWorkspaceTool`, `readAppState`, `computer`), gated by `workspaceTools`. |
587
+ | 0.9.0 | `defineOps`, `handleOp`, `opTool`: one declaration for an op's input, its server parser and its tool. |
588
+ | 0.8.0 | `generateAppImage`; `setAppRole` and `listAppRoles`; `useSignInWall().ask()` and `serverSays`. |
589
+ | 0.7.0 | Index kinds (`contains`, `text`) and cursor paging; `ctx.emit`; list field defaults; `aggregate`, `groupBy`, `$transaction`, `*Many` documented. |
590
+ | 0.6.0 | Tables (`db`, `pluginDb`, rules, scopes, sealed fields, additive migrations). `viewer` on every entry point, roles, access levels, the sign-in wall. Realtime audiences. Bindings. `viewerProfile`. Testing: `memoryDb`, `fakeViewer`, `runOp`. |
591
+ | 0.5.0 | Routes, `sseStream`, realtime channels. |
592
+ | 0.3.0 | Renamed from `@externalsoul/plugin-sdk`. `readAppState`, `callWorkspaceTool`. The import wall. |
593
+ | 0.2.0 | File sources and providers. |
594
+
595
+ ## Releasing (maintainers)
596
+
597
+ ```bash
598
+ cd packages/esoul-sdk
599
+ npm run release:check # fresh docs + build, dist completeness, pack, clean-room install, every entry point, the CLI
600
+ npm publish # prepack rebuilds docs and dist; the owner's npm login
601
+ npm view esoul-sdk version
602
+ ```
603
+
604
+ The check refuses a version that is already on npm and a `dist/` that lacks a module `src/` has.
605
+ Commit the regenerated `llms.txt`, `llms-full.txt` and `api-reference.md` with the version bump.