esoul-sdk 0.7.0 → 0.16.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/README.md +575 -153
- package/api-reference.md +3291 -0
- package/dist/assets.d.ts +72 -0
- package/dist/assets.js +139 -0
- package/dist/audience.d.ts +2 -0
- package/dist/audience.js +2 -0
- package/dist/bindings.d.ts +3 -0
- package/dist/bindings.js +3 -0
- package/dist/chart-font.d.ts +19 -0
- package/dist/chart-font.js +15 -0
- package/dist/chart.d.ts +83 -0
- package/dist/chart.js +247 -0
- package/dist/computer.d.ts +191 -0
- package/dist/computer.js +226 -0
- package/dist/db/client-core.d.ts +38 -0
- package/dist/db/client-core.js +68 -1
- package/dist/db/compile-rules.d.ts +60 -2
- package/dist/db/compile-rules.js +178 -30
- package/dist/db/custom-roles.d.ts +156 -0
- package/dist/db/custom-roles.js +280 -0
- package/dist/db/memory-client.d.ts +0 -15
- package/dist/db/memory-client.js +13 -23
- package/dist/db/schema-gen.js +2 -8
- package/dist/editor-sync.d.ts +130 -0
- package/dist/editor-sync.js +413 -0
- package/dist/files.d.ts +70 -0
- package/dist/files.js +48 -0
- package/dist/helpers.d.ts +10 -0
- package/dist/helpers.js +10 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +25 -0
- package/dist/labelme.d.ts +84 -0
- package/dist/labelme.js +118 -0
- package/dist/manifest.d.ts +400 -106
- package/dist/manifest.js +59 -5
- package/dist/ops.d.ts +115 -0
- package/dist/ops.js +120 -0
- package/dist/react.d.ts +280 -4
- package/dist/react.js +100 -3
- package/dist/server.d.ts +268 -10
- package/dist/server.js +120 -4
- package/dist/testing/db.d.ts +6 -0
- package/dist/testing/db.js +3 -8
- package/dist/testing/files.d.ts +22 -0
- package/dist/testing/files.js +175 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/types.d.ts +47 -0
- package/docs/02-manifest.md +2 -0
- package/docs/03-events-and-state.md +8 -0
- package/docs/04-tools.md +58 -26
- package/docs/05-ui.md +35 -0
- package/docs/06-server.md +121 -7
- package/docs/07-background-tasks.md +7 -9
- package/docs/09-files.md +126 -17
- package/docs/10-testing.md +6 -0
- package/docs/11-shipping.md +10 -2
- package/docs/13-people-and-access.md +180 -0
- package/docs/14-database.md +6 -0
- package/docs/15-realtime.md +3 -0
- package/docs/17-editing-and-merging.md +155 -0
- package/llms-full.txt +1296 -214
- package/llms.txt +3 -3
- package/package.json +9 -4
- package/schemas/plugin.schema.json +134 -15
- package/scripts/build-api-reference.mjs +104 -0
- package/scripts/build-llms.mjs +1 -2
package/README.md
CHANGED
|
@@ -1,183 +1,605 @@
|
|
|
1
1
|
# esoul-sdk
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
##
|
|
85
|
+
## Concepts
|
|
20
86
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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` |
|
|
130
|
-
| `esoul-sdk/
|
|
131
|
-
| `esoul-sdk/
|
|
132
|
-
| `esoul-sdk/testing` | `memoryDb`, `fakeViewer`, `runOp`, `fakeApps`, `capture`, `startMockOAuth`
|
|
133
|
-
| `esoul-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
147
|
-
const
|
|
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("
|
|
394
|
+
it("a customer sees only their own orders", async () => {
|
|
150
395
|
const db = memoryDb(manifest);
|
|
151
|
-
await runOp(pluginServer, "
|
|
152
|
-
const
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
-
|
|
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.
|