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/dist/manifest.js
CHANGED
|
@@ -35,6 +35,126 @@ export const PluginConnectionSchema = z
|
|
|
35
35
|
.strict()
|
|
36
36
|
.refine((c) => c.kind !== "oauth2" ||
|
|
37
37
|
(!!c.authorizeUrl && !!c.tokenUrl && !!c.clientIdEnv), { message: "oauth2 connections require authorizeUrl + tokenUrl + clientIdEnv" });
|
|
38
|
+
/**
|
|
39
|
+
* WHO MAY REACH A SURFACE — the access level of one op, route or kickable task.
|
|
40
|
+
*
|
|
41
|
+
* Every entry point of an app has always been owner-or-editor-only, which is
|
|
42
|
+
* the right default and the wrong ONLY option: an app with customers needs a
|
|
43
|
+
* catalogue anyone may read and an order anyone signed in may place.
|
|
44
|
+
*
|
|
45
|
+
* write (default) — people who may edit the workspace. Today's behaviour.
|
|
46
|
+
* read — anyone who may see it, including a read-only member.
|
|
47
|
+
* public — anyone on the app's public share link, signed in or not.
|
|
48
|
+
*
|
|
49
|
+
* `requires: "account"` adds "…but they must be signed in", answered with
|
|
50
|
+
* `login-required` (and a way back) rather than a flat refusal, so the app can
|
|
51
|
+
* render a sign-in wall instead of an error.
|
|
52
|
+
*
|
|
53
|
+
* NOTHING opens by default. A level is opt-in per NAME, and the install card
|
|
54
|
+
* lists every public door before the owner presses Install.
|
|
55
|
+
*/
|
|
56
|
+
export const PLUGIN_ACCESS_LEVELS = ["write", "read", "public"];
|
|
57
|
+
export const PluginSurfaceEntrySchema = z
|
|
58
|
+
.object({
|
|
59
|
+
access: z.enum(PLUGIN_ACCESS_LEVELS).optional().default("write"),
|
|
60
|
+
requires: z.literal("account").optional(),
|
|
61
|
+
})
|
|
62
|
+
.strict();
|
|
63
|
+
/**
|
|
64
|
+
* A surface list is EITHER the plain array of names it has always been (every
|
|
65
|
+
* entry `write`), OR a map of name → access. The two forms exist so no shipped
|
|
66
|
+
* manifest has to change; the map is what an app with customers writes, and it
|
|
67
|
+
* keeps a name and its access in ONE place — a separate access block would be a
|
|
68
|
+
* second list to forget to update, and the failure mode of forgetting would be
|
|
69
|
+
* a surface quietly staying shut.
|
|
70
|
+
*/
|
|
71
|
+
const surfaceList = (nameRe) => z
|
|
72
|
+
.union([
|
|
73
|
+
z.array(z.string().regex(nameRe)),
|
|
74
|
+
z.record(z.string().regex(nameRe), PluginSurfaceEntrySchema),
|
|
75
|
+
])
|
|
76
|
+
.optional()
|
|
77
|
+
.default([]);
|
|
78
|
+
/** Normalised view of any surface list: every declared name, in order. */
|
|
79
|
+
export function surfaceNames(list) {
|
|
80
|
+
if (Array.isArray(list))
|
|
81
|
+
return list.map(String);
|
|
82
|
+
if (list && typeof list === "object")
|
|
83
|
+
return Object.keys(list);
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
/** The access a surface declares. Absent, unknown or array-form → `write`. */
|
|
87
|
+
export function surfaceAccess(list, name) {
|
|
88
|
+
if (list && !Array.isArray(list) && typeof list === "object") {
|
|
89
|
+
const e = list[name];
|
|
90
|
+
if (e)
|
|
91
|
+
return { level: e.access ?? "write", requiresAccount: e.requires === "account" };
|
|
92
|
+
}
|
|
93
|
+
return { level: "write", requiresAccount: false };
|
|
94
|
+
}
|
|
95
|
+
/** Every name in this list that is reachable without a workspace session. */
|
|
96
|
+
export function publicSurfaceNames(list) {
|
|
97
|
+
return surfaceNames(list).filter((n) => surfaceAccess(list, n).level === "public");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* WHAT THE APP CALLS ITS PEOPLE.
|
|
101
|
+
*
|
|
102
|
+
* The platform knows owner / member / visitor / anonymous. A shop thinks in
|
|
103
|
+
* customer / staff / owner. An app declares its own words and how the
|
|
104
|
+
* platform's kinds map onto them, and the owner can override the word for one
|
|
105
|
+
* person and one app in the sharing settings — the `appRoles` column that has
|
|
106
|
+
* carried a "Phase 5+" comment since it was added, and that nothing has ever
|
|
107
|
+
* written.
|
|
108
|
+
*
|
|
109
|
+
* `describe` is not decoration: it is what the role picker shows the owner when
|
|
110
|
+
* they are deciding what to give somebody.
|
|
111
|
+
*/
|
|
112
|
+
export const PluginRolesSchema = z
|
|
113
|
+
.object({
|
|
114
|
+
vocabulary: z.array(z.string().regex(/^[a-z][a-z0-9_-]*$/)).min(1).max(12),
|
|
115
|
+
default: z
|
|
116
|
+
.object({
|
|
117
|
+
owner: z.string().optional(),
|
|
118
|
+
"member-edit": z.string().optional(),
|
|
119
|
+
"member-readonly": z.string().optional(),
|
|
120
|
+
visitor: z.string().optional(),
|
|
121
|
+
anonymous: z.string().optional(),
|
|
122
|
+
agent: z.string().optional(),
|
|
123
|
+
})
|
|
124
|
+
.strict()
|
|
125
|
+
.optional()
|
|
126
|
+
.default({}),
|
|
127
|
+
describe: z.record(z.string(), z.string().max(200)).optional(),
|
|
128
|
+
})
|
|
129
|
+
.strict()
|
|
130
|
+
.superRefine((r, ctx) => {
|
|
131
|
+
// A default naming a role the app does not have is the stale-override bug
|
|
132
|
+
// waiting to happen, and it is free to catch here.
|
|
133
|
+
for (const [kind, role] of Object.entries(r.default ?? {})) {
|
|
134
|
+
// `inherit` (an agent takes the role of whoever it acts for) and `none`
|
|
135
|
+
// (this app has no word for that kind of caller, so no rule can match
|
|
136
|
+
// them) are the PLATFORM's two sentinels, not app roles. `none` is what
|
|
137
|
+
// `resolveAppRole` itself returns for an unmapped kind — refusing an
|
|
138
|
+
// author for writing down exactly what the platform produces was a
|
|
139
|
+
// disagreement between the validator and the resolver (2026-09-11).
|
|
140
|
+
if (role && role !== "inherit" && role !== "none" && !r.vocabulary.includes(role)) {
|
|
141
|
+
ctx.addIssue({
|
|
142
|
+
code: z.ZodIssueCode.custom,
|
|
143
|
+
path: ["default", kind],
|
|
144
|
+
message: `"${role}" is not in this app's vocabulary (${r.vocabulary.join(", ")})`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const role of Object.keys(r.describe ?? {})) {
|
|
149
|
+
if (!r.vocabulary.includes(role)) {
|
|
150
|
+
ctx.addIssue({
|
|
151
|
+
code: z.ZodIssueCode.custom,
|
|
152
|
+
path: ["describe", role],
|
|
153
|
+
message: `"${role}" is described but not in the vocabulary`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
38
158
|
export const PluginManifestSchema = z.object({
|
|
39
159
|
manifestVersion: z.literal(PLUGIN_MANIFEST_VERSION),
|
|
40
160
|
/** Directory name under src/plugins/. Kebab-case. */
|
|
@@ -63,12 +183,15 @@ export const PluginManifestSchema = z.object({
|
|
|
63
183
|
url: z.string().optional(),
|
|
64
184
|
})
|
|
65
185
|
.optional(),
|
|
66
|
-
/** Tasks the BROWSER may kick via /api/inngest/send-event. */
|
|
67
|
-
kickableTasks:
|
|
186
|
+
/** Tasks the BROWSER may kick via /api/inngest/send-event. Array or name→access. */
|
|
187
|
+
kickableTasks: surfaceList(/^[a-z][a-zA-Z0-9_-]*$/),
|
|
68
188
|
/** Push-ingress endpoints — POST|GET /api/plugins/<id>/webhook/<name>. */
|
|
69
189
|
webhooks: z.array(z.string().regex(/^[a-z][a-z0-9-]*$/)).optional().default([]),
|
|
70
|
-
/** Server ops — POST /api/plugins/<id>/op/<name
|
|
71
|
-
ops:
|
|
190
|
+
/** Server ops — POST /api/plugins/<id>/op/<name>. Array or name→access. */
|
|
191
|
+
ops: surfaceList(/^[a-z][a-z0-9_-]*$/),
|
|
192
|
+
/** Server routes the app brings with it — `pluginServer.routes` in server.ts, mounted at
|
|
193
|
+
* `/api/plugins/<id>/route/<name>` on install (GET and POST; may stream). */
|
|
194
|
+
routes: surfaceList(/^[a-z][a-z0-9_-]*$/),
|
|
72
195
|
/** Poll cadences for durable tasks (the offline-safe pull lane). */
|
|
73
196
|
pollTasks: z
|
|
74
197
|
.array(z.object({
|
|
@@ -83,6 +206,8 @@ export const PluginManifestSchema = z.object({
|
|
|
83
206
|
.optional(),
|
|
84
207
|
/** Reserved for the review/consent flow. */
|
|
85
208
|
scopes: z.array(z.string()).optional().default([]),
|
|
209
|
+
/** The app's own role vocabulary (see PluginRolesSchema). */
|
|
210
|
+
roles: PluginRolesSchema.optional(),
|
|
86
211
|
/**
|
|
87
212
|
* Tools of OTHER apps in the workspace this plugin's UI may invoke through
|
|
88
213
|
* `useWorkspaceTools()` — "<applicationType>:<tool base name>", e.g.
|
|
@@ -133,4 +258,92 @@ export const PluginManifestSchema = z.object({
|
|
|
133
258
|
.strict())
|
|
134
259
|
.optional()
|
|
135
260
|
.default([]),
|
|
136
|
-
|
|
261
|
+
/**
|
|
262
|
+
* SLOTS this app needs filled (S9). `contract` names what must stand there
|
|
263
|
+
* ("stock/v1"); the OWNER picks which app fills it, and the platform checks
|
|
264
|
+
* at bind time that the provider really has every tool, event and table the
|
|
265
|
+
* contract requires. A required slot nothing fills refuses at the seam
|
|
266
|
+
* (`not-bound`); an optional one is simply absent.
|
|
267
|
+
*/
|
|
268
|
+
uses: z
|
|
269
|
+
.record(z.string().regex(/^[a-z][a-z0-9-]*$/), z
|
|
270
|
+
.object({
|
|
271
|
+
contract: z.string().regex(/^[a-z][a-z0-9-]*\/v\d+$/),
|
|
272
|
+
label: z.string().min(1).max(60).optional(),
|
|
273
|
+
optional: z.boolean().optional(),
|
|
274
|
+
})
|
|
275
|
+
.strict())
|
|
276
|
+
.optional(),
|
|
277
|
+
/**
|
|
278
|
+
* What this app OFFERS other apps, per contract id. A claim, checked at bind
|
|
279
|
+
* time against what the app actually has — a manifest that names a tool the
|
|
280
|
+
* app does not mint refuses the binding rather than failing later inside
|
|
281
|
+
* someone's order.
|
|
282
|
+
*/
|
|
283
|
+
provides: z
|
|
284
|
+
.record(z.string().regex(/^[a-z][a-z0-9-]*\/v\d+$/), z
|
|
285
|
+
.object({
|
|
286
|
+
tools: z.array(z.string()).optional(),
|
|
287
|
+
events: z.array(z.string()).optional(),
|
|
288
|
+
models: z.array(z.string()).optional(),
|
|
289
|
+
})
|
|
290
|
+
.strict())
|
|
291
|
+
.optional(),
|
|
292
|
+
/**
|
|
293
|
+
* The app's realtime TOPICS, and who hears each (forge-sdk-spec-and-tests
|
|
294
|
+
* §A1.3.5). `audience` is `all` (default — the instance's channel), `viewer`
|
|
295
|
+
* (one channel per person) or `role:<name>`; `mayAddress` names the roles
|
|
296
|
+
* allowed to aim a message at someone else. The platform mints a token per
|
|
297
|
+
* channel, so a customer is never handed the staff channel or another
|
|
298
|
+
* customer's — it is not filtered on arrival, it is never issued.
|
|
299
|
+
* `esoul-sdk/audience` is the one decision both sides use.
|
|
300
|
+
*/
|
|
301
|
+
channel: z
|
|
302
|
+
.object({
|
|
303
|
+
topics: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/), z
|
|
304
|
+
.object({
|
|
305
|
+
audience: z.union([z.literal("all"), z.literal("viewer"), z.string().regex(/^role:[a-z][a-z0-9-]*$/)]).optional(),
|
|
306
|
+
mayAddress: z.array(z.string()).optional(),
|
|
307
|
+
description: z.string().max(200).optional(),
|
|
308
|
+
})
|
|
309
|
+
.strict()),
|
|
310
|
+
})
|
|
311
|
+
.strict()
|
|
312
|
+
.optional(),
|
|
313
|
+
/**
|
|
314
|
+
* The app's own TABLES (forge-sdk-spec-and-tests §A1.3.4). Shape only: the
|
|
315
|
+
* rule compiler (`db/compile-rules.ts`) is what validates a field type, a
|
|
316
|
+
* rule's principals and a sealed column, and it says which key was wrong.
|
|
317
|
+
*
|
|
318
|
+
* It is declared HERE because a schema that does not know a block SILENTLY
|
|
319
|
+
* DROPS it — `z.object` strips what it has no field for. The install path
|
|
320
|
+
* parses through this schema, so for one evening an app installed from its
|
|
321
|
+
* own repository arrived with `db: undefined`, the job skipped its
|
|
322
|
+
* schema step without a word, and the app was live with no tables
|
|
323
|
+
* (2026-09-11, found by driving it). Hence `.strict()` below: a block this
|
|
324
|
+
* platform does not understand refuses the install instead of vanishing.
|
|
325
|
+
*/
|
|
326
|
+
db: z
|
|
327
|
+
.record(z.string().regex(/^[A-Z][A-Za-z0-9]*$/), z
|
|
328
|
+
.object({
|
|
329
|
+
scope: z.enum(["instance", "workspace", "user"]).optional(),
|
|
330
|
+
owner: z.literal("creator").optional(),
|
|
331
|
+
fields: z.record(z.string().regex(/^[a-z][A-Za-z0-9]*$/), z.string()),
|
|
332
|
+
sealed: z.array(z.string()).optional(),
|
|
333
|
+
unique: z.array(z.array(z.string())).optional(),
|
|
334
|
+
// A plain group is a btree; an object names a kind — `contains` for
|
|
335
|
+
// a list field, `text` for substring search (docs/14).
|
|
336
|
+
indexes: z
|
|
337
|
+
.array(z.union([
|
|
338
|
+
z.array(z.string()),
|
|
339
|
+
z.object({ fields: z.array(z.string()).min(1), kind: z.enum(["btree", "contains", "text"]).optional() }).strict(),
|
|
340
|
+
]))
|
|
341
|
+
.optional(),
|
|
342
|
+
rules: z.record(z.string(), z.unknown()).optional(),
|
|
343
|
+
})
|
|
344
|
+
.strict())
|
|
345
|
+
.optional(),
|
|
346
|
+
})
|
|
347
|
+
// A manifest key the platform does not know is a REFUSAL, never a silent
|
|
348
|
+
// drop: the block the author wrote would not run, and nothing would say so.
|
|
349
|
+
.strict();
|
package/dist/roles.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE role mapping — one table, one implementation.
|
|
3
|
+
*
|
|
4
|
+
* An app declares its own words for the people who use it (`customer`,
|
|
5
|
+
* `staff`), and the platform has its own (`owner`, `editor`, `viewer`). Turning
|
|
6
|
+
* one into the other is three lines, which is exactly why it was about to
|
|
7
|
+
* exist twice: once in the host's viewer builder and once in the SDK's test
|
|
8
|
+
* helpers. Two implementations of one mapping is the shape of bug where a
|
|
9
|
+
* read-only member is `staff` in an author's unit test and `viewer` in
|
|
10
|
+
* production, and every rule naming `staff` quietly changes meaning between
|
|
11
|
+
* the two.
|
|
12
|
+
*
|
|
13
|
+
* So it lives in the package (the half an author installs) and the host reads
|
|
14
|
+
* it from here — the same single-sourcing as the manifest schema.
|
|
15
|
+
*/
|
|
16
|
+
/** What the platform knows about a caller, as a key into an app's `default` map. */
|
|
17
|
+
export type RoleKindKey = "owner" | "member-edit" | "member-readonly" | "visitor" | "anonymous" | "agent";
|
|
18
|
+
export interface PluginRoleVocabulary {
|
|
19
|
+
/** Every word this app uses. A role outside it is not a role. */
|
|
20
|
+
vocabulary: string[];
|
|
21
|
+
/** Which word each kind of caller gets by default. */
|
|
22
|
+
default: Partial<Record<RoleKindKey, string>>;
|
|
23
|
+
}
|
|
24
|
+
/** The key for a caller, given their kind and (for a member) their access. */
|
|
25
|
+
export declare function roleKeyFor(kind: "owner" | "member" | "visitor" | "anonymous" | "agent" | "internal", accessType?: "readonly" | "edit" | null): RoleKindKey;
|
|
26
|
+
/**
|
|
27
|
+
* The app's word for this caller.
|
|
28
|
+
*
|
|
29
|
+
* `platformRole` is the platform's own answer (`appRoles[nodeId] ?? defaultRole`).
|
|
30
|
+
* Three rules, in order:
|
|
31
|
+
* 1. No vocabulary → the platform's word passes through, so nothing that
|
|
32
|
+
* exists today moves.
|
|
33
|
+
* 2. The platform's word IS one of the app's → the owner's per-app override
|
|
34
|
+
* wins, which is what the override is for.
|
|
35
|
+
* 3. Otherwise the app's default for this kind — and if that names something
|
|
36
|
+
* outside the vocabulary (a stale override, a role dropped in an update),
|
|
37
|
+
* `"none"`, which no rule can match. Never a word no rule mentions.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveAppRole(args: {
|
|
40
|
+
roles?: PluginRoleVocabulary | null;
|
|
41
|
+
platformRole: string;
|
|
42
|
+
key: RoleKindKey;
|
|
43
|
+
}): string;
|
package/dist/roles.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE role mapping — one table, one implementation.
|
|
3
|
+
*
|
|
4
|
+
* An app declares its own words for the people who use it (`customer`,
|
|
5
|
+
* `staff`), and the platform has its own (`owner`, `editor`, `viewer`). Turning
|
|
6
|
+
* one into the other is three lines, which is exactly why it was about to
|
|
7
|
+
* exist twice: once in the host's viewer builder and once in the SDK's test
|
|
8
|
+
* helpers. Two implementations of one mapping is the shape of bug where a
|
|
9
|
+
* read-only member is `staff` in an author's unit test and `viewer` in
|
|
10
|
+
* production, and every rule naming `staff` quietly changes meaning between
|
|
11
|
+
* the two.
|
|
12
|
+
*
|
|
13
|
+
* So it lives in the package (the half an author installs) and the host reads
|
|
14
|
+
* it from here — the same single-sourcing as the manifest schema.
|
|
15
|
+
*/
|
|
16
|
+
/** The key for a caller, given their kind and (for a member) their access. */
|
|
17
|
+
export function roleKeyFor(kind, accessType) {
|
|
18
|
+
switch (kind) {
|
|
19
|
+
case "owner":
|
|
20
|
+
return "owner";
|
|
21
|
+
case "member":
|
|
22
|
+
return accessType === "readonly" ? "member-readonly" : "member-edit";
|
|
23
|
+
case "visitor":
|
|
24
|
+
return "visitor";
|
|
25
|
+
case "anonymous":
|
|
26
|
+
return "anonymous";
|
|
27
|
+
case "agent":
|
|
28
|
+
return "agent";
|
|
29
|
+
case "internal":
|
|
30
|
+
// The app's own code acts with the owner's reach; `internal` bypasses
|
|
31
|
+
// rules anyway, so the word it carries only ever shows up in a log.
|
|
32
|
+
return "owner";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The app's word for this caller.
|
|
37
|
+
*
|
|
38
|
+
* `platformRole` is the platform's own answer (`appRoles[nodeId] ?? defaultRole`).
|
|
39
|
+
* Three rules, in order:
|
|
40
|
+
* 1. No vocabulary → the platform's word passes through, so nothing that
|
|
41
|
+
* exists today moves.
|
|
42
|
+
* 2. The platform's word IS one of the app's → the owner's per-app override
|
|
43
|
+
* wins, which is what the override is for.
|
|
44
|
+
* 3. Otherwise the app's default for this kind — and if that names something
|
|
45
|
+
* outside the vocabulary (a stale override, a role dropped in an update),
|
|
46
|
+
* `"none"`, which no rule can match. Never a word no rule mentions.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveAppRole(args) {
|
|
49
|
+
const { roles, platformRole, key } = args;
|
|
50
|
+
if (!roles || !roles.vocabulary?.length)
|
|
51
|
+
return platformRole;
|
|
52
|
+
if (roles.vocabulary.includes(platformRole))
|
|
53
|
+
return platformRole;
|
|
54
|
+
const mapped = roles.default?.[key];
|
|
55
|
+
return mapped && roles.vocabulary.includes(mapped) ? mapped : "none";
|
|
56
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -12,6 +12,20 @@ export interface PluginWebhookContext {
|
|
|
12
12
|
/** Raw request — read body/headers; VERIFY THE PROVIDER'S SIGNATURE
|
|
13
13
|
* YOURSELF (timing-safe!). The platform does no auth on webhooks. */
|
|
14
14
|
request: Request;
|
|
15
|
+
/**
|
|
16
|
+
* The shared secret an operator configured for THIS plugin's webhooks, or
|
|
17
|
+
* null. Compare it with `timingSafeEqual`; refuse when it is null rather
|
|
18
|
+
* than falling back to a default, or an unconfigured deployment accepts
|
|
19
|
+
* anyone's POST.
|
|
20
|
+
*
|
|
21
|
+
* It exists because a webhook must verify its caller and the secret has to
|
|
22
|
+
* come from somewhere — and an app reading `process.env` itself is exactly
|
|
23
|
+
* what the import wall refuses (it found this: the demo plugin was doing it).
|
|
24
|
+
* The PLATFORM reads the environment and names the variable, so an operator
|
|
25
|
+
* knows what to set: `PLUGIN_<ID>_WEBHOOK_TOKEN`, id upper-snaked
|
|
26
|
+
* (`todo-plugin` → `PLUGIN_TODO_PLUGIN_WEBHOOK_TOKEN`).
|
|
27
|
+
*/
|
|
28
|
+
secret: string | null;
|
|
15
29
|
method: string;
|
|
16
30
|
pluginId: string;
|
|
17
31
|
hookName: string;
|
|
@@ -20,6 +34,39 @@ export interface PluginWebhookContext {
|
|
|
20
34
|
sendInngestEvent(name: string, data: Record<string, unknown>): Promise<void>;
|
|
21
35
|
}
|
|
22
36
|
export type PluginWebhookHandler = (ctx: PluginWebhookContext) => Promise<Response>;
|
|
37
|
+
/**
|
|
38
|
+
* WHO is calling. MIRRORS the host (src/lib/plugins/viewer.ts).
|
|
39
|
+
*
|
|
40
|
+
* The platform resolves it before your handler runs and it is the ONLY
|
|
41
|
+
* identity you get: an app cannot read cookies or headers (the import wall
|
|
42
|
+
* refuses the modules that would let it), so there is nothing to forge and
|
|
43
|
+
* nothing to get wrong. Branch on `role` for what your app means by this
|
|
44
|
+
* person; `viewerIds` is for scoping rows to whoever made them.
|
|
45
|
+
*/
|
|
46
|
+
export type PluginViewerKind = "owner" | "member" | "visitor" | "anonymous" | "agent" | "internal";
|
|
47
|
+
export interface PluginViewer {
|
|
48
|
+
kind: PluginViewerKind;
|
|
49
|
+
/** The esoul account, when there is one. */
|
|
50
|
+
userId: string | null;
|
|
51
|
+
/**
|
|
52
|
+
* Every id this person has acted under — the guest cookie, the account, and
|
|
53
|
+
* every guest id linked to it at login. Scope a row to its creator against
|
|
54
|
+
* this SET, not against `userId`, and an order placed before signing in
|
|
55
|
+
* stays theirs afterwards. Server-side only; never reaches the browser.
|
|
56
|
+
*/
|
|
57
|
+
viewerIds: string[];
|
|
58
|
+
/** Your app's own word for this caller, from the manifest's `roles`. */
|
|
59
|
+
role: string;
|
|
60
|
+
/** May this caller change the workspace at all? */
|
|
61
|
+
canWrite: boolean;
|
|
62
|
+
/** The public share they arrived through, if any. */
|
|
63
|
+
shareId: string | null;
|
|
64
|
+
/** Set when `kind === "agent"` — the person the run acts for. */
|
|
65
|
+
agent?: {
|
|
66
|
+
runId?: string;
|
|
67
|
+
onBehalfOf: Omit<PluginViewer, "agent">;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
23
70
|
export interface PluginOpContext {
|
|
24
71
|
pluginId: string;
|
|
25
72
|
opName: string;
|
|
@@ -29,13 +76,116 @@ export interface PluginOpContext {
|
|
|
29
76
|
/** The instance's bound connection (or null). Resolve credentials via
|
|
30
77
|
* getPluginConnectionCredentials. */
|
|
31
78
|
cloudConnectionId: string | null;
|
|
79
|
+
/** WHO is calling — resolved by the platform, never by you. */
|
|
80
|
+
viewer: PluginViewer;
|
|
32
81
|
/** Caller-supplied args — validate before use. */
|
|
33
82
|
args: unknown;
|
|
83
|
+
/**
|
|
84
|
+
* Publish on one of your channel's topics (S8). WHO HEARS IT is the topic's
|
|
85
|
+
* `audience` in your manifest: `all` reaches anyone watching the app,
|
|
86
|
+
* `viewer` only the person it concerns, `role:<name>` only that desk.
|
|
87
|
+
*
|
|
88
|
+
* Unaddressed, a message goes where the topic says — a `viewer` topic to the
|
|
89
|
+
* CALLER's own channel, which is what "your order was placed" wants. `to`
|
|
90
|
+
* aims it somewhere else, and aiming is a separate permission: this caller
|
|
91
|
+
* may address only itself unless the topic's `mayAddress` names its role.
|
|
92
|
+
* Your own tasks always may.
|
|
93
|
+
*/
|
|
94
|
+
notify(topic: string, data: unknown, opts?: {
|
|
95
|
+
to?: {
|
|
96
|
+
viewerIds: string[];
|
|
97
|
+
} | {
|
|
98
|
+
role: string;
|
|
99
|
+
};
|
|
100
|
+
}): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* RECORD SOMETHING ON YOUR OWN TIMELINE, from the server half.
|
|
103
|
+
*
|
|
104
|
+
* The fold is for what is shared, small and worth scrubbing. Until this
|
|
105
|
+
* existed, only the UI could put anything there — so a shop whose products
|
|
106
|
+
* were added by its AGENT had a catalogue and no departments, because the
|
|
107
|
+
* departments were only recorded by the owner's form (2026-09-12). Anything
|
|
108
|
+
* a person can cause through your UI, an agent can cause through a tool, and
|
|
109
|
+
* the server is the only place that sees both.
|
|
110
|
+
*
|
|
111
|
+
* The event is your own (`eventName` from your schema), it goes through the
|
|
112
|
+
* platform's spine — your `dataCreator` mints, your reducer folds, triggers
|
|
113
|
+
* fire — and it is recorded against THIS instance. Give it a change id you
|
|
114
|
+
* can derive again (`product:<id>`, never a random one) so a retry, or the
|
|
115
|
+
* UI's own optimistic dispatch of the same fact, is a no-op.
|
|
116
|
+
*/
|
|
117
|
+
emit(eventName: string, eventData: Record<string, unknown>): Promise<void>;
|
|
118
|
+
/**
|
|
119
|
+
* The apps standing in this app's SLOTS (manifest `uses`), by slot name
|
|
120
|
+
* (S9). A slot the owner has not filled is ABSENT — `ctx.apps.stock?.call(…)`
|
|
121
|
+
* reads as the question it is, and a required slot that must be filled
|
|
122
|
+
* should refuse with `not-bound` rather than pretend.
|
|
123
|
+
*
|
|
124
|
+
* What a slot reaches is the CONTRACT's tools, not the provider's whole
|
|
125
|
+
* toolkit, and the caller travels: the provider's own rules meet the actual
|
|
126
|
+
* person. Writes never cross directly — the provider's tools are the only
|
|
127
|
+
* writers of its tables, which is how a ledger keeps refusing to oversell.
|
|
128
|
+
*/
|
|
129
|
+
apps: Record<string, {
|
|
130
|
+
nodeId: string;
|
|
131
|
+
applicationType: string;
|
|
132
|
+
via: string;
|
|
133
|
+
state(): Promise<Record<string, unknown>>;
|
|
134
|
+
call(tool: string, args?: Record<string, unknown>): Promise<{
|
|
135
|
+
ok: boolean;
|
|
136
|
+
text: string;
|
|
137
|
+
}>;
|
|
138
|
+
}>;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* WHO IS THIS, IN WORDS. The caller's own name and email, for an app with a
|
|
142
|
+
* reason to ask — a receipt, an address form they should not retype.
|
|
143
|
+
*
|
|
144
|
+
* SERVER ONLY, and only ever about the CALLER: there is no argument for whose
|
|
145
|
+
* profile to read, so an app cannot look up a person who is not talking to it.
|
|
146
|
+
* Null for anyone without an account. One database read: call it where you
|
|
147
|
+
* need it, not on every request.
|
|
148
|
+
*/
|
|
149
|
+
export declare function viewerProfile(_viewer: PluginViewer): Promise<ViewerProfile | null>;
|
|
150
|
+
export interface ViewerProfile {
|
|
151
|
+
userId: string;
|
|
152
|
+
email: string | null;
|
|
153
|
+
name: string | null;
|
|
154
|
+
picture: string | null;
|
|
34
155
|
}
|
|
35
156
|
export type PluginOpHandler = (ctx: PluginOpContext) => Promise<unknown>;
|
|
157
|
+
/**
|
|
158
|
+
* A server ROUTE the app brings with it: `GET|POST /api/plugins/<id>/route/<name>?nodeId=…`
|
|
159
|
+
* (declared in plugin.json `routes`). Unlike an op it owns the whole Response — it may
|
|
160
|
+
* stream (Server-Sent Events via `sseStream`), set headers, return bytes. The platform
|
|
161
|
+
* resolves the instance and the caller's access before the handler runs; the handler
|
|
162
|
+
* never sees an unauthenticated request. It runs where the platform's own API routes
|
|
163
|
+
* run (Fluid compute, minutes-long invocations allowed), so a clock, a poller or a
|
|
164
|
+
* long-poll can live here — for as long as ONE request lasts. Anything that must
|
|
165
|
+
* outlive a request is a task (docs/07).
|
|
166
|
+
*/
|
|
167
|
+
export interface PluginRouteContext {
|
|
168
|
+
pluginId: string;
|
|
169
|
+
routeName: string;
|
|
170
|
+
method: "GET" | "POST";
|
|
171
|
+
request: Request;
|
|
172
|
+
searchParams: URLSearchParams;
|
|
173
|
+
workspaceId: string;
|
|
174
|
+
nodeId: string;
|
|
175
|
+
instanceName: string;
|
|
176
|
+
applicationType: string;
|
|
177
|
+
cloudConnectionId: string | null;
|
|
178
|
+
/** The caller may mutate this workspace (a viewer of a public share may not). */
|
|
179
|
+
canWrite: boolean;
|
|
180
|
+
/** WHO is calling — the same resolved identity every other seam receives. */
|
|
181
|
+
viewer: PluginViewer;
|
|
182
|
+
}
|
|
183
|
+
export type PluginRouteHandler = (ctx: PluginRouteContext) => Promise<Response>;
|
|
36
184
|
export interface PluginServerModule {
|
|
37
185
|
webhooks?: Record<string, PluginWebhookHandler>;
|
|
38
186
|
ops?: Record<string, PluginOpHandler>;
|
|
187
|
+
/** routeName → handler, for names declared in plugin.json `routes`. */
|
|
188
|
+
routes?: Record<string, PluginRouteHandler>;
|
|
39
189
|
}
|
|
40
190
|
export type PluginConnectionCredentials = {
|
|
41
191
|
kind: "oauth2";
|
|
@@ -50,6 +200,28 @@ export type PluginConnectionCredentials = {
|
|
|
50
200
|
* refreshes expiring OAuth tokens). HOST-ONLY.
|
|
51
201
|
*/
|
|
52
202
|
export declare function getPluginConnectionCredentials(_connectionId: string, _pluginId: string): Promise<PluginConnectionCredentials>;
|
|
203
|
+
/**
|
|
204
|
+
* The client for the tables YOUR manifest declared (`db`). Hand it any server
|
|
205
|
+
* context — an op's, a route's, a task's — and get back a client whose reads
|
|
206
|
+
* are already scoped to this instance and this caller, and whose writes refuse
|
|
207
|
+
* what the rules refuse. You write no access checks; you cannot forget one.
|
|
208
|
+
*
|
|
209
|
+
* const db = await pluginDb<ShopDb>(ctx); // ShopDb from ./.esoul/db
|
|
210
|
+
* const mine = await db.order.findMany({ orderBy: { createdAt: "desc" } });
|
|
211
|
+
*
|
|
212
|
+
* `across: "owned-instances"` (the owner, across their shops) and `across:
|
|
213
|
+
* "my-rows"` (an account, across every instance) are read-only reaches.
|
|
214
|
+
* HOST-ONLY: in a unit test use `memoryDb(manifest)` from `esoul-sdk/testing`.
|
|
215
|
+
*/
|
|
216
|
+
export declare function pluginDb<T = unknown>(_ctx: {
|
|
217
|
+
pluginId: string;
|
|
218
|
+
workspaceId: string;
|
|
219
|
+
nodeId: string;
|
|
220
|
+
viewer: PluginViewer;
|
|
221
|
+
}, _reach?: {
|
|
222
|
+
across?: "owned-instances" | "my-rows";
|
|
223
|
+
viaBinding?: boolean;
|
|
224
|
+
}): Promise<T>;
|
|
53
225
|
export interface EmitPluginAppEventArgs {
|
|
54
226
|
source: {
|
|
55
227
|
pluginId: string;
|
|
@@ -187,3 +359,13 @@ export declare function filesForOp(_ctx: {
|
|
|
187
359
|
workspaceId: string;
|
|
188
360
|
nodeId?: string;
|
|
189
361
|
}): Promise<FilesApi>;
|
|
362
|
+
/**
|
|
363
|
+
* A Server-Sent Events response. `run` gets `send(event, data)` and the request's
|
|
364
|
+
* abort signal; return when done (or when the signal fires — the client left). A
|
|
365
|
+
* heartbeat comment every 15 s keeps proxies from closing an idle stream. Real code,
|
|
366
|
+
* not host-provided: streaming is the web platform's.
|
|
367
|
+
*/
|
|
368
|
+
export declare function sseStream(run: (send: (event: string, data: unknown) => void, signal: AbortSignal) => Promise<void>, opts?: {
|
|
369
|
+
signal?: AbortSignal;
|
|
370
|
+
heartbeatMs?: number;
|
|
371
|
+
}): Response;
|
package/dist/server.js
CHANGED
|
@@ -8,6 +8,18 @@
|
|
|
8
8
|
* Inside the host, `esoul-sdk/server` is aliased to the real
|
|
9
9
|
* implementations.
|
|
10
10
|
*/
|
|
11
|
+
/**
|
|
12
|
+
* WHO IS THIS, IN WORDS. The caller's own name and email, for an app with a
|
|
13
|
+
* reason to ask — a receipt, an address form they should not retype.
|
|
14
|
+
*
|
|
15
|
+
* SERVER ONLY, and only ever about the CALLER: there is no argument for whose
|
|
16
|
+
* profile to read, so an app cannot look up a person who is not talking to it.
|
|
17
|
+
* Null for anyone without an account. One database read: call it where you
|
|
18
|
+
* need it, not on every request.
|
|
19
|
+
*/
|
|
20
|
+
export function viewerProfile(_viewer) {
|
|
21
|
+
return hostOnly("viewerProfile");
|
|
22
|
+
}
|
|
11
23
|
const hostOnly = (name) => {
|
|
12
24
|
throw new Error(`${name} runs only inside the ExternalSoul host (the app aliases esoul-sdk to its real implementations). In unit tests, mock this module.`);
|
|
13
25
|
};
|
|
@@ -18,6 +30,22 @@ const hostOnly = (name) => {
|
|
|
18
30
|
export function getPluginConnectionCredentials(_connectionId, _pluginId) {
|
|
19
31
|
return hostOnly("getPluginConnectionCredentials");
|
|
20
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The client for the tables YOUR manifest declared (`db`). Hand it any server
|
|
35
|
+
* context — an op's, a route's, a task's — and get back a client whose reads
|
|
36
|
+
* are already scoped to this instance and this caller, and whose writes refuse
|
|
37
|
+
* what the rules refuse. You write no access checks; you cannot forget one.
|
|
38
|
+
*
|
|
39
|
+
* const db = await pluginDb<ShopDb>(ctx); // ShopDb from ./.esoul/db
|
|
40
|
+
* const mine = await db.order.findMany({ orderBy: { createdAt: "desc" } });
|
|
41
|
+
*
|
|
42
|
+
* `across: "owned-instances"` (the owner, across their shops) and `across:
|
|
43
|
+
* "my-rows"` (an account, across every instance) are read-only reaches.
|
|
44
|
+
* HOST-ONLY: in a unit test use `memoryDb(manifest)` from `esoul-sdk/testing`.
|
|
45
|
+
*/
|
|
46
|
+
export function pluginDb(_ctx, _reach) {
|
|
47
|
+
return hostOnly("pluginDb");
|
|
48
|
+
}
|
|
21
49
|
/**
|
|
22
50
|
* Cross-app events: dispatch the TARGET app's own events through the
|
|
23
51
|
* platform spine (target's dataCreator mints; triggers fire; every event is
|
|
@@ -56,3 +84,55 @@ export function pluginFiles(_ctx) {
|
|
|
56
84
|
export function filesForOp(_ctx) {
|
|
57
85
|
return hostOnly("filesForOp");
|
|
58
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* A Server-Sent Events response. `run` gets `send(event, data)` and the request's
|
|
89
|
+
* abort signal; return when done (or when the signal fires — the client left). A
|
|
90
|
+
* heartbeat comment every 15 s keeps proxies from closing an idle stream. Real code,
|
|
91
|
+
* not host-provided: streaming is the web platform's.
|
|
92
|
+
*/
|
|
93
|
+
export function sseStream(run, opts) {
|
|
94
|
+
const enc = new TextEncoder();
|
|
95
|
+
const ac = new AbortController();
|
|
96
|
+
opts?.signal?.addEventListener("abort", () => ac.abort(), { once: true });
|
|
97
|
+
const stream = new ReadableStream({
|
|
98
|
+
start(controller) {
|
|
99
|
+
let closed = false;
|
|
100
|
+
const write = (chunk) => {
|
|
101
|
+
if (closed)
|
|
102
|
+
return;
|
|
103
|
+
try {
|
|
104
|
+
controller.enqueue(enc.encode(chunk));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
closed = true;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const send = (event, data) => write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
111
|
+
const beat = setInterval(() => write(": keep-alive\n\n"), Math.max(1_000, opts?.heartbeatMs ?? 15_000));
|
|
112
|
+
const finish = () => {
|
|
113
|
+
clearInterval(beat);
|
|
114
|
+
if (closed)
|
|
115
|
+
return;
|
|
116
|
+
closed = true;
|
|
117
|
+
try {
|
|
118
|
+
controller.close();
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* already closed */
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
ac.signal.addEventListener("abort", finish, { once: true });
|
|
125
|
+
write(": open\n\n");
|
|
126
|
+
run(send, ac.signal).then(finish, (err) => {
|
|
127
|
+
send("error", { message: err instanceof Error ? err.message : String(err) });
|
|
128
|
+
finish();
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
cancel() {
|
|
132
|
+
ac.abort();
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
return new Response(stream, {
|
|
136
|
+
headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no" },
|
|
137
|
+
});
|
|
138
|
+
}
|