esoul-sdk 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +135 -24
  2. package/dist/audience.d.ts +103 -0
  3. package/dist/audience.js +142 -0
  4. package/dist/bindings.d.ts +164 -0
  5. package/dist/bindings.js +163 -0
  6. package/dist/db/client-core.d.ts +154 -0
  7. package/dist/db/client-core.js +274 -0
  8. package/dist/db/compile-rules.d.ts +199 -0
  9. package/dist/db/compile-rules.js +390 -0
  10. package/dist/db/memory-client.d.ts +136 -0
  11. package/dist/db/memory-client.js +323 -0
  12. package/dist/db/schema-gen.d.ts +103 -0
  13. package/dist/db/schema-gen.js +329 -0
  14. package/dist/helpers.d.ts +67 -0
  15. package/dist/helpers.js +125 -8
  16. package/dist/index.d.ts +24 -0
  17. package/dist/index.js +22 -0
  18. package/dist/manifest.d.ts +445 -13
  19. package/dist/manifest.js +211 -5
  20. package/dist/react.d.ts +29 -0
  21. package/dist/react.js +10 -0
  22. package/dist/roles.d.ts +43 -0
  23. package/dist/roles.js +56 -0
  24. package/dist/server.d.ts +165 -0
  25. package/dist/server.js +80 -0
  26. package/dist/testing/db.d.ts +69 -0
  27. package/dist/testing/db.js +94 -0
  28. package/dist/testing/index.d.ts +14 -0
  29. package/dist/testing/index.js +9 -0
  30. package/dist/testing/ops.d.ts +84 -0
  31. package/dist/testing/ops.js +76 -0
  32. package/dist/types.d.ts +22 -1
  33. package/docs/04-tools.md +5 -2
  34. package/docs/05-ui.md +30 -0
  35. package/docs/06-server.md +49 -0
  36. package/docs/07-background-tasks.md +29 -3
  37. package/docs/10-testing.md +18 -0
  38. package/docs/12-rules.md +3 -2
  39. package/docs/13-people-and-access.md +148 -0
  40. package/docs/14-database.md +115 -0
  41. package/docs/15-realtime.md +88 -0
  42. package/docs/16-bindings.md +79 -0
  43. package/llms-full.txt +715 -31
  44. package/llms.txt +4 -0
  45. package/package.json +7 -3
  46. package/schemas/plugin.schema.json +323 -9
package/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: z.array(z.string()).optional().default([]),
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> (platform-gated). */
71
- ops: z.array(z.string().regex(/^[a-z][a-z0-9_-]*$/)).optional().default([]),
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,85 @@ 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
+ indexes: z.array(z.array(z.string())).optional(),
335
+ rules: z.record(z.string(), z.unknown()).optional(),
336
+ })
337
+ .strict())
338
+ .optional(),
339
+ })
340
+ // A manifest key the platform does not know is a REFUSAL, never a silent
341
+ // drop: the block the author wrote would not run, and nothing would say so.
342
+ .strict();
package/dist/react.d.ts CHANGED
@@ -83,3 +83,32 @@ export declare function usePluginFileUpload(_workspaceId: string): (file: File)
83
83
  export declare function useFileSources(_workspaceId: string | null): FileSourcesState;
84
84
  /** One mount's listing; a failing source reports error/errorKind, never []. */
85
85
  export declare function useFileSourceEntries(_workspaceId: string | null, _sourceId: string | null, _folderRef?: string): FileEntriesState;
86
+ export interface PluginRealtimeMessage<T = unknown> {
87
+ topic: string;
88
+ data: T;
89
+ }
90
+ export interface PluginRealtime<T = unknown> {
91
+ /** Every message received this mount, oldest first. */
92
+ data: PluginRealtimeMessage<T>[];
93
+ latestData: PluginRealtimeMessage<T> | null;
94
+ error: Error | null;
95
+ /** "connecting" | "active" | "closed" | "error". */
96
+ state: string;
97
+ }
98
+ /**
99
+ * Subscribe to this instance's channel (the one you declared with
100
+ * `definePluginChannel` and put on the schema as `channel`). A task publishes
101
+ * with `ctx.notify(topic, data)`; each message arrives here as
102
+ * `{ topic, data }`. Realtime is a NUDGE, never the truth: anything that must
103
+ * survive a refresh goes on the timeline as an event as well.
104
+ */
105
+ export declare function usePluginRealtime<T = unknown>(_args: {
106
+ channel: (p: {
107
+ workspaceId: string;
108
+ nodeId: string;
109
+ }) => unknown;
110
+ workspaceId: string;
111
+ nodeId: string;
112
+ topics: readonly string[];
113
+ enabled?: boolean;
114
+ }): PluginRealtime<T>;
package/dist/react.js CHANGED
@@ -41,3 +41,13 @@ export function useFileSources(_workspaceId) {
41
41
  export function useFileSourceEntries(_workspaceId, _sourceId, _folderRef) {
42
42
  return hostOnly("useFileSourceEntries");
43
43
  }
44
+ /**
45
+ * Subscribe to this instance's channel (the one you declared with
46
+ * `definePluginChannel` and put on the schema as `channel`). A task publishes
47
+ * with `ctx.notify(topic, data)`; each message arrives here as
48
+ * `{ topic, data }`. Realtime is a NUDGE, never the truth: anything that must
49
+ * survive a refresh goes on the timeline as an event as well.
50
+ */
51
+ export function usePluginRealtime(_args) {
52
+ return hostOnly("usePluginRealtime");
53
+ }
@@ -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,99 @@ 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
+ * The apps standing in this app's SLOTS (manifest `uses`), by slot name
103
+ * (S9). A slot the owner has not filled is ABSENT — `ctx.apps.stock?.call(…)`
104
+ * reads as the question it is, and a required slot that must be filled
105
+ * should refuse with `not-bound` rather than pretend.
106
+ *
107
+ * What a slot reaches is the CONTRACT's tools, not the provider's whole
108
+ * toolkit, and the caller travels: the provider's own rules meet the actual
109
+ * person. Writes never cross directly — the provider's tools are the only
110
+ * writers of its tables, which is how a ledger keeps refusing to oversell.
111
+ */
112
+ apps: Record<string, {
113
+ nodeId: string;
114
+ applicationType: string;
115
+ via: string;
116
+ state(): Promise<Record<string, unknown>>;
117
+ call(tool: string, args?: Record<string, unknown>): Promise<{
118
+ ok: boolean;
119
+ text: string;
120
+ }>;
121
+ }>;
122
+ }
123
+ /**
124
+ * WHO IS THIS, IN WORDS. The caller's own name and email, for an app with a
125
+ * reason to ask — a receipt, an address form they should not retype.
126
+ *
127
+ * SERVER ONLY, and only ever about the CALLER: there is no argument for whose
128
+ * profile to read, so an app cannot look up a person who is not talking to it.
129
+ * Null for anyone without an account. One database read: call it where you
130
+ * need it, not on every request.
131
+ */
132
+ export declare function viewerProfile(_viewer: PluginViewer): Promise<ViewerProfile | null>;
133
+ export interface ViewerProfile {
134
+ userId: string;
135
+ email: string | null;
136
+ name: string | null;
137
+ picture: string | null;
34
138
  }
35
139
  export type PluginOpHandler = (ctx: PluginOpContext) => Promise<unknown>;
140
+ /**
141
+ * A server ROUTE the app brings with it: `GET|POST /api/plugins/<id>/route/<name>?nodeId=…`
142
+ * (declared in plugin.json `routes`). Unlike an op it owns the whole Response — it may
143
+ * stream (Server-Sent Events via `sseStream`), set headers, return bytes. The platform
144
+ * resolves the instance and the caller's access before the handler runs; the handler
145
+ * never sees an unauthenticated request. It runs where the platform's own API routes
146
+ * run (Fluid compute, minutes-long invocations allowed), so a clock, a poller or a
147
+ * long-poll can live here — for as long as ONE request lasts. Anything that must
148
+ * outlive a request is a task (docs/07).
149
+ */
150
+ export interface PluginRouteContext {
151
+ pluginId: string;
152
+ routeName: string;
153
+ method: "GET" | "POST";
154
+ request: Request;
155
+ searchParams: URLSearchParams;
156
+ workspaceId: string;
157
+ nodeId: string;
158
+ instanceName: string;
159
+ applicationType: string;
160
+ cloudConnectionId: string | null;
161
+ /** The caller may mutate this workspace (a viewer of a public share may not). */
162
+ canWrite: boolean;
163
+ /** WHO is calling — the same resolved identity every other seam receives. */
164
+ viewer: PluginViewer;
165
+ }
166
+ export type PluginRouteHandler = (ctx: PluginRouteContext) => Promise<Response>;
36
167
  export interface PluginServerModule {
37
168
  webhooks?: Record<string, PluginWebhookHandler>;
38
169
  ops?: Record<string, PluginOpHandler>;
170
+ /** routeName → handler, for names declared in plugin.json `routes`. */
171
+ routes?: Record<string, PluginRouteHandler>;
39
172
  }
40
173
  export type PluginConnectionCredentials = {
41
174
  kind: "oauth2";
@@ -50,6 +183,28 @@ export type PluginConnectionCredentials = {
50
183
  * refreshes expiring OAuth tokens). HOST-ONLY.
51
184
  */
52
185
  export declare function getPluginConnectionCredentials(_connectionId: string, _pluginId: string): Promise<PluginConnectionCredentials>;
186
+ /**
187
+ * The client for the tables YOUR manifest declared (`db`). Hand it any server
188
+ * context — an op's, a route's, a task's — and get back a client whose reads
189
+ * are already scoped to this instance and this caller, and whose writes refuse
190
+ * what the rules refuse. You write no access checks; you cannot forget one.
191
+ *
192
+ * const db = await pluginDb<ShopDb>(ctx); // ShopDb from ./.esoul/db
193
+ * const mine = await db.order.findMany({ orderBy: { createdAt: "desc" } });
194
+ *
195
+ * `across: "owned-instances"` (the owner, across their shops) and `across:
196
+ * "my-rows"` (an account, across every instance) are read-only reaches.
197
+ * HOST-ONLY: in a unit test use `memoryDb(manifest)` from `esoul-sdk/testing`.
198
+ */
199
+ export declare function pluginDb<T = unknown>(_ctx: {
200
+ pluginId: string;
201
+ workspaceId: string;
202
+ nodeId: string;
203
+ viewer: PluginViewer;
204
+ }, _reach?: {
205
+ across?: "owned-instances" | "my-rows";
206
+ viaBinding?: boolean;
207
+ }): Promise<T>;
53
208
  export interface EmitPluginAppEventArgs {
54
209
  source: {
55
210
  pluginId: string;
@@ -187,3 +342,13 @@ export declare function filesForOp(_ctx: {
187
342
  workspaceId: string;
188
343
  nodeId?: string;
189
344
  }): Promise<FilesApi>;
345
+ /**
346
+ * A Server-Sent Events response. `run` gets `send(event, data)` and the request's
347
+ * abort signal; return when done (or when the signal fires — the client left). A
348
+ * heartbeat comment every 15 s keeps proxies from closing an idle stream. Real code,
349
+ * not host-provided: streaming is the web platform's.
350
+ */
351
+ export declare function sseStream(run: (send: (event: string, data: unknown) => void, signal: AbortSignal) => Promise<void>, opts?: {
352
+ signal?: AbortSignal;
353
+ heartbeatMs?: number;
354
+ }): Response;