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.
Files changed (43) hide show
  1. package/README.md +146 -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 +169 -0
  7. package/dist/db/client-core.js +316 -0
  8. package/dist/db/compile-rules.d.ts +229 -0
  9. package/dist/db/compile-rules.js +426 -0
  10. package/dist/db/memory-client.d.ts +136 -0
  11. package/dist/db/memory-client.js +332 -0
  12. package/dist/db/schema-gen.d.ts +109 -0
  13. package/dist/db/schema-gen.js +363 -0
  14. package/dist/helpers.d.ts +52 -0
  15. package/dist/helpers.js +106 -10
  16. package/dist/index.d.ts +5 -0
  17. package/dist/index.js +5 -0
  18. package/dist/manifest.d.ts +466 -13
  19. package/dist/manifest.js +218 -5
  20. package/dist/roles.d.ts +43 -0
  21. package/dist/roles.js +56 -0
  22. package/dist/server.d.ts +182 -0
  23. package/dist/server.js +80 -0
  24. package/dist/testing/db.d.ts +71 -0
  25. package/dist/testing/db.js +103 -0
  26. package/dist/testing/index.d.ts +14 -0
  27. package/dist/testing/index.js +9 -0
  28. package/dist/testing/ops.d.ts +84 -0
  29. package/dist/testing/ops.js +76 -0
  30. package/dist/types.d.ts +22 -1
  31. package/docs/04-tools.md +5 -2
  32. package/docs/06-server.md +78 -0
  33. package/docs/07-background-tasks.md +23 -0
  34. package/docs/10-testing.md +18 -0
  35. package/docs/12-rules.md +3 -2
  36. package/docs/13-people-and-access.md +152 -0
  37. package/docs/14-database.md +221 -0
  38. package/docs/15-realtime.md +88 -0
  39. package/docs/16-bindings.md +79 -0
  40. package/llms-full.txt +829 -28
  41. package/llms.txt +4 -0
  42. package/package.json +7 -3
  43. package/schemas/plugin.schema.json +351 -9
package/README.md CHANGED
@@ -1,61 +1,183 @@
1
1
  # esoul-sdk
2
2
 
3
- Build **ExternalSoul apps**: native apps for an event-sourced workspace where people and AI
4
- agents share one canvas. An app you write with this SDK is indistinguishable from the platform's
5
- own once it ships — the same events, the same timeline, the same durability, the same tools that
6
- chat, voice, agents and MCP call.
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) the package gives you the types, the manifest validator, and the test
14
- helpers; the server functions throw "host only" if called, because they run in the platform.
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. You get a cloud machine with the platform on it, a live
20
- preview in your frame, your app's tools callable before it is installed, checks, and a submit
21
- button. Read [docs/01-getting-started.md](docs/01-getting-started.md).
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, webhooks, reading state, calling other apps
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 state is the fold of its events, re-run on every replay,
40
- scrub and sync. So a reducer must be pure and idempotent, ids and timestamps are minted in the
41
- `dataCreator` (never in a reducer), whole-replace events carry a collapse key, and the agent-facing
42
- state description never claims something it could not read. Everything in the docs follows from
43
- that.
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`, `incompleteStateNotice`, `deterministicReducerId`, `stableStringify`, `timingSafeEqual`, `nanoid`, `callPluginOp`, the manifest schema |
50
- | `esoul-sdk/react` | `usePluginEventDispatch`, `useAppCanEdit`, `usePluginCurrentChatId`, `useWorkspaceTools`, `usePluginRealtime`, file hooks |
51
- | `esoul-sdk/server` | `PluginServerModule`, `readAppState`, `callWorkspaceTool`, `emitPluginAppEvent`, `getPluginConnectionCredentials`, file provider types |
52
- | `esoul-sdk/testing` | a mock OAuth server for connection tests |
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.3.0** — renamed from `@externalsoul/plugin-sdk` (still resolved as an alias inside the
58
- platform). `readAppState`, `callWorkspaceTool`, `nanoid` on the index. The import wall: an app
59
- reaches the platform only through this package. Docs rewritten for the Forge workbench loop.
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;
@@ -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;