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
@@ -0,0 +1,363 @@
1
+ /* ───────────────────────────── naming ─────────────────────────────────── */
2
+ /** `ShipAddress` → `ship_address`; `Order` → `order`. */
3
+ export function snakeCase(name) {
4
+ return name
5
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
6
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
7
+ .toLowerCase();
8
+ }
9
+ /** The Prisma model name: `plugin_shop_min__Order`. */
10
+ export function prismaModelName(applicationType, model) {
11
+ return `${applicationType}__${model}`;
12
+ }
13
+ /** The table it maps to: `plugin_shop_min__order`. */
14
+ export function prismaTableName(applicationType, model) {
15
+ return `${applicationType}__${snakeCase(model)}`;
16
+ }
17
+ /* ───────────────────────── the Prisma fragment ────────────────────────── */
18
+ const PRISMA_SCALAR = {
19
+ string: "String",
20
+ text: "String",
21
+ int: "Int",
22
+ float: "Float",
23
+ boolean: "Boolean",
24
+ datetime: "DateTime",
25
+ json: "Json",
26
+ ref: "String",
27
+ };
28
+ function prismaDefault(field) {
29
+ if (field.default === undefined || field.list)
30
+ return null;
31
+ switch (field.type) {
32
+ case "string":
33
+ case "text":
34
+ return `@default(${JSON.stringify(String(field.default))})`;
35
+ case "int":
36
+ case "float":
37
+ return `@default(${Number(field.default)})`;
38
+ case "boolean":
39
+ return `@default(${field.default ? "true" : "false"})`;
40
+ default:
41
+ // A default on json/datetime/ref has no Prisma spelling worth guessing;
42
+ // the client applies it on create, which is where it matters.
43
+ return null;
44
+ }
45
+ }
46
+ function platformColumns(scope) {
47
+ return [
48
+ { name: "id", type: "String", attrs: "@id @default(uuid())" },
49
+ { name: "workspaceId", type: "String", attrs: "" },
50
+ // A user-scoped row belongs to an account, not an instance; the memory
51
+ // client stores null there and the database must accept the same row.
52
+ { name: "nodeId", type: scope === "user" ? "String?" : "String", attrs: "" },
53
+ { name: "ownerId", type: "String?", attrs: "" },
54
+ { name: "createdBy", type: "Json?", attrs: "" },
55
+ { name: "createdAt", type: "DateTime", attrs: "@default(now())" },
56
+ { name: "updatedAt", type: "DateTime", attrs: "@updatedAt" },
57
+ { name: "deletedAt", type: "DateTime?", attrs: "" },
58
+ ];
59
+ }
60
+ function declaredColumn(name, field, sealed) {
61
+ const base = sealed ? "String" : PRISMA_SCALAR[field.type];
62
+ // Prisma scalar lists are never optional and carry no scalar default.
63
+ const type = field.list ? `${base}[]` : field.optional ? `${base}?` : base;
64
+ const def = sealed ? null : prismaDefault(field);
65
+ return { name, type, attrs: def ?? "" };
66
+ }
67
+ function scopeColumn(scope) {
68
+ return scope === "user" ? "ownerId" : scope === "workspace" ? "workspaceId" : "nodeId";
69
+ }
70
+ function indexGroups(model) {
71
+ const lead = scopeColumn(model.scope);
72
+ const out = [];
73
+ const seen = new Set();
74
+ const push = (fields, kind = "btree") => {
75
+ const key = `${kind}:${fields.join(",")}`;
76
+ if (seen.has(key))
77
+ return;
78
+ seen.add(key);
79
+ out.push({ fields, kind });
80
+ };
81
+ // The scope index every query starts from.
82
+ push(model.scope === "instance" ? ["workspaceId", "nodeId"] : [lead]);
83
+ if (model.ownedByCreator && model.scope !== "user")
84
+ push(["ownerId"]);
85
+ // Declared groups, `unique` first then `indexes`, each led by the scope
86
+ // column. A group that already names the lead column is not doubled.
87
+ for (const group of model.unique)
88
+ push([lead, ...group.filter((c) => c !== lead)]);
89
+ for (const idx of model.indexes) {
90
+ // A GIN index (a list or a trigram search) is single-column and NOT
91
+ // scope-led: without `btree_gin` it cannot lead with the scope column, so
92
+ // Postgres bitmap-ANDs it with the scope index instead.
93
+ if (idx.kind === "btree")
94
+ push([lead, ...idx.fields.filter((c) => c !== lead)]);
95
+ else
96
+ push([...idx.fields], idx.kind);
97
+ }
98
+ return out;
99
+ }
100
+ /**
101
+ * One `@@index(...)` argument list. A trigram index needs the operator class
102
+ * spelled out (`gin_trgm_ops`) — that is what makes `contains` an index lookup
103
+ * rather than a scan — and Prisma emits both forms as the SQL we emit below,
104
+ * which is what keeps the fragment and the DDL in step.
105
+ */
106
+ function prismaIndexArgs(g) {
107
+ // QUALIFIED. `pg_trgm` installs its operator class in `public`, and DDL that
108
+ // runs with a different search_path (the db-test harness makes a throwaway
109
+ // schema and does exactly that) cannot resolve a bare `gin_trgm_ops`:
110
+ // `operator class "gin_trgm_ops" does not exist for access method "gin"`.
111
+ // Measured 2026-09-12 against the real database.
112
+ if (g.kind === "text")
113
+ return `[${g.fields[0]}(ops: raw("public.gin_trgm_ops"))], type: Gin`;
114
+ if (g.kind === "contains")
115
+ return `[${g.fields.join(", ")}], type: Gin`;
116
+ return `[${g.fields.join(", ")}]`;
117
+ }
118
+ function renderModel(model, applicationType) {
119
+ const cols = [
120
+ ...platformColumns(model.scope),
121
+ ...Object.keys(model.fields).map((name) => declaredColumn(name, model.fields[name], model.sealed.includes(name))),
122
+ ];
123
+ const nameW = Math.max(...cols.map((c) => c.name.length));
124
+ const typeW = Math.max(...cols.map((c) => c.type.length));
125
+ const lines = cols.map((c) => {
126
+ const head = ` ${c.name.padEnd(nameW)} ${c.type.padEnd(typeW)}`;
127
+ return (c.attrs ? `${head} ${c.attrs}` : head).trimEnd();
128
+ });
129
+ const idx = indexGroups(model).map((g) => ` @@index(${prismaIndexArgs(g)})`);
130
+ return [
131
+ `model ${prismaModelName(applicationType, model.name)} {`,
132
+ ...lines,
133
+ "",
134
+ ...idx,
135
+ ` @@map(${JSON.stringify(prismaTableName(applicationType, model.name))})`,
136
+ "}",
137
+ ].join("\n");
138
+ }
139
+ /**
140
+ * The Prisma schema fragment for one app — every model, platform columns
141
+ * first, scope-led indexes, mapped to a prefixed table. No datasource and no
142
+ * generator: this is a FRAGMENT that joins the platform's schema folder.
143
+ */
144
+ export function prismaFragment(rules, opts) {
145
+ const models = Object.keys(rules.models).map((name) => rules.models[name]);
146
+ const body = models.map((m) => renderModel(m, opts.applicationType)).join("\n\n");
147
+ return [
148
+ `/// AUTO-GENERATED from plugin "${rules.pluginId}" (plugin.json \`db\`) — DO NOT EDIT.`,
149
+ `/// Regenerated by \`yarn plugins:sync\`. Tables are prefixed \`${opts.applicationType}__\`;`,
150
+ "/// every row carries the platform's scope columns; indexes lead with the scope column;",
151
+ "/// declared `unique` groups are plain indexes (uniqueness is enforced among live rows by the client).",
152
+ "",
153
+ body,
154
+ "",
155
+ ].join("\n");
156
+ }
157
+ /* ─────────────────────────── the typings ──────────────────────────────── */
158
+ const TS_SCALAR = {
159
+ string: "string",
160
+ text: "string",
161
+ int: "number",
162
+ float: "number",
163
+ boolean: "boolean",
164
+ datetime: "Date",
165
+ json: "unknown",
166
+ ref: "string",
167
+ };
168
+ function rowFieldType(field) {
169
+ const base = TS_SCALAR[field.type];
170
+ if (field.list)
171
+ return `${base}[]`;
172
+ return field.optional ? `${base} | null` : base;
173
+ }
174
+ function createFieldType(field) {
175
+ const base = TS_SCALAR[field.type];
176
+ if (field.list)
177
+ return `${base}[]`;
178
+ return field.optional ? `${base} | null` : base;
179
+ }
180
+ function quoteKey(name) {
181
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
182
+ }
183
+ function renderRows(model) {
184
+ const platform = [
185
+ " id: string;",
186
+ " workspaceId: string;",
187
+ ` nodeId: ${model.scope === "user" ? "string | null" : "string"};`,
188
+ " ownerId: string | null;",
189
+ " createdAt: Date;",
190
+ " updatedAt: Date;",
191
+ ];
192
+ const declared = Object.keys(model.fields).map((n) => ` ${quoteKey(n)}: ${rowFieldType(model.fields[n])};`);
193
+ return [`export interface ${model.name}Row {`, ...platform, ...declared, "}"].join("\n");
194
+ }
195
+ function renderCreate(model) {
196
+ const declared = Object.keys(model.fields).map((n) => {
197
+ const f = model.fields[n];
198
+ const optional = f.optional || f.default !== undefined || f.list;
199
+ return ` ${quoteKey(n)}${optional ? "?" : ""}: ${createFieldType(f)};`;
200
+ });
201
+ return [`export interface ${model.name}Create {`, ...declared, "}"].join("\n");
202
+ }
203
+ function renderFilterable(model) {
204
+ const keys = model.filterable.map((k) => JSON.stringify(k)).join(" | ");
205
+ return `export type ${model.name}Filterable = ${keys || "never"};`;
206
+ }
207
+ /**
208
+ * The `.esoul/db.d.ts` for one app: a `<Model>Row` and `<Model>Create` per
209
+ * model, a `<Model>Filterable` union that is exactly the compiled `filterable`
210
+ * list (so `where` and `orderBy` on an unindexed field are compile errors —
211
+ * the runtime rule, landed in the editor), and one `Collection` shape mirroring
212
+ * the memory client's method surface. Everything an author calls is typed with
213
+ * their own names; nothing here knows the table prefix.
214
+ */
215
+ export function dbTypings(rules, opts) {
216
+ const models = Object.keys(rules.models).map((name) => rules.models[name]);
217
+ const parts = [
218
+ `/** AUTO-GENERATED from plugin "${rules.pluginId}" (plugin.json \`db\`) — DO NOT EDIT. Regenerated by \`yarn plugins:sync\`. */`,
219
+ "",
220
+ "export type Scalar = string | number | boolean | Date | null;",
221
+ "export type Filter<V> =",
222
+ " | V",
223
+ " | { in: V[] }",
224
+ " | { notIn: V[] }",
225
+ " | { not: V }",
226
+ " | { gt: V }",
227
+ " | { gte: V }",
228
+ " | { lt: V }",
229
+ " | { lte: V }",
230
+ " | { contains: string };",
231
+ "/** A LIST field takes one question: does it hold this? (a `contains` index answers it) */",
232
+ "export type ListFilter<V> = { has: V };",
233
+ "/** Only INDEXED fields may be filtered — the generator emits the union per model. */",
234
+ "export type Where<T, K extends keyof T> = {",
235
+ " [P in K]?: T[P] extends readonly (infer E)[] ? ListFilter<E> : T[P] extends Scalar ? Filter<T[P]> : never;",
236
+ "};",
237
+ "export type OrderBy<K extends string> = { [P in K]?: \"asc\" | \"desc\" };",
238
+ "",
239
+ "export interface FindManyArgs<T, K extends keyof T & string> {",
240
+ " where?: Where<T, K>;",
241
+ " orderBy?: OrderBy<K> | OrderBy<K>[];",
242
+ " take?: number;",
243
+ " skip?: number;",
244
+ " cursor?: { id: string };",
245
+ " includeDeleted?: boolean;",
246
+ "}",
247
+ "export interface AggregateArgs<T, K extends keyof T & string> {",
248
+ " where?: Where<T, K>;",
249
+ " _count?: true;",
250
+ " _sum?: Partial<Record<keyof T & string, true>>;",
251
+ " _avg?: Partial<Record<keyof T & string, true>>;",
252
+ " _min?: Partial<Record<keyof T & string, true>>;",
253
+ " _max?: Partial<Record<keyof T & string, true>>;",
254
+ "}",
255
+ "export interface GroupByArgs<T, K extends keyof T & string> extends AggregateArgs<T, K> {",
256
+ " by: K[];",
257
+ "}",
258
+ "",
259
+ "export interface Collection<Row, Create, K extends keyof Row & string> {",
260
+ " findMany(args?: FindManyArgs<Row, K>): Promise<Row[]>;",
261
+ " findFirst(args?: FindManyArgs<Row, K>): Promise<Row | null>;",
262
+ " findUnique(args: { where: { id: string }; includeDeleted?: boolean }): Promise<Row | null>;",
263
+ " count(args?: { where?: Where<Row, K>; includeDeleted?: boolean }): Promise<number>;",
264
+ " aggregate(args?: AggregateArgs<Row, K>): Promise<Record<string, unknown>>;",
265
+ " groupBy(args: GroupByArgs<Row, K>): Promise<Record<string, unknown>[]>;",
266
+ " create(args: { data: Create }): Promise<Row>;",
267
+ " createMany(args: { data: Create[] }): Promise<{ count: number }>;",
268
+ " update(args: { where: { id: string }; data: Partial<Create> }): Promise<Row>;",
269
+ " updateMany(args: { where?: Where<Row, K>; data: Partial<Create> }): Promise<{ count: number }>;",
270
+ " upsert(args: { where: { id: string }; create: Create; update: Partial<Create> }): Promise<Row>;",
271
+ " delete(args: { where: { id: string } }): Promise<Row>;",
272
+ " deleteMany(args?: { where?: Where<Row, K> }): Promise<{ count: number }>;",
273
+ "}",
274
+ "",
275
+ ];
276
+ for (const m of models) {
277
+ parts.push(renderRows(m), renderCreate(m), renderFilterable(m), "");
278
+ }
279
+ parts.push(`export interface ${opts.typeName} {`);
280
+ for (const m of models) {
281
+ parts.push(` ${quoteKey(m.key)}: Collection<${m.name}Row, ${m.name}Create, ${m.name}Filterable>;`);
282
+ }
283
+ parts.push(` $transaction<T>(fn: (tx: ${opts.typeName}) => Promise<T>): Promise<T>;`, "}", "");
284
+ return parts.join("\n");
285
+ }
286
+ /** The compiled artefact as the bytes `.esoul/rules.json` holds. */
287
+ export function rulesJson(rules) {
288
+ return JSON.stringify(rules, null, 2) + "\n";
289
+ }
290
+ const SQL_OF_PRISMA = {
291
+ String: "TEXT",
292
+ Int: "INTEGER",
293
+ Float: "DOUBLE PRECISION",
294
+ Boolean: "BOOLEAN",
295
+ DateTime: "TIMESTAMP(3)",
296
+ Json: "JSONB",
297
+ };
298
+ function sqlColumn(c) {
299
+ const list = c.type.endsWith("[]");
300
+ const optional = c.type.endsWith("?");
301
+ const base = c.type.replace(/\[\]$|\?$/, "");
302
+ const sql = SQL_OF_PRISMA[base];
303
+ if (!sql)
304
+ throw new Error(`[schema-gen] no SQL type for Prisma ${base}`);
305
+ let def = null;
306
+ const m = /@default\((.*)\)/.exec(c.attrs);
307
+ if (m) {
308
+ const raw = m[1];
309
+ if (raw === "now()")
310
+ def = "CURRENT_TIMESTAMP";
311
+ else if (raw === "uuid()")
312
+ def = null; // the client mints ids; Prisma's DDL carries no default either
313
+ else if (/^".*"$/.test(raw))
314
+ def = `'${JSON.parse(raw).replace(/'/g, "''")}'`;
315
+ else
316
+ def = raw; // a number or true/false
317
+ }
318
+ // Prisma emits a scalar list WITHOUT `NOT NULL` (an absent list is an empty
319
+ // one, not a null), so a list column must not carry the constraint either —
320
+ // or the planner would read a difference against the live table forever.
321
+ return { name: c.name, type: list ? `${sql}[]` : sql, nullable: optional || list, default: def };
322
+ }
323
+ /** Prisma's index name: `<table>_<col>_<col>_idx`. */
324
+ export function indexName(table, columns) {
325
+ return `${table}_${columns.join("_")}_idx`;
326
+ }
327
+ /** Every table an app declares, as columns and indexes — the input to the planner and the emitter. */
328
+ export function tableSpecs(rules, opts) {
329
+ return Object.keys(rules.models).map((name) => {
330
+ const model = rules.models[name];
331
+ const table = prismaTableName(opts.applicationType, model.name);
332
+ const cols = [
333
+ ...platformColumns(model.scope),
334
+ ...Object.keys(model.fields).map((f) => declaredColumn(f, model.fields[f], model.sealed.includes(f))),
335
+ ];
336
+ return {
337
+ name: table,
338
+ columns: cols.map(sqlColumn),
339
+ indexes: indexGroups(model).map((g) => ({ name: indexName(table, g.fields), columns: g.fields, kind: g.kind })),
340
+ };
341
+ });
342
+ }
343
+ const q = (ident) => `"${ident}"`;
344
+ function columnDef(c) {
345
+ return `${q(c.name)} ${c.type}${c.nullable ? "" : " NOT NULL"}${c.default !== null ? ` DEFAULT ${c.default}` : ""}`;
346
+ }
347
+ export function createTableSql(t) {
348
+ return [`CREATE TABLE ${q(t.name)} (`, ...t.columns.map((c) => ` ${columnDef(c)},`), "", ` CONSTRAINT ${q(`${t.name}_pkey`)} PRIMARY KEY ("id")`, ");"].join("\n");
349
+ }
350
+ export function createIndexSql(table, idx) {
351
+ if (idx.kind === "text")
352
+ return `CREATE INDEX ${q(idx.name)} ON ${q(table)} USING GIN (${q(idx.columns[0])} public.gin_trgm_ops);`;
353
+ if (idx.kind === "contains")
354
+ return `CREATE INDEX ${q(idx.name)} ON ${q(table)} USING GIN (${idx.columns.map(q).join(", ")});`;
355
+ return `CREATE INDEX ${q(idx.name)} ON ${q(table)}(${idx.columns.map(q).join(", ")});`;
356
+ }
357
+ export function addColumnSql(table, c) {
358
+ return `ALTER TABLE ${q(table)} ADD COLUMN ${columnDef(c)};`;
359
+ }
360
+ /** The whole script for a fresh install: tables first, then every index — Prisma's order. */
361
+ export function ddlStatements(specs) {
362
+ return [...specs.map(createTableSql), ...specs.flatMap((t) => t.indexes.map((i) => createIndexSql(t.name, i)))];
363
+ }
package/dist/helpers.d.ts CHANGED
@@ -31,7 +31,54 @@ export declare function incompleteStateNotice(args: {
31
31
  * side rides the session. Throws the op's honest error — relay it, never
32
32
  * swallow it.
33
33
  */
34
+ /**
35
+ * The public share this page is being viewed through, or null.
36
+ *
37
+ * A customer browsing a shop is on `/share/<id>` — they have no workspace
38
+ * session at all, so the platform needs the share id to know which door they
39
+ * are standing at before it will let them reach a `public` op or route. Read
40
+ * from the URL rather than plumbed through the app, because the URL is the one
41
+ * thing that is true on every surface without the app having to care.
42
+ *
43
+ * A share reached through `/u/<handle>` or a custom domain resolves its share
44
+ * server-side and is NOT in the path. The platform tells the page which share
45
+ * it is standing on (`window.__esoulShareId`, written by the frame that
46
+ * renders the app), and that is read first. The day this was left to the app
47
+ * ("pass `shareId` yourself there") a shop published as a homepage answered
48
+ * `forbidden` to every visitor (2026-09-12): an app cannot know a thing only
49
+ * the platform resolved.
50
+ */
51
+ export declare function currentShareId(): string | null;
52
+ /**
53
+ * VIEW AS, from the browser's side. In a Forge preview the board's switcher
54
+ * writes a persona onto the window; every call the app's UI makes then carries
55
+ * it, so the server sees the same pretend person the screen shows. Absent
56
+ * outside a preview: the platform never reads it, and the header is not sent.
57
+ */
58
+ export declare function previewPersona(): string | null;
34
59
  export declare function callPluginOp<T = unknown>(pluginId: string, op: string, nodeId: string, args?: unknown): Promise<T>;
60
+ /**
61
+ * What a failed op call throws. It CARRIES the platform's refusal code, so a
62
+ * UI can tell `login-required` (raise the sign-in wall) from `forbidden` (an
63
+ * error) — `useSignInWall().raise(err)` reads exactly this. A bare `Error`
64
+ * dropped the code, and the wall never rose (found by the first S6 drive).
65
+ */
66
+ export declare class PluginCallError extends Error {
67
+ /** HTTP status of the answer. */
68
+ readonly status: number;
69
+ /** `forbidden` | `login-required` | `not-bound` | `rate-limited` | `invalid`, when the platform said which. */
70
+ readonly code?: string;
71
+ /** Where to send the browser to sign in (`login-required` only). */
72
+ readonly signInPath?: string;
73
+ /** The author's explanation — present only in a workbench box. */
74
+ readonly detail?: string;
75
+ constructor(message: string, args: {
76
+ status: number;
77
+ code?: string;
78
+ signInPath?: string;
79
+ detail?: string;
80
+ });
81
+ }
35
82
  /**
36
83
  * Constant-time string compare for webhook secrets/signatures. A plain
37
84
  * `===` leaks length/prefix timing; use THIS in every webhook handler.
@@ -58,3 +105,8 @@ export declare function kickPluginTask(args: {
58
105
  ok: boolean;
59
106
  status: number;
60
107
  }>;
108
+ /**
109
+ * The URL of one of your server routes (docs/06) for one instance — what an
110
+ * `EventSource` or `fetch` in your UI opens. Same origin; the session rides along.
111
+ */
112
+ export declare function pluginRouteUrl(pluginId: string, routeName: string, nodeId: string, params?: Record<string, string | number | boolean>): string;
package/dist/helpers.js CHANGED
@@ -57,25 +57,100 @@ export function incompleteStateNotice(args) {
57
57
  * side rides the session. Throws the op's honest error — relay it, never
58
58
  * swallow it.
59
59
  */
60
+ /**
61
+ * The public share this page is being viewed through, or null.
62
+ *
63
+ * A customer browsing a shop is on `/share/<id>` — they have no workspace
64
+ * session at all, so the platform needs the share id to know which door they
65
+ * are standing at before it will let them reach a `public` op or route. Read
66
+ * from the URL rather than plumbed through the app, because the URL is the one
67
+ * thing that is true on every surface without the app having to care.
68
+ *
69
+ * A share reached through `/u/<handle>` or a custom domain resolves its share
70
+ * server-side and is NOT in the path. The platform tells the page which share
71
+ * it is standing on (`window.__esoulShareId`, written by the frame that
72
+ * renders the app), and that is read first. The day this was left to the app
73
+ * ("pass `shareId` yourself there") a shop published as a homepage answered
74
+ * `forbidden` to every visitor (2026-09-12): an app cannot know a thing only
75
+ * the platform resolved.
76
+ */
77
+ export function currentShareId() {
78
+ if (typeof window === "undefined")
79
+ return null;
80
+ const told = window.__esoulShareId;
81
+ if (typeof told === "string" && told)
82
+ return told;
83
+ const m = /^\/share\/([^/?#]+)/.exec(window.location.pathname);
84
+ return m ? decodeURIComponent(m[1]) : null;
85
+ }
86
+ /**
87
+ * VIEW AS, from the browser's side. In a Forge preview the board's switcher
88
+ * writes a persona onto the window; every call the app's UI makes then carries
89
+ * it, so the server sees the same pretend person the screen shows. Absent
90
+ * outside a preview: the platform never reads it, and the header is not sent.
91
+ */
92
+ export function previewPersona() {
93
+ if (typeof window === "undefined")
94
+ return null;
95
+ if (!window.location.pathname.includes("/internal/plugin-preview"))
96
+ return null;
97
+ const p = window.__esoulPreviewViewer;
98
+ return typeof p === "string" && p ? p : null;
99
+ }
100
+ const PREVIEW_VIEWER_HEADER = "x-esoul-preview-viewer";
101
+ function previewViewerHeaders() {
102
+ const p = previewPersona();
103
+ return p ? { [PREVIEW_VIEWER_HEADER]: p } : {};
104
+ }
60
105
  export async function callPluginOp(pluginId, op, nodeId, args) {
61
- const serverBase = typeof window === "undefined"
62
- ? process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
63
- : "";
64
- const headers = { "Content-Type": "application/json" };
65
- if (typeof window === "undefined" && process.env.INTERNAL_TOOL_SECRET) {
106
+ const onServer = typeof window === "undefined";
107
+ const serverBase = onServer ? process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" : "";
108
+ const headers = { "Content-Type": "application/json", ...previewViewerHeaders() };
109
+ if (onServer && process.env.INTERNAL_TOOL_SECRET) {
66
110
  headers["x-esoul-internal-secret"] = process.env.INTERNAL_TOOL_SECRET;
67
111
  }
68
- const res = await fetch(`${serverBase}/api/plugins/${pluginId}/op/${op}`, {
112
+ // A Forge preview serves ops on its own lean route over the workbench store (docs/10).
113
+ const inPreview = onServer ? process.env.WORKBENCH_STORE === "1" : window.location.pathname.includes("/internal/plugin-preview");
114
+ const res = await fetch(inPreview ? `${serverBase}/internal/plugin-preview/op/${pluginId}/${op}` : `${serverBase}/api/plugins/${pluginId}/op/${op}`, {
69
115
  method: "POST",
70
116
  headers,
71
- body: JSON.stringify({ nodeId, args }),
117
+ body: JSON.stringify({ nodeId, args, ...(currentShareId() ? { shareId: currentShareId() } : {}) }),
72
118
  });
73
119
  const body = (await res.json().catch(() => null));
74
120
  if (!res.ok || !body?.ok) {
75
- throw new Error(body?.error ?? `plugin op ${pluginId}/${op} failed (HTTP ${res.status})`);
121
+ throw new PluginCallError(body?.error ?? `plugin op ${pluginId}/${op} failed (HTTP ${res.status})`, {
122
+ status: res.status,
123
+ code: body?.code,
124
+ signInPath: body?.signInPath,
125
+ detail: body?.detail,
126
+ });
76
127
  }
77
128
  return body.result;
78
129
  }
130
+ /**
131
+ * What a failed op call throws. It CARRIES the platform's refusal code, so a
132
+ * UI can tell `login-required` (raise the sign-in wall) from `forbidden` (an
133
+ * error) — `useSignInWall().raise(err)` reads exactly this. A bare `Error`
134
+ * dropped the code, and the wall never rose (found by the first S6 drive).
135
+ */
136
+ export class PluginCallError extends Error {
137
+ /** HTTP status of the answer. */
138
+ status;
139
+ /** `forbidden` | `login-required` | `not-bound` | `rate-limited` | `invalid`, when the platform said which. */
140
+ code;
141
+ /** Where to send the browser to sign in (`login-required` only). */
142
+ signInPath;
143
+ /** The author's explanation — present only in a workbench box. */
144
+ detail;
145
+ constructor(message, args) {
146
+ super(message);
147
+ this.name = "PluginCallError";
148
+ this.status = args.status;
149
+ this.code = args.code;
150
+ this.signInPath = args.signInPath;
151
+ this.detail = args.detail;
152
+ }
153
+ }
79
154
  /* ── webhook secret comparison ──────────────────────────────────────── */
80
155
  /**
81
156
  * Constant-time string compare for webhook secrets/signatures. A plain
@@ -101,10 +176,13 @@ export function timingSafeEqual(a, b) {
101
176
  export async function kickPluginTask(args) {
102
177
  const onServer = typeof window === "undefined";
103
178
  const base = onServer ? (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000") : "";
104
- const headers = { "Content-Type": "application/json" };
179
+ const headers = { "Content-Type": "application/json", ...previewViewerHeaders() };
105
180
  if (onServer && process.env.INTERNAL_TOOL_SECRET)
106
181
  headers["x-esoul-internal-secret"] = process.env.INTERNAL_TOOL_SECRET;
107
- const res = await fetch(`${base}/api/inngest/send-event`, {
182
+ // A Forge preview has no Inngest: its dev server runs the task in-process behind the same
183
+ // ctx contract, kicked through the preview's own route. Same app code, a different transport.
184
+ const inPreview = onServer ? process.env.WORKBENCH_STORE === "1" : window.location.pathname.includes("/internal/plugin-preview");
185
+ const res = await fetch(inPreview ? `${base}/internal/plugin-preview/kick` : `${base}/api/inngest/send-event`, {
108
186
  method: "POST",
109
187
  headers,
110
188
  body: JSON.stringify({
@@ -120,3 +198,21 @@ export async function kickPluginTask(args) {
120
198
  });
121
199
  return { ok: res.ok, status: res.status };
122
200
  }
201
+ /**
202
+ * The URL of one of your server routes (docs/06) for one instance — what an
203
+ * `EventSource` or `fetch` in your UI opens. Same origin; the session rides along.
204
+ */
205
+ export function pluginRouteUrl(pluginId, routeName, nodeId, params) {
206
+ const share = currentShareId();
207
+ const persona = previewPersona();
208
+ const q = new URLSearchParams({
209
+ nodeId,
210
+ ...(share ? { shareId: share } : {}),
211
+ ...(persona ? { viewer: persona } : {}),
212
+ ...Object.fromEntries(Object.entries(params ?? {}).map(([k, v]) => [k, String(v)])),
213
+ });
214
+ const inPreview = typeof window !== "undefined" && window.location.pathname.includes("/internal/plugin-preview");
215
+ return inPreview
216
+ ? `/internal/plugin-preview/route/${encodeURIComponent(pluginId)}/${encodeURIComponent(routeName)}?${q.toString()}`
217
+ : `/api/plugins/${encodeURIComponent(pluginId)}/route/${encodeURIComponent(routeName)}?${q.toString()}`;
218
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,11 @@ export * from "./types.js";
2
2
  export * from "./manifest.js";
3
3
  export * from "./helpers.js";
4
4
  export * from "./files.js";
5
+ export * from "./bindings.js";
6
+ /** WHO hears a realtime message (S8): topic audiences, and who may aim one. */
7
+ export * from "./audience.js";
8
+ /** The app's own words for the platform's kinds of caller (S3). */
9
+ export * from "./roles.js";
5
10
  /** Id minting for dataCreators; an app depends on the SDK, not on nanoid. */
6
11
  export { nanoid } from "nanoid";
7
12
  /**
package/dist/index.js CHANGED
@@ -2,6 +2,11 @@ export * from "./types.js";
2
2
  export * from "./manifest.js";
3
3
  export * from "./helpers.js";
4
4
  export * from "./files.js";
5
+ export * from "./bindings.js";
6
+ /** WHO hears a realtime message (S8): topic audiences, and who may aim one. */
7
+ export * from "./audience.js";
8
+ /** The app's own words for the platform's kinds of caller (S3). */
9
+ export * from "./roles.js";
5
10
  /** Id minting for dataCreators; an app depends on the SDK, not on nanoid. */
6
11
  export { nanoid } from "nanoid";
7
12
  /**