esoul-sdk 0.3.0 → 0.6.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 (46) hide show
  1. package/README.md +135 -24
  2. package/dist/audience.d.ts +103 -0
  3. package/dist/audience.js +142 -0
  4. package/dist/bindings.d.ts +164 -0
  5. package/dist/bindings.js +163 -0
  6. package/dist/db/client-core.d.ts +154 -0
  7. package/dist/db/client-core.js +274 -0
  8. package/dist/db/compile-rules.d.ts +199 -0
  9. package/dist/db/compile-rules.js +390 -0
  10. package/dist/db/memory-client.d.ts +136 -0
  11. package/dist/db/memory-client.js +323 -0
  12. package/dist/db/schema-gen.d.ts +103 -0
  13. package/dist/db/schema-gen.js +329 -0
  14. package/dist/helpers.d.ts +67 -0
  15. package/dist/helpers.js +125 -8
  16. package/dist/index.d.ts +24 -0
  17. package/dist/index.js +22 -0
  18. package/dist/manifest.d.ts +445 -13
  19. package/dist/manifest.js +211 -5
  20. package/dist/react.d.ts +29 -0
  21. package/dist/react.js +10 -0
  22. package/dist/roles.d.ts +43 -0
  23. package/dist/roles.js +56 -0
  24. package/dist/server.d.ts +165 -0
  25. package/dist/server.js +80 -0
  26. package/dist/testing/db.d.ts +69 -0
  27. package/dist/testing/db.js +94 -0
  28. package/dist/testing/index.d.ts +14 -0
  29. package/dist/testing/index.js +9 -0
  30. package/dist/testing/ops.d.ts +84 -0
  31. package/dist/testing/ops.js +76 -0
  32. package/dist/types.d.ts +22 -1
  33. package/docs/04-tools.md +5 -2
  34. package/docs/05-ui.md +30 -0
  35. package/docs/06-server.md +49 -0
  36. package/docs/07-background-tasks.md +29 -3
  37. package/docs/10-testing.md +18 -0
  38. package/docs/12-rules.md +3 -2
  39. package/docs/13-people-and-access.md +148 -0
  40. package/docs/14-database.md +115 -0
  41. package/docs/15-realtime.md +88 -0
  42. package/docs/16-bindings.md +79 -0
  43. package/llms-full.txt +715 -31
  44. package/llms.txt +4 -0
  45. package/package.json +7 -3
  46. package/schemas/plugin.schema.json +323 -9
package/docs/12-rules.md CHANGED
@@ -33,5 +33,6 @@
33
33
  - "State reverts after reload" → `reconstructStateFromEventLog` unset, or a processor minted ids.
34
34
  - "check_app is red on registry" → the manifest failed validation or the import wall refused a
35
35
  file; the detail names it.
36
- - "My tool needs the database" → write an op; call it with `callPluginOp`; expect a refusal in
37
- the workbench (no database behind a preview) and use `read_app_state` there.
36
+ - "My tool needs the database" → declare the tables in the manifest's `db` and write an op over
37
+ `pluginDb(ctx)`; call it with `callPluginOp`. The workbench runs it over in-memory tables with
38
+ the same rules, so VIEW AS shows what each person may read.
@@ -0,0 +1,148 @@
1
+ # 13 · People and access
2
+
3
+ Your app will be used by people with different relationships to it: the person who installed it,
4
+ the people they invited, and everyone else. This page is how the platform tells them apart, and
5
+ how you say what each may do — without writing a single access check.
6
+
7
+ ## The viewer
8
+
9
+ Every seam of your app receives a `viewer`. The UI reads it with `useViewer()`; the server gets
10
+ `ctx.viewer` on an op, a route and a tool; a task carries `ctx.kickedBy` (who asked) and runs as
11
+ your app's own code.
12
+
13
+ ```ts
14
+ viewer.kind // "owner" | "member" | "visitor" | "anonymous" | "agent" | "internal"
15
+ viewer.userId // the account, or null
16
+ viewer.role // YOUR app's word for them (see below)
17
+ viewer.canWrite // may they change the workspace at all
18
+ ```
19
+
20
+ - **owner** — whose workspace this is.
21
+ - **member** — someone the owner invited, with edit or read-only access.
22
+ - **visitor** — signed in, but not in the workspace: someone on a share link.
23
+ - **anonymous** — not signed in. Still has an identity (a cookie) so rows they create can be
24
+ theirs, and stay theirs after they sign in.
25
+ - **agent** — a run acting for one of the above. It gains nothing: everything about what it may
26
+ see comes from the person it acts for.
27
+ - **internal** — your own app's server code (a task, the install job). Rules do not apply to it;
28
+ scope still does.
29
+
30
+ The platform resolves this. An app cannot supply, widen or forge one — the import wall keeps you
31
+ from reading a cookie or a header to invent an identity.
32
+
33
+ ## Who they actually are
34
+
35
+ A viewer is an identity, not a person's details. When your app needs the details — a name to put
36
+ on a confirmation, an address to send one to — ask for the CALLER's own account:
37
+
38
+ ```ts
39
+ import { viewerProfile } from "esoul-sdk/server";
40
+
41
+ const me = await viewerProfile(ctx.viewer); // { userId, email, name, picture } | null
42
+ ```
43
+
44
+ Server only, one database read, and only ever about the caller: there is no argument for whose
45
+ profile to read, so an app cannot look somebody up. It is null for anyone without an account,
46
+ which is the same thing `requires: "account"` is about. Use it to pre-fill a form rather than
47
+ asking a signed-in person to type what the platform already knows.
48
+
49
+ ## Roles — your app's own vocabulary
50
+
51
+ The platform's words are about a workspace. Your app's words are about your app. A help desk has
52
+ requesters and agents; a library has readers and librarians; a clinic has patients and staff.
53
+
54
+ ```json
55
+ "roles": {
56
+ "vocabulary": ["requester", "agent", "supervisor"],
57
+ "default": {
58
+ "owner": "supervisor",
59
+ "member-edit": "agent",
60
+ "member-readonly": "agent",
61
+ "visitor": "requester",
62
+ "anonymous": "requester",
63
+ "agent": "inherit"
64
+ },
65
+ "describe": {
66
+ "requester": "raises tickets and sees their own",
67
+ "agent": "answers every ticket; never the billing notes",
68
+ "supervisor": "everything, including who handled what"
69
+ }
70
+ }
71
+ ```
72
+
73
+ `default` maps the platform's kinds onto your words. Two sentinels are the platform's, not yours:
74
+ `inherit` (an agent takes the role of whoever it acts for) and `none` (this app has no word for
75
+ that kind of caller, so no rule can match them — the right answer when a stranger has no business
76
+ here at all).
77
+
78
+ **The workspace owner can override it per person, per app.** In workspace sharing, an app that
79
+ declares a vocabulary gets a picker showing your words and your descriptions. So one colleague is
80
+ an `agent` in the help desk and another a `supervisor`, in the same workspace, and everyone
81
+ outside is a `requester` whether or not they are signed in.
82
+
83
+ A role is a WORD, never a permission. It decides what your rules mean. Whether someone may write
84
+ the workspace at all is still the platform's answer: a read-only collaborator you call `agent`
85
+ still cannot write.
86
+
87
+ ## Access levels — nothing opens by default
88
+
89
+ Every op, route and task is `write` unless you say otherwise: the owner and editing members only.
90
+
91
+ ```json
92
+ "ops": {
93
+ "list-public-articles": { "access": "public" },
94
+ "raise-ticket": { "access": "public", "requires": "account" },
95
+ "my-tickets": { "access": "public", "requires": "account" },
96
+ "close-ticket": {}
97
+ },
98
+ "routes": { "queue-stream": { "access": "read" } },
99
+ "kickableTasks": { "escalate": { "access": "write" } }
100
+ ```
101
+
102
+ | level | who reaches it |
103
+ |---|---|
104
+ | `write` (default) | the owner and members who may edit |
105
+ | `read` | anyone who can SEE the workspace, including read-only members |
106
+ | `public` | anyone holding the app's share link, with no workspace access at all |
107
+ | `public` + `requires: "account"` | the same, but they must be signed in |
108
+
109
+ **`read` is not "outsiders".** It means workspace members. Someone on a share link is reached with
110
+ `public`. Getting this wrong is the commonest mistake: an app that declares "my own records" as
111
+ `read` refuses the very people it was written for.
112
+
113
+ **`requires: "account"` is the sign-in wall.** Without it an anonymous caller gets `forbidden`,
114
+ which a UI can only render as an error. With it they get `login-required` and a way back, and
115
+ `useSignInWall()` turns it into a wall:
116
+
117
+ ```tsx
118
+ const wall = useSignInWall();
119
+ try { await op("raise-ticket", args); }
120
+ catch (e) { wall.raise(e); } // login-required → the wall; anything else rethrows
121
+ ```
122
+
123
+ Every `public` door is listed on the install card, by name, before anyone installs your app.
124
+
125
+ ## What a refusal looks like
126
+
127
+ One shape everywhere, with a code your UI can act on:
128
+
129
+ | code | status | means |
130
+ |---|---|---|
131
+ | `forbidden` | 403 | the rules say no, and signing in would not change it |
132
+ | `login-required` | 401 | sign in and try again; `signInPath` says where |
133
+ | `not-bound` | 409 | a slot this app needs is not filled yet |
134
+ | `rate-limited` | 429 | too many calls at a public door |
135
+ | `invalid` | 400 | the call itself is malformed |
136
+
137
+ In a workbench the refusal also carries `detail`, written for YOU: which rule refused and what to
138
+ change. It is never returned in production, and never contains the caller's data.
139
+
140
+ ## Seeing it before anyone installs
141
+
142
+ A workbench has **VIEW AS**: a switcher for the platform's kinds — owner, member, read-only
143
+ member, two different visitors, anonymous, agent. Your app resolves each one through your own
144
+ `roles`. Two visitors on purpose: the question worth asking is not "does a person see their own
145
+ record" but "does the OTHER one see it".
146
+
147
+ `look_at_app` takes the same personas, so you can photograph the screen each of them gets — the
148
+ one screen an author can otherwise never see, because the author is always the owner.
@@ -0,0 +1,115 @@
1
+ # 14 · Your own tables
2
+
3
+ The fold (docs/03) is for what is shared, small and worth scrubbing. Records that belong to one
4
+ person, that grow without limit, and that must never revert because somebody scrubbed a timeline
5
+ are none of those. Those go in your app's own tables.
6
+
7
+ You declare them. The platform generates the schema, the typings and the rules, applies the
8
+ migration on install, and scopes every query. You never write a migration, an ORM, or a
9
+ `WHERE userId = …`.
10
+
11
+ ## Declaring
12
+
13
+ ```json
14
+ "db": {
15
+ "Article": {
16
+ "fields": { "title": "string", "body": "text", "published": "boolean=true" },
17
+ "unique": [["title"]],
18
+ "indexes": [["published"]],
19
+ "rules": { "read": ["anyone"], "write": ["supervisor"] }
20
+ },
21
+ "Ticket": {
22
+ "owner": "creator",
23
+ "fields": { "status": "string=open", "subject": "string", "detail": "text", "article": "ref:Article?" },
24
+ "indexes": [["status"], ["createdAt"]],
25
+ "rules": {
26
+ "read": ["creator", "agent", "supervisor"],
27
+ "create": { "roles": ["requester", "agent"], "requires": "account" },
28
+ "update": { "roles": ["agent", "supervisor"], "creatorMay": ["detail"] },
29
+ "delete": ["supervisor"]
30
+ }
31
+ },
32
+ "ContactDetails": {
33
+ "scope": "user",
34
+ "sealed": ["phone"],
35
+ "fields": { "label": "string", "phone": "string" }
36
+ }
37
+ }
38
+ ```
39
+
40
+ **Field types.** `string`, `text`, `int`, `float`, `boolean`, `datetime`, `json`, `ref:Model`.
41
+ A trailing `?` is optional; `=value` is a default; `[]` is a list. Platform columns (`id`,
42
+ `workspaceId`, `nodeId`, `ownerId`, `createdBy`, `createdAt`, `updatedAt`, `deletedAt`) are added
43
+ for you and may not be declared.
44
+
45
+ **Scope** decides what "this app's rows" means:
46
+
47
+ | scope | rows belong to | use it for |
48
+ |---|---|---|
49
+ | `instance` (default) | ONE app instance | this help desk's tickets |
50
+ | `workspace` | every instance in the workspace | something shared between apps |
51
+ | `user` | one person, across every instance | contact details that follow them |
52
+
53
+ **`owner: "creator"`** makes each row belong to whoever made it, which is what `creator` in a rule
54
+ matches against. It matches every id that person has acted under, so a record created before
55
+ signing in is still theirs afterwards.
56
+
57
+ **`sealed`** fields are encrypted at rest and readable only by the row's owner (and your app's own
58
+ internal code, which is how a background job can still use one). Anyone else reading a sealed
59
+ field gets null, not ciphertext.
60
+
61
+ ## Rules
62
+
63
+ A rule names PRINCIPALS: `"anyone"`, `"creator"`, a role from your vocabulary, or `"apps"` (apps
64
+ bound to yours — see docs/16). The long form adds `requires: "account"` and `creatorMay`, which
65
+ lists the fields a row's creator may change even when they may not otherwise update it.
66
+
67
+ Rules are compiled once, at build time, and the SAME compiled artefact drives the in-memory client
68
+ your tests use, the Prisma client production uses, and the workbench. A rule that names a role
69
+ your app does not declare fails the build, with the key path.
70
+
71
+ ## Using
72
+
73
+ ```ts
74
+ import { pluginDb } from "esoul-sdk/server";
75
+ import type { HelpdeskDb } from "./.esoul/db"; // generated from your manifest
76
+
77
+ async function myTickets(ctx: PluginOpContext) {
78
+ const db = await pluginDb<HelpdeskDb>(ctx);
79
+ return db.ticket.findMany({ orderBy: { createdAt: "desc" }, take: 50 });
80
+ }
81
+ ```
82
+
83
+ That is the whole access story. The same call returns one person's own tickets and an agent's
84
+ whole queue, because `ctx.viewer` is already in the client. There is no argument you could pass to
85
+ read someone else's rows: `workspaceId`, `nodeId` and `ownerId` are injected, and naming one in a
86
+ query is refused as `invalid` rather than quietly overridden.
87
+
88
+ Ordering and filtering are limited to the fields you indexed — an unindexed `orderBy` is a compile
89
+ error in your editor, not a slow query in production.
90
+
91
+ ## Migrations
92
+
93
+ On install the platform computes what your declaration means for the live database and applies it
94
+ in one transaction, recording it in a ledger. **Additive only.** Adding a table, a column or an
95
+ index is applied; changing a column's type, or adding a required column with no default, is
96
+ REFUSED with the column named, and the install does not proceed. A field you removed keeps its
97
+ column and its data — dropping it is a separate, deliberate act.
98
+
99
+ Uninstalling never drops a table. The rows are somebody's records.
100
+
101
+ ## Testing
102
+
103
+ ```ts
104
+ import { fakeViewer, memoryDb, runOp } from "esoul-sdk/testing";
105
+
106
+ const db = memoryDb(manifest); // your rules, compiled from your own plugin.json
107
+ const ada = fakeViewer("visitor", { userId: "u_ada", role: "requester" });
108
+ const lin = fakeViewer("visitor", { userId: "u_lin", role: "requester" });
109
+
110
+ await runOp(pluginServer, "raise-ticket", { viewer: ada, args: TICKET, db: db.as(ada) });
111
+ expect(await db.as(lin).ticket.count()).toBe(0);
112
+ ```
113
+
114
+ The in-memory client and the production one are proven equal by a differential test on every
115
+ build, so a rule you prove here is a rule the database keeps.
@@ -0,0 +1,88 @@
1
+ # 15 · Realtime, and who hears it
2
+
3
+ Your app can push. A screen that is already open moves without anyone asking it to, which is the
4
+ difference between an application and a form.
5
+
6
+ Two declarations, both needed. The manifest says WHO hears each topic; the schema says what the
7
+ topics are and what rides on them.
8
+
9
+ ```json
10
+ "channel": {
11
+ "topics": {
12
+ "notice": { "description": "something everyone watching should see" },
13
+ "ticket-status": { "audience": "viewer", "mayAddress": ["agent", "supervisor"] },
14
+ "new-ticket": { "audience": "role:agent", "mayAddress": ["requester", "agent", "supervisor"] }
15
+ }
16
+ }
17
+ ```
18
+
19
+ ```ts
20
+ export const channel = definePluginChannel({
21
+ applicationType: APP_TYPE,
22
+ topics: {
23
+ notice: { schema: z.object({ what: z.string() }) },
24
+ "ticket-status": { schema: z.object({ ticketId: z.string(), status: z.string() }) },
25
+ "new-ticket": { schema: z.object({ ticketId: z.string(), subject: z.string() }) },
26
+ },
27
+ });
28
+ ```
29
+
30
+ ## Audiences
31
+
32
+ | `audience` | the channel |
33
+ |---|---|
34
+ | `all` (default) | one per instance — anyone watching the app |
35
+ | `viewer` | one per person — "your ticket was answered" |
36
+ | `role:<name>` | one per role — the queue everyone on duty watches |
37
+
38
+ The platform mints a token PER CHANNEL, and the browser asks for a KIND of channel, never for an
39
+ identifier. The server fills in whose channel that is from the viewer it resolved. So one person
40
+ cannot ask for another's channel: the id is not theirs to supply, and a role's channel is simply
41
+ not minted for someone outside that role. Nothing is filtered on arrival, because nothing wrong
42
+ ever arrives.
43
+
44
+ An app that declares no audiences keeps the single channel it always had.
45
+
46
+ ## Sending
47
+
48
+ ```ts
49
+ await ctx.notify("ticket-status", { ticketId, status }); // the CALLER's own channel
50
+ await ctx.notify("ticket-status", { ticketId, status }, { to: { viewerIds: [row.ownerId] } });
51
+ await ctx.notify("new-ticket", { ticketId, subject }, { to: { role: "agent" } });
52
+ ```
53
+
54
+ Unaddressed, a message goes where the topic says. Addressed, **aiming is a separate permission
55
+ from hearing**: a caller may address only ITSELF unless the topic's `mayAddress` names its role.
56
+ Your app's own tasks always may, which is why a follow-up task can tell the person who waited.
57
+
58
+ Two traps worth naming. Both cost the reference app a live drive, and both are easy to repeat:
59
+
60
+ - **Everyone who may DO the thing must be allowed to announce it.** An app that let two roles
61
+ create a record but only one of them announce it refused the others their own action — by their
62
+ own announcement. List every role your `create` rule allows.
63
+ - **A courtesy must not undo a completed write.** The record is already in the table when you
64
+ announce it. Catch a failed notify and report it; do not let it throw out of the op, or a person
65
+ is told "forbidden" about something that exists and tries again.
66
+
67
+ ## Listening
68
+
69
+ ```tsx
70
+ const live = usePluginRealtime<{ ticketId?: string; status?: string }>({
71
+ channel,
72
+ workspaceId: state.workspaceId,
73
+ nodeId: state.nodeId,
74
+ topics: channel.topicNames,
75
+ enabled: !!state.nodeId && viewer.signedIn,
76
+ });
77
+
78
+ useEffect(() => {
79
+ if (live.latestData?.topic === "ticket-status") reload();
80
+ }, [live.latestData, reload]);
81
+ ```
82
+
83
+ **Treat a message as a nudge, not as data.** Re-read through your op, so the rules decide what
84
+ comes back. A payload that carried the row would be a second way to learn about it, and only one
85
+ of them is checked.
86
+
87
+ In a workbench the preview shows a persona only what their channels would carry, so VIEW AS tells
88
+ you the truth: switch to the other visitor and the message is not there.
@@ -0,0 +1,79 @@
1
+ # 16 · Bindings — leaning on another app
2
+
3
+ An app is an island until it can use another one. A booking app that wants real room availability
4
+ either keeps its own counts and guesses, or names one particular rooms app and is married to it.
5
+ Neither is what a person means by "use my room list for this".
6
+
7
+ So you declare a SLOT and the CONTRACT that must stand in it. The owner picks which app fills it.
8
+
9
+ ## Declaring
10
+
11
+ The consumer:
12
+
13
+ ```json
14
+ "uses": { "rooms": { "contract": "rooms/v1", "label": "Rooms", "optional": true } }
15
+ ```
16
+
17
+ The provider:
18
+
19
+ ```json
20
+ "provides": {
21
+ "rooms/v1": { "tools": ["hold_room", "release_room"], "events": ["room_freed"], "models": ["Room"] }
22
+ }
23
+ ```
24
+
25
+ A consumer must also fold the binding event, because the owner's choice lives on your app's own
26
+ timeline — a binding is CONSENT, and consent that is only a column cannot be scrubbed, explained
27
+ or audited:
28
+
29
+ ```ts
30
+ export const bindingSetEvent = defineBindingEvent<BookingData>({ applicationType: APP_TYPE, slots: ["rooms"] });
31
+ // …and put it in the schema's `events`.
32
+ ```
33
+
34
+ The build refuses an app that declares `uses` without it.
35
+
36
+ ## What the platform checks, and when
37
+
38
+ At BIND time, once, loudly — never at call time in front of somebody using the app:
39
+
40
+ - The provider must claim the same contract NAME at a version at least as high. **Versions grow by
41
+ adding**, so a `rooms/v2` provider fills a `rooms/v1` slot; the reverse is refused, because v1
42
+ never heard of what v2 added. That rule is what lets your app keep working while its provider
43
+ moves on.
44
+ - The provider must really HAVE what it claims: the tools it actually mints, the events its schema
45
+ declares, the tables its `db` generates. A manifest is a claim, and a claim that is short is
46
+ refused with the missing part named.
47
+
48
+ ## Using
49
+
50
+ ```ts
51
+ const rooms = ctx.apps?.rooms; // absent when nothing is bound
52
+ if (rooms) {
53
+ const held = await rooms.call("hold_room", { roomId, from, to });
54
+ if (!held.ok) throw new Error(`cannot take this booking: ${held.text}`);
55
+ }
56
+ ```
57
+
58
+ - **An unfilled slot is ABSENT**, not present and broken, so `ctx.apps.rooms?` reads as the
59
+ question it is. An optional slot means your app still works without one; a required slot that is
60
+ empty refuses at the seam with `not-bound`, which a UI should render as "connect one" rather
61
+ than as an error.
62
+ - **A slot reaches the CONTRACT's tools, not the provider's toolkit.** A consumer bound through
63
+ `rooms/v1` may hold a room and release it; it may not call the rooms app's `retire_room`, though
64
+ it has one. The binding is consent to a contract.
65
+ - **The caller travels.** The provider's rules meet the actual person, so a visitor reaching the
66
+ rooms app through your app sees what a visitor may see.
67
+ - **Writes never cross directly.** The provider's own tools and ops are the only writers of its
68
+ tables, which is how a ledger keeps refusing to oversell no matter who asked.
69
+
70
+ ## Being a good provider
71
+
72
+ A provider's whole value is usually one refusal: you cannot hold what is not free. Keep it in ONE
73
+ place — the app's own server — so it holds however the hold was asked for: through a binding, a
74
+ tool, or a person working in the app itself. That is what makes an app worth binding to. An app
75
+ that counted the same thing in its own fold could not make the promise, because two people can
76
+ read the same fold in the same instant.
77
+
78
+ Say no in your own words, too. A refusal that reads `cannot hold 2: only 1 free (1 free, 1 already
79
+ held)` reaches the consumer, and the consumer can put it in front of a person instead of a shrug.