esoul-sdk 0.4.0 → 0.7.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 +146 -24
- package/dist/audience.d.ts +103 -0
- package/dist/audience.js +142 -0
- package/dist/bindings.d.ts +164 -0
- package/dist/bindings.js +163 -0
- package/dist/db/client-core.d.ts +169 -0
- package/dist/db/client-core.js +316 -0
- package/dist/db/compile-rules.d.ts +229 -0
- package/dist/db/compile-rules.js +426 -0
- package/dist/db/memory-client.d.ts +136 -0
- package/dist/db/memory-client.js +332 -0
- package/dist/db/schema-gen.d.ts +109 -0
- package/dist/db/schema-gen.js +363 -0
- package/dist/helpers.d.ts +52 -0
- package/dist/helpers.js +106 -10
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/manifest.d.ts +466 -13
- package/dist/manifest.js +218 -5
- package/dist/roles.d.ts +43 -0
- package/dist/roles.js +56 -0
- package/dist/server.d.ts +182 -0
- package/dist/server.js +80 -0
- package/dist/testing/db.d.ts +71 -0
- package/dist/testing/db.js +103 -0
- package/dist/testing/index.d.ts +14 -0
- package/dist/testing/index.js +9 -0
- package/dist/testing/ops.d.ts +84 -0
- package/dist/testing/ops.js +76 -0
- package/dist/types.d.ts +22 -1
- package/docs/04-tools.md +5 -2
- package/docs/06-server.md +78 -0
- package/docs/07-background-tasks.md +23 -0
- package/docs/10-testing.md +18 -0
- package/docs/12-rules.md +3 -2
- package/docs/13-people-and-access.md +152 -0
- package/docs/14-database.md +221 -0
- package/docs/15-realtime.md +88 -0
- package/docs/16-bindings.md +79 -0
- package/llms-full.txt +829 -28
- package/llms.txt +4 -0
- package/package.json +7 -3
- package/schemas/plugin.schema.json +351 -9
package/README.md
CHANGED
|
@@ -1,61 +1,183 @@
|
|
|
1
1
|
# esoul-sdk
|
|
2
2
|
|
|
3
|
-
Build **ExternalSoul
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
9
|
npm install --save-dev esoul-sdk
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
Inside ExternalSoul the package name resolves to the platform's real implementations. Outside it
|
|
13
|
-
(your editor, your tests)
|
|
14
|
-
helpers; the server functions throw "host only" if called, because they run in the
|
|
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.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## What you can build with it
|
|
20
|
+
|
|
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.
|
|
25
|
+
|
|
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.
|
|
28
|
+
|
|
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
|
+
---
|
|
15
87
|
|
|
16
88
|
## Where to start
|
|
17
89
|
|
|
18
90
|
- **Build it in the Forge, not on your laptop.** Open a Forge board in your workspace and ask it
|
|
19
|
-
to open a workbench for your app
|
|
20
|
-
|
|
21
|
-
|
|
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).
|
|
22
95
|
- **The contract, one page per part:**
|
|
23
96
|
1. [Getting started](docs/01-getting-started.md) — the loop, the package layout
|
|
24
97
|
2. [The manifest](docs/02-manifest.md) — `plugin.json`, every field
|
|
25
98
|
3. [Events and state](docs/03-events-and-state.md) — the heart: dataCreator, processor, replay
|
|
26
99
|
4. [Tools](docs/04-tools.md) — what agents call, on every surface
|
|
27
100
|
5. [The UI](docs/05-ui.md) — React, hooks, theme, responsive rules
|
|
28
|
-
6. [The server half](docs/06-server.md) — ops,
|
|
101
|
+
6. [The server half](docs/06-server.md) — ops, routes, webhooks, calling other apps
|
|
29
102
|
7. [Background tasks](docs/07-background-tasks.md) — durable work, polling, the replay model
|
|
30
103
|
8. [Connections and OAuth](docs/08-connections.md) — tokens the platform holds for you
|
|
31
104
|
9. [Files](docs/09-files.md) — workspace files, Drive, your own provider
|
|
32
|
-
10. [Testing](docs/10-testing.md) — the fold contract as tests
|
|
105
|
+
10. [Testing](docs/10-testing.md) — the fold contract, and your rules, as tests
|
|
33
106
|
11. [Shipping](docs/11-shipping.md) — submit, review, release, install; the import wall
|
|
34
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
|
|
35
112
|
- **For a coding model:** `llms.txt` (short) and `llms-full.txt` (the whole contract in one file).
|
|
36
113
|
|
|
37
114
|
## The one rule that explains the others
|
|
38
115
|
|
|
39
|
-
**Events are the truth.** Your app's
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
44
124
|
|
|
45
125
|
## What is in the package
|
|
46
126
|
|
|
47
127
|
| Entry | What it gives you |
|
|
48
128
|
|---|---|
|
|
49
|
-
| `esoul-sdk` | `ApplicationSchema`, `EventDefinition`, `EventTypes`, `ApplicationIdentifier`, `
|
|
50
|
-
| `esoul-sdk/react` | `
|
|
51
|
-
| `esoul-sdk/server` | `PluginServerModule`, `readAppState`, `callWorkspaceTool`, `emitPluginAppEvent`, `getPluginConnectionCredentials`, file provider types |
|
|
52
|
-
| `esoul-sdk/testing` |
|
|
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 |
|
|
53
133
|
| `esoul-app validate <dir>` | validates a package folder against the manifest schema |
|
|
54
134
|
|
|
135
|
+
## Testing your app
|
|
136
|
+
|
|
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.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { fakeViewer, memoryDb, runOp } from "esoul-sdk/testing";
|
|
143
|
+
import manifest from "./plugin.json";
|
|
144
|
+
import { pluginServer } from "./server";
|
|
145
|
+
|
|
146
|
+
const ada = fakeViewer("visitor", { userId: "u_ada", role: "requester" });
|
|
147
|
+
const lin = fakeViewer("visitor", { userId: "u_lin", role: "requester" });
|
|
148
|
+
|
|
149
|
+
it("does not let the OTHER one see it", async () => {
|
|
150
|
+
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) });
|
|
153
|
+
expect(result).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
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?
|
|
160
|
+
|
|
55
161
|
## Versions
|
|
56
162
|
|
|
57
|
-
- **0.
|
|
58
|
-
|
|
59
|
-
|
|
163
|
+
- **0.7.0** — what a catalogue-sized app needs. **Index kinds**: a plain group is a btree,
|
|
164
|
+
`{ fields: ["tags"], kind: "contains" }` answers `{ tags: { has: … } }` on a list, and
|
|
165
|
+
`{ fields: ["title"], kind: "text" }` answers `{ title: { contains: … } }` — so a department
|
|
166
|
+
and a search term are questions for the database, with `cursor` paging. `contains` ignores
|
|
167
|
+
case in both clients (a search box that misses "earl grey" is broken quietly). Asking a list a
|
|
168
|
+
scalar's question (or the reverse) is refused with the right one named. **`ctx.emit`** — an op
|
|
169
|
+
records on its OWN timeline, so a fact reaches the fold whether a person or an agent caused it.
|
|
170
|
+
A list field defaults to `[]` (it used to refuse every create), a rule-less model defaults to
|
|
171
|
+
the role your manifest calls the owner, and `viewerProfile` is a real export rather than a
|
|
172
|
+
declaration. Documented the client surface that was already there: `aggregate`, `groupBy`,
|
|
173
|
+
`$transaction`, the `*Many` writes.
|
|
174
|
+
- **0.6.0** — the full-stack release. Your own tables (`db`, `pluginDb`, rules, scopes, sealed
|
|
175
|
+
fields, additive migrations applied on install). `viewer` on every seam, app `roles`, per-surface
|
|
176
|
+
access levels with the sign-in wall. Realtime audiences: a topic says who hears it and the mint
|
|
177
|
+
issues a token per channel. Bindings: `uses`/`provides`, contracts, `ctx.apps.<slot>`. A tool now
|
|
178
|
+
acts for the person who invoked it rather than for the platform. `viewerProfile` — the caller's
|
|
179
|
+
own esoul account, server-side only. Testing: `memoryDb`, `fakeViewer`, `runOp`.
|
|
180
|
+
- 0.5.0 — server routes (`pluginServer.routes`), `sseStream`, plugin realtime.
|
|
181
|
+
- 0.3.0 — renamed from `@externalsoul/plugin-sdk`. `readAppState`, `callWorkspaceTool`, `nanoid`
|
|
182
|
+
on the index. The import wall: an app reaches the platform only through this package.
|
|
60
183
|
- 0.2.0 — file sources and providers.
|
|
61
|
-
- 0.1.0 — the contract: manifest, schema, events, tools, tasks, webhooks, ops, connections.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHO HEARS A MESSAGE.
|
|
3
|
+
*
|
|
4
|
+
* An app's realtime channel is one per instance, and until now every viewer
|
|
5
|
+
* who could reach the app held a token for all of it: a shop publishing
|
|
6
|
+
* "order 91 is on its way" reached every customer watching, with the order id
|
|
7
|
+
* and whatever else the app put in the payload. The rules made the DATABASE
|
|
8
|
+
* private per customer and the transport stayed a loudspeaker.
|
|
9
|
+
*
|
|
10
|
+
* So a topic declares its AUDIENCE, and the audience decides the channel:
|
|
11
|
+
*
|
|
12
|
+
* all one channel for the instance — a catalogue change, a tick
|
|
13
|
+
* viewer one channel per person — "your order shipped"
|
|
14
|
+
* role:<name> one channel per role — "a new order arrived", for staff
|
|
15
|
+
*
|
|
16
|
+
* A token is minted per channel, so a customer is never handed the staff
|
|
17
|
+
* channel or another customer's: not filtered out on arrival, never issued.
|
|
18
|
+
*
|
|
19
|
+
* And ADDRESSING is a separate permission from hearing. `notify(topic, data,
|
|
20
|
+
* { to })` lets a caller aim a message; without a rule, a customer's op could
|
|
21
|
+
* aim one at another customer. So: a non-internal caller may address only
|
|
22
|
+
* ITSELF, unless the topic's `mayAddress` names its role. That is invariant
|
|
23
|
+
* S7 of the security pass, and `decideDestination` is where it lives.
|
|
24
|
+
*
|
|
25
|
+
* Pure — no transport, no platform. The mint calls `subscriptionsFor`, the
|
|
26
|
+
* server's `notify` calls `decideDestination`, and the workbench calls both,
|
|
27
|
+
* so a box shows an author exactly what an installed app will do.
|
|
28
|
+
*/
|
|
29
|
+
/** `all` (default) · `viewer` · `role:<name>`. */
|
|
30
|
+
export type TopicAudience = "all" | "viewer" | `role:${string}`;
|
|
31
|
+
export interface TopicDecl {
|
|
32
|
+
audience?: TopicAudience;
|
|
33
|
+
/**
|
|
34
|
+
* Roles that may address this topic AT SOMEONE ELSE (`to: { role }` or
|
|
35
|
+
* another viewer's ids). The app's own server code running internally
|
|
36
|
+
* always may; nobody else does unless named here.
|
|
37
|
+
*/
|
|
38
|
+
mayAddress?: string[];
|
|
39
|
+
}
|
|
40
|
+
export type ChannelAudience = {
|
|
41
|
+
kind: "all";
|
|
42
|
+
} | {
|
|
43
|
+
kind: "viewer";
|
|
44
|
+
viewerId: string;
|
|
45
|
+
} | {
|
|
46
|
+
kind: "role";
|
|
47
|
+
role: string;
|
|
48
|
+
};
|
|
49
|
+
/** What `notify`'s caller asked for. Absent = the topic's own audience, aimed at the caller. */
|
|
50
|
+
export type NotifyTarget = {
|
|
51
|
+
viewerIds: string[];
|
|
52
|
+
} | {
|
|
53
|
+
role: string;
|
|
54
|
+
} | "apps";
|
|
55
|
+
export declare class AudienceError extends Error {
|
|
56
|
+
readonly code: "forbidden";
|
|
57
|
+
constructor(message: string);
|
|
58
|
+
}
|
|
59
|
+
/** The viewer facts this module needs — the shape `PluginViewer` already has. */
|
|
60
|
+
export interface AudienceViewer {
|
|
61
|
+
kind: string;
|
|
62
|
+
userId: string | null;
|
|
63
|
+
viewerIds: string[];
|
|
64
|
+
role: string;
|
|
65
|
+
}
|
|
66
|
+
export declare function audienceOf(decl: TopicDecl | undefined): TopicAudience;
|
|
67
|
+
/**
|
|
68
|
+
* The id a person's own channel is keyed on. A signed-in account first, so
|
|
69
|
+
* the channel survives a new browser and a new guest cookie; otherwise the
|
|
70
|
+
* first viewer id, which is the guest cookie.
|
|
71
|
+
*
|
|
72
|
+
* KNOWN LIMIT, on purpose: someone who ordered as a guest and then signed in
|
|
73
|
+
* listens on their ACCOUNT's channel, while a message addressed at the order's
|
|
74
|
+
* stored `ownerId` (the guest id) goes to the guest channel they no longer
|
|
75
|
+
* hold. They see the change when the list re-reads — the database is the
|
|
76
|
+
* truth and realtime is the nudge — and nobody else ever hears it either.
|
|
77
|
+
*/
|
|
78
|
+
export declare function ownChannelId(viewer: AudienceViewer): string | null;
|
|
79
|
+
/**
|
|
80
|
+
* Every channel this viewer may hold a token for, with the topics to ask for
|
|
81
|
+
* on each. Topics whose audience no channel of this viewer can carry are
|
|
82
|
+
* simply absent — an anonymous viewer gets no `role:` channel, and a viewer
|
|
83
|
+
* with no id at all gets no personal one.
|
|
84
|
+
*/
|
|
85
|
+
export declare function subscriptionsFor(topics: Record<string, TopicDecl | undefined>, viewer: AudienceViewer): {
|
|
86
|
+
audience: ChannelAudience;
|
|
87
|
+
topics: string[];
|
|
88
|
+
}[];
|
|
89
|
+
/**
|
|
90
|
+
* Where one `notify` lands — one channel per addressee — or a refusal.
|
|
91
|
+
*
|
|
92
|
+
* Unaddressed, a message goes where the topic says: the instance channel for
|
|
93
|
+
* `all`, the CALLER's own channel for `viewer`, the topic's role for `role:`.
|
|
94
|
+
* Addressed, the caller must be allowed to aim it.
|
|
95
|
+
*/
|
|
96
|
+
export declare function decideDestination(args: {
|
|
97
|
+
topic: string;
|
|
98
|
+
decl: TopicDecl | undefined;
|
|
99
|
+
viewer: AudienceViewer;
|
|
100
|
+
to?: NotifyTarget;
|
|
101
|
+
}): ChannelAudience[];
|
|
102
|
+
/** The channel name for an instance and an audience — the one spelling, both sides. */
|
|
103
|
+
export declare function channelName(base: string, audience: ChannelAudience): string;
|
package/dist/audience.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHO HEARS A MESSAGE.
|
|
3
|
+
*
|
|
4
|
+
* An app's realtime channel is one per instance, and until now every viewer
|
|
5
|
+
* who could reach the app held a token for all of it: a shop publishing
|
|
6
|
+
* "order 91 is on its way" reached every customer watching, with the order id
|
|
7
|
+
* and whatever else the app put in the payload. The rules made the DATABASE
|
|
8
|
+
* private per customer and the transport stayed a loudspeaker.
|
|
9
|
+
*
|
|
10
|
+
* So a topic declares its AUDIENCE, and the audience decides the channel:
|
|
11
|
+
*
|
|
12
|
+
* all one channel for the instance — a catalogue change, a tick
|
|
13
|
+
* viewer one channel per person — "your order shipped"
|
|
14
|
+
* role:<name> one channel per role — "a new order arrived", for staff
|
|
15
|
+
*
|
|
16
|
+
* A token is minted per channel, so a customer is never handed the staff
|
|
17
|
+
* channel or another customer's: not filtered out on arrival, never issued.
|
|
18
|
+
*
|
|
19
|
+
* And ADDRESSING is a separate permission from hearing. `notify(topic, data,
|
|
20
|
+
* { to })` lets a caller aim a message; without a rule, a customer's op could
|
|
21
|
+
* aim one at another customer. So: a non-internal caller may address only
|
|
22
|
+
* ITSELF, unless the topic's `mayAddress` names its role. That is invariant
|
|
23
|
+
* S7 of the security pass, and `decideDestination` is where it lives.
|
|
24
|
+
*
|
|
25
|
+
* Pure — no transport, no platform. The mint calls `subscriptionsFor`, the
|
|
26
|
+
* server's `notify` calls `decideDestination`, and the workbench calls both,
|
|
27
|
+
* so a box shows an author exactly what an installed app will do.
|
|
28
|
+
*/
|
|
29
|
+
export class AudienceError extends Error {
|
|
30
|
+
code = "forbidden";
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "AudienceError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function audienceOf(decl) {
|
|
37
|
+
const a = decl?.audience;
|
|
38
|
+
if (a === "viewer")
|
|
39
|
+
return "viewer";
|
|
40
|
+
if (typeof a === "string" && a.startsWith("role:") && a.length > 5)
|
|
41
|
+
return a;
|
|
42
|
+
return "all";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The id a person's own channel is keyed on. A signed-in account first, so
|
|
46
|
+
* the channel survives a new browser and a new guest cookie; otherwise the
|
|
47
|
+
* first viewer id, which is the guest cookie.
|
|
48
|
+
*
|
|
49
|
+
* KNOWN LIMIT, on purpose: someone who ordered as a guest and then signed in
|
|
50
|
+
* listens on their ACCOUNT's channel, while a message addressed at the order's
|
|
51
|
+
* stored `ownerId` (the guest id) goes to the guest channel they no longer
|
|
52
|
+
* hold. They see the change when the list re-reads — the database is the
|
|
53
|
+
* truth and realtime is the nudge — and nobody else ever hears it either.
|
|
54
|
+
*/
|
|
55
|
+
export function ownChannelId(viewer) {
|
|
56
|
+
return viewer.userId ?? viewer.viewerIds[0] ?? null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Every channel this viewer may hold a token for, with the topics to ask for
|
|
60
|
+
* on each. Topics whose audience no channel of this viewer can carry are
|
|
61
|
+
* simply absent — an anonymous viewer gets no `role:` channel, and a viewer
|
|
62
|
+
* with no id at all gets no personal one.
|
|
63
|
+
*/
|
|
64
|
+
export function subscriptionsFor(topics, viewer) {
|
|
65
|
+
const all = [];
|
|
66
|
+
const own = [];
|
|
67
|
+
const byRole = [];
|
|
68
|
+
for (const name of Object.keys(topics)) {
|
|
69
|
+
const a = audienceOf(topics[name]);
|
|
70
|
+
if (a === "all")
|
|
71
|
+
all.push(name);
|
|
72
|
+
else if (a === "viewer")
|
|
73
|
+
own.push(name);
|
|
74
|
+
else if (a === `role:${viewer.role}`)
|
|
75
|
+
byRole.push(name);
|
|
76
|
+
}
|
|
77
|
+
const out = [];
|
|
78
|
+
if (all.length)
|
|
79
|
+
out.push({ audience: { kind: "all" }, topics: all });
|
|
80
|
+
const id = ownChannelId(viewer);
|
|
81
|
+
if (own.length && id)
|
|
82
|
+
out.push({ audience: { kind: "viewer", viewerId: id }, topics: own });
|
|
83
|
+
if (byRole.length)
|
|
84
|
+
out.push({ audience: { kind: "role", role: viewer.role }, topics: byRole });
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Where one `notify` lands — one channel per addressee — or a refusal.
|
|
89
|
+
*
|
|
90
|
+
* Unaddressed, a message goes where the topic says: the instance channel for
|
|
91
|
+
* `all`, the CALLER's own channel for `viewer`, the topic's role for `role:`.
|
|
92
|
+
* Addressed, the caller must be allowed to aim it.
|
|
93
|
+
*/
|
|
94
|
+
export function decideDestination(args) {
|
|
95
|
+
const { topic, decl, viewer, to } = args;
|
|
96
|
+
const audience = audienceOf(decl);
|
|
97
|
+
const internal = viewer.kind === "internal";
|
|
98
|
+
const mayAddressOthers = internal || (decl?.mayAddress ?? []).includes(viewer.role);
|
|
99
|
+
if (to === "apps") {
|
|
100
|
+
// Reserved for S9's bindings; nothing consumes it yet, and a caller that
|
|
101
|
+
// asks for it now should hear so rather than have it quietly ignored.
|
|
102
|
+
throw new AudienceError(`\`to: "apps"\` needs bindings (S9); topic "${topic}" cannot address other apps yet`);
|
|
103
|
+
}
|
|
104
|
+
if (!to) {
|
|
105
|
+
if (audience === "all")
|
|
106
|
+
return [{ kind: "all" }];
|
|
107
|
+
if (audience === "viewer") {
|
|
108
|
+
const id = ownChannelId(viewer);
|
|
109
|
+
if (!id)
|
|
110
|
+
throw new AudienceError(`topic "${topic}" is per-viewer and this caller has no identity to address`);
|
|
111
|
+
return [{ kind: "viewer", viewerId: id }];
|
|
112
|
+
}
|
|
113
|
+
return [{ kind: "role", role: audience.slice("role:".length) }];
|
|
114
|
+
}
|
|
115
|
+
if ("role" in to) {
|
|
116
|
+
if (!mayAddressOthers) {
|
|
117
|
+
throw new AudienceError(`${viewer.role} may not address the "${to.role}" desk on topic "${topic}" — list it in channel.topics["${topic}"].mayAddress`);
|
|
118
|
+
}
|
|
119
|
+
return [{ kind: "role", role: to.role }];
|
|
120
|
+
}
|
|
121
|
+
const ids = [...new Set(to.viewerIds.filter(Boolean))];
|
|
122
|
+
if (!ids.length)
|
|
123
|
+
throw new AudienceError(`topic "${topic}" was addressed at nobody`);
|
|
124
|
+
if (!mayAddressOthers) {
|
|
125
|
+
// The one case a plain caller may address: itself. Anything else is how a
|
|
126
|
+
// customer would push a message into another customer's channel.
|
|
127
|
+
const mine = new Set(viewer.viewerIds);
|
|
128
|
+
const strangers = ids.filter((id) => !mine.has(id));
|
|
129
|
+
if (strangers.length) {
|
|
130
|
+
throw new AudienceError(`${viewer.role} may address only itself on topic "${topic}" — list the role in channel.topics["${topic}"].mayAddress to let it address others`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return ids.map((viewerId) => ({ kind: "viewer", viewerId }));
|
|
134
|
+
}
|
|
135
|
+
/** The channel name for an instance and an audience — the one spelling, both sides. */
|
|
136
|
+
export function channelName(base, audience) {
|
|
137
|
+
if (audience.kind === "all")
|
|
138
|
+
return base;
|
|
139
|
+
if (audience.kind === "viewer")
|
|
140
|
+
return `${base}:v:${audience.viewerId}`;
|
|
141
|
+
return `${base}:r:${audience.role}`;
|
|
142
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT STANDS BEHIND A SLOT.
|
|
3
|
+
*
|
|
4
|
+
* An app is an island until it can lean on another one. A storefront that
|
|
5
|
+
* wants real stock either keeps its own products table and guesses, or names
|
|
6
|
+
* a specific inventory app and is married to it forever. Neither is what a
|
|
7
|
+
* person means when they say "use my warehouse for this shop".
|
|
8
|
+
*
|
|
9
|
+
* So an app declares a SLOT and the CONTRACT it needs:
|
|
10
|
+
*
|
|
11
|
+
* "uses": { "stock": { "contract": "stock/v1", "label": "Stock" } }
|
|
12
|
+
*
|
|
13
|
+
* and any app that can do the job declares that it does:
|
|
14
|
+
*
|
|
15
|
+
* "provides": { "stock/v1": { "tools": ["reserve_stock"], "events": ["stock_low"], "models": ["Product"] } }
|
|
16
|
+
*
|
|
17
|
+
* The owner picks which app fills the slot, and the platform checks — at BIND
|
|
18
|
+
* time, once, loudly — that the provider really has every tool, event and
|
|
19
|
+
* table the contract names. Not at call time, where a missing tool is an
|
|
20
|
+
* error in front of a customer.
|
|
21
|
+
*
|
|
22
|
+
* Versions grow by ADDING. A provider on `stock/v2` satisfies a consumer
|
|
23
|
+
* asking for `stock/v1`, because v2 still has everything v1 named; the
|
|
24
|
+
* reverse is refused, because v1 has never heard of what v2 added. That one
|
|
25
|
+
* rule is what lets a storefront keep working while its provider moves on.
|
|
26
|
+
*
|
|
27
|
+
* Pure: no platform, no transport. The bind-time check, the workbench picker
|
|
28
|
+
* and the author's own tests all read this.
|
|
29
|
+
*/
|
|
30
|
+
export interface ContractId {
|
|
31
|
+
/** `stock` in `stock/v1`. */
|
|
32
|
+
name: string;
|
|
33
|
+
/** `1` in `stock/v1`. */
|
|
34
|
+
version: number;
|
|
35
|
+
}
|
|
36
|
+
/** What a contract REQUIRES of whoever claims it. The platform ships these; an app may publish its own. */
|
|
37
|
+
export interface ContractDef {
|
|
38
|
+
id: string;
|
|
39
|
+
/** One line, shown in the picker. */
|
|
40
|
+
describe?: string;
|
|
41
|
+
tools: string[];
|
|
42
|
+
events: string[];
|
|
43
|
+
models: string[];
|
|
44
|
+
}
|
|
45
|
+
/** What one app says it offers for one contract. */
|
|
46
|
+
export interface ProvidesDecl {
|
|
47
|
+
tools?: string[];
|
|
48
|
+
events?: string[];
|
|
49
|
+
models?: string[];
|
|
50
|
+
}
|
|
51
|
+
/** A slot an app needs filled. */
|
|
52
|
+
export interface UsesDecl {
|
|
53
|
+
contract: string;
|
|
54
|
+
label?: string;
|
|
55
|
+
/** An unfilled required slot is a refusal at the seam (`not-bound`); an optional one is simply absent. */
|
|
56
|
+
optional?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** What a candidate app ACTUALLY has, read from its manifest and registry — never from its claims. */
|
|
59
|
+
export interface ProviderFacts {
|
|
60
|
+
applicationType: string;
|
|
61
|
+
/** Tool VERBS the app mints (`reserve_stock`, not `reserve_stock_<instance>`). */
|
|
62
|
+
tools: string[];
|
|
63
|
+
/** Event names the app declares. */
|
|
64
|
+
events: string[];
|
|
65
|
+
/** Table names in the app's `db`. */
|
|
66
|
+
models: string[];
|
|
67
|
+
/** The `provides` block from its manifest. */
|
|
68
|
+
provides: Record<string, ProvidesDecl>;
|
|
69
|
+
}
|
|
70
|
+
export declare function parseContractId(raw: string): ContractId | null;
|
|
71
|
+
export declare function formatContractId(c: ContractId): string;
|
|
72
|
+
export type BindCheck = {
|
|
73
|
+
ok: true;
|
|
74
|
+
via: string;
|
|
75
|
+
tools: string[];
|
|
76
|
+
events: string[];
|
|
77
|
+
models: string[];
|
|
78
|
+
} | {
|
|
79
|
+
ok: false;
|
|
80
|
+
reasons: string[];
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* May this app fill this slot? Every reason it may not, in the author's
|
|
84
|
+
* words — a picker shows them, and a bind refuses on any of them.
|
|
85
|
+
*
|
|
86
|
+
* `contract` is the DEFINITION (what the contract requires). `facts` is what
|
|
87
|
+
* the candidate app really has. A provider's own `provides` block is a claim,
|
|
88
|
+
* and a claim is checked against both.
|
|
89
|
+
*/
|
|
90
|
+
export declare function checkBinding(args: {
|
|
91
|
+
wanted: string;
|
|
92
|
+
contract: ContractDef | null;
|
|
93
|
+
facts: ProviderFacts;
|
|
94
|
+
}): BindCheck;
|
|
95
|
+
export interface Binding {
|
|
96
|
+
/** The provider instance. */
|
|
97
|
+
nodeId: string;
|
|
98
|
+
applicationType: string;
|
|
99
|
+
/** The contract id it was bound through — what the consumer may reach. */
|
|
100
|
+
via: string;
|
|
101
|
+
boundAt?: number;
|
|
102
|
+
}
|
|
103
|
+
/** slot → binding. The consumer app's own state holds this. */
|
|
104
|
+
export type BindingMap = Record<string, Binding>;
|
|
105
|
+
/**
|
|
106
|
+
* The reducer behind `plugin/binding_set`. Deterministic: setting a slot
|
|
107
|
+
* replaces it, binding an empty nodeId clears it, and an unknown slot is
|
|
108
|
+
* ignored rather than invented — the slots are the manifest's, not the
|
|
109
|
+
* event's.
|
|
110
|
+
*/
|
|
111
|
+
export declare function reduceBinding(current: BindingMap, event: {
|
|
112
|
+
slot?: unknown;
|
|
113
|
+
nodeId?: unknown;
|
|
114
|
+
applicationType?: unknown;
|
|
115
|
+
via?: unknown;
|
|
116
|
+
at?: unknown;
|
|
117
|
+
}, knownSlots: readonly string[]): BindingMap;
|
|
118
|
+
/** The slots a manifest declares that nothing fills yet — the ones a seam refuses on. */
|
|
119
|
+
export declare function unfilledRequiredSlots(uses: Record<string, UsesDecl>, bindings: BindingMap): string[];
|
|
120
|
+
/**
|
|
121
|
+
* WHERE A BINDING LIVES: on the consumer app's own timeline, folded under
|
|
122
|
+
* `_bindings`.
|
|
123
|
+
*
|
|
124
|
+
* It could have been a column. It is an event because a binding is the
|
|
125
|
+
* OWNER'S CONSENT — "this shop may reach that warehouse" — and consent that
|
|
126
|
+
* is only a column cannot be scrubbed, explained or audited. On the timeline
|
|
127
|
+
* it can: who bound it, when, through which contract, and what it replaced.
|
|
128
|
+
*
|
|
129
|
+
* An app that declares `uses` must put this in its `events`; the sync refuses
|
|
130
|
+
* one that does not, because a slot nothing can fill is a promise the app
|
|
131
|
+
* cannot keep.
|
|
132
|
+
*/
|
|
133
|
+
export interface BindingHolder {
|
|
134
|
+
_bindings?: BindingMap;
|
|
135
|
+
}
|
|
136
|
+
export declare function bindingEventName(applicationType: string): string;
|
|
137
|
+
/**
|
|
138
|
+
* The event definition for one app's slots. `knownSlots` comes from the
|
|
139
|
+
* manifest, so an event naming a slot the app never declared is ignored
|
|
140
|
+
* rather than inventing one.
|
|
141
|
+
*
|
|
142
|
+
* Typed loosely on purpose: the SDK's `EventDefinition` lives in `types.ts`
|
|
143
|
+
* and this module stays free of it, so the same file can be read by the
|
|
144
|
+
* platform, the workbench and an author's tests without dragging the schema
|
|
145
|
+
* types along.
|
|
146
|
+
*/
|
|
147
|
+
export declare function defineBindingEvent<S extends BindingHolder>(args: {
|
|
148
|
+
applicationType: string;
|
|
149
|
+
slots: readonly string[];
|
|
150
|
+
}): {
|
|
151
|
+
eventName: string;
|
|
152
|
+
type: "Client";
|
|
153
|
+
triggerMeta: {
|
|
154
|
+
displayName: string;
|
|
155
|
+
description: string;
|
|
156
|
+
sampleVariables: string[];
|
|
157
|
+
};
|
|
158
|
+
dataCreator: (a: Record<string, unknown>) => Record<string, unknown>;
|
|
159
|
+
processor: (state: S, event: {
|
|
160
|
+
eventData?: Record<string, unknown>;
|
|
161
|
+
}) => S;
|
|
162
|
+
};
|
|
163
|
+
/** The bindings an app currently holds, from its folded state. */
|
|
164
|
+
export declare function bindingsOf(state: BindingHolder | null | undefined): BindingMap;
|