esoul-sdk 0.7.0 → 0.8.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 CHANGED
@@ -160,6 +160,15 @@ something: does the write survive?
160
160
 
161
161
  ## Versions
162
162
 
163
+ - **0.8.0** — the three things making an app LOOK like a product turned out to
164
+ need: `generateAppImage` (one call — generates, brings the bytes to web
165
+ weight, hands back an https URL; a 2.4 MB PNG is not a page), `setAppRole` /
166
+ `listAppRoles` (an owner gives one esoul account one of your role words, by
167
+ email; the platform does every check and records it on the app's timeline),
168
+ and `wall.ask()` — attempt a read and let a `login-required` refusal answer,
169
+ because `viewer.signedIn` is this page's guess and can disagree with the
170
+ session your ops run under. An app that gated on the guess showed a
171
+ signed-in shopper a sign-in wall over a full basket.
163
172
  - **0.7.0** — what a catalogue-sized app needs. **Index kinds**: a plain group is a btree,
164
173
  `{ fields: ["tags"], kind: "contains" }` answers `{ tags: { has: … } }` on a list, and
165
174
  `{ fields: ["title"], kind: "text" }` answers `{ title: { contains: … } }` — so a department
package/dist/server.d.ts CHANGED
@@ -222,6 +222,97 @@ export declare function pluginDb<T = unknown>(_ctx: {
222
222
  across?: "owned-instances" | "my-rows";
223
223
  viaBinding?: boolean;
224
224
  }): Promise<T>;
225
+ /**
226
+ * A PICTURE FOR YOUR APP, FROM ONE CALL.
227
+ *
228
+ * Generates an image, brings it to web weight and returns an **https URL** to
229
+ * store on your own row and render. You do not choose a model, resize
230
+ * anything, or know where the bytes live.
231
+ *
232
+ * const pic = await generateAppImage(ctx, {
233
+ * name: product.sku ?? product.id,
234
+ * prompt: "A small dish of loose-leaf black tea beside a glass cup of amber tea.",
235
+ * // the SAME style for every picture in the app, so a shelf looks
236
+ * // photographed on one table instead of bought from a library:
237
+ * style: "On warm oatmeal linen, soft window light from the left, shallow depth of field.",
238
+ * shape: "square",
239
+ * });
240
+ * if (pic.ok) await db.product.update({ where: { id }, data: { imageUrl: pic.url } });
241
+ *
242
+ * A refusal comes back as `{ ok: false, error }` naming every provider that
243
+ * said no — an account denied by one and unkeyed for another are different
244
+ * problems. Flat, so an app compiled with `strict: false` can read it.
245
+ *
246
+ * HOST-ONLY. It spends money; keep it behind an op only the owner may call.
247
+ */
248
+ export declare function generateAppImage(_ctx: {
249
+ pluginId: string;
250
+ nodeId: string;
251
+ }, _args: {
252
+ name: string;
253
+ prompt: string;
254
+ style?: string;
255
+ shape?: "square" | "wide" | "tall";
256
+ }): Promise<GeneratedAppImage>;
257
+ export interface GeneratedAppImage {
258
+ ok: boolean;
259
+ /** Store this on your row and put it in an `img src`. */
260
+ url?: string;
261
+ bytes?: number;
262
+ model?: string;
263
+ error?: string;
264
+ }
265
+ /**
266
+ * WHO ELSE MAY USE THIS INSTANCE, AND AS WHAT.
267
+ *
268
+ * Your app declares its own role words (`roles.vocabulary`), and the owner can
269
+ * hand one to another esoul account by EMAIL. The platform does all of it: it
270
+ * checks the caller owns the app, resolves the email against a real account
271
+ * once, refuses a role your manifest never declared, and records the grant on
272
+ * this instance's own timeline — so your op is a pass-through and your app
273
+ * cannot forge a grant.
274
+ *
275
+ * `role: ""` takes it back. The next time that person opens the app,
276
+ * `ctx.viewer.role` is the word you gave them, and your `db` rules decide the
277
+ * rest. It does NOT make them a writer of the workspace: a grant changes your
278
+ * app's word for someone, never the platform's capabilities.
279
+ *
280
+ * HOST-ONLY.
281
+ */
282
+ export declare function setAppRole(_ctx: {
283
+ pluginId: string;
284
+ workspaceId: string;
285
+ nodeId: string;
286
+ viewer: PluginViewer;
287
+ }, _args: {
288
+ email: string;
289
+ role: string;
290
+ }): Promise<AppRoleResult>;
291
+ /** Everyone the owner has given a role in THIS instance. HOST-ONLY. */
292
+ export declare function listAppRoles(_ctx: {
293
+ pluginId: string;
294
+ workspaceId: string;
295
+ nodeId: string;
296
+ viewer: PluginViewer;
297
+ }): Promise<{
298
+ people: AppRolePerson[];
299
+ roles: string[];
300
+ }>;
301
+ export interface AppRolePerson {
302
+ userId: string;
303
+ /** As the owner typed it, for the screen. Never what is checked. */
304
+ email: string | null;
305
+ role: string;
306
+ grantedAt?: number;
307
+ }
308
+ /** Flat, not a discriminated union: an app compiled with `strict: false` cannot narrow one. */
309
+ export interface AppRoleResult {
310
+ ok: boolean;
311
+ userId?: string;
312
+ email?: string;
313
+ role?: string;
314
+ error?: string;
315
+ }
225
316
  export interface EmitPluginAppEventArgs {
226
317
  source: {
227
318
  pluginId: string;
package/dist/server.js CHANGED
@@ -46,6 +46,56 @@ export function getPluginConnectionCredentials(_connectionId, _pluginId) {
46
46
  export function pluginDb(_ctx, _reach) {
47
47
  return hostOnly("pluginDb");
48
48
  }
49
+ /**
50
+ * A PICTURE FOR YOUR APP, FROM ONE CALL.
51
+ *
52
+ * Generates an image, brings it to web weight and returns an **https URL** to
53
+ * store on your own row and render. You do not choose a model, resize
54
+ * anything, or know where the bytes live.
55
+ *
56
+ * const pic = await generateAppImage(ctx, {
57
+ * name: product.sku ?? product.id,
58
+ * prompt: "A small dish of loose-leaf black tea beside a glass cup of amber tea.",
59
+ * // the SAME style for every picture in the app, so a shelf looks
60
+ * // photographed on one table instead of bought from a library:
61
+ * style: "On warm oatmeal linen, soft window light from the left, shallow depth of field.",
62
+ * shape: "square",
63
+ * });
64
+ * if (pic.ok) await db.product.update({ where: { id }, data: { imageUrl: pic.url } });
65
+ *
66
+ * A refusal comes back as `{ ok: false, error }` naming every provider that
67
+ * said no — an account denied by one and unkeyed for another are different
68
+ * problems. Flat, so an app compiled with `strict: false` can read it.
69
+ *
70
+ * HOST-ONLY. It spends money; keep it behind an op only the owner may call.
71
+ */
72
+ export function generateAppImage(_ctx, _args) {
73
+ return hostOnly("generateAppImage");
74
+ }
75
+ /**
76
+ * WHO ELSE MAY USE THIS INSTANCE, AND AS WHAT.
77
+ *
78
+ * Your app declares its own role words (`roles.vocabulary`), and the owner can
79
+ * hand one to another esoul account by EMAIL. The platform does all of it: it
80
+ * checks the caller owns the app, resolves the email against a real account
81
+ * once, refuses a role your manifest never declared, and records the grant on
82
+ * this instance's own timeline — so your op is a pass-through and your app
83
+ * cannot forge a grant.
84
+ *
85
+ * `role: ""` takes it back. The next time that person opens the app,
86
+ * `ctx.viewer.role` is the word you gave them, and your `db` rules decide the
87
+ * rest. It does NOT make them a writer of the workspace: a grant changes your
88
+ * app's word for someone, never the platform's capabilities.
89
+ *
90
+ * HOST-ONLY.
91
+ */
92
+ export function setAppRole(_ctx, _args) {
93
+ return hostOnly("setAppRole");
94
+ }
95
+ /** Everyone the owner has given a role in THIS instance. HOST-ONLY. */
96
+ export function listAppRoles(_ctx) {
97
+ return hostOnly("listAppRoles");
98
+ }
49
99
  /**
50
100
  * Cross-app events: dispatch the TARGET app's own events through the
51
101
  * platform spine (target's dataCreator mints; triggers fire; every event is
package/docs/05-ui.md CHANGED
@@ -114,3 +114,31 @@ installed, only the grants in `plugin.json` `workspaceTools` are allowed. Declar
114
114
  The platform is built as craft: a sticky-notes wall is linen and paper with a gummed strip, not a
115
115
  grid of yellow rectangles. Derive any randomness from stable ids, never `Math.random()` in
116
116
  render, so nothing twitches between renders. Motion is brief and respects reduced-motion.
117
+
118
+ ## Pictures
119
+
120
+ `generateAppImage` is one call: it generates, brings the bytes to web weight and
121
+ returns an **https URL** to store on your own row.
122
+
123
+ ```ts
124
+ const pic = await generateAppImage(ctx, {
125
+ name: product.sku ?? product.id,
126
+ prompt: "A small dish of loose-leaf black tea beside a glass cup of amber tea.",
127
+ // THE SAME style for every picture in the app. This is what makes a shelf
128
+ // look photographed on one table instead of bought from a library.
129
+ style: "On warm oatmeal linen, soft window light from the left, shallow depth of field.",
130
+ shape: "square", // or "wide" for a hero, "tall"
131
+ });
132
+ if (pic.ok) await db.product.update({ where: { id }, data: { imageUrl: pic.url } });
133
+ ```
134
+
135
+ It costs real money, so keep it behind an op only the owner may call. A
136
+ workbench refuses it on purpose — a preview that billed on every hot reload
137
+ would be a bad surprise, and one that returned a fake URL a worse one.
138
+
139
+ **Draw the empty case first.** A generated picture is the second version of a
140
+ screen; the first is the one with no picture at all, which is what every app
141
+ looks like on the day it is installed. An `aspect-square` box with a small
142
+ inline SVG pattern and the thing's icon reads as "not photographed yet"; a grey
143
+ rectangle reads as broken. Fix the aspect ratio in both branches and nothing
144
+ moves when a photograph arrives.
@@ -150,3 +150,47 @@ record" but "does the OTHER one see it".
150
150
 
151
151
  `look_at_app` takes the same personas, so you can photograph the screen each of them gets — the
152
152
  one screen an author can otherwise never see, because the author is always the owner.
153
+
154
+ ## Who has an account: ASK, never assume
155
+
156
+ `viewer.signedIn` is the platform's guess from THIS PAGE's capabilities. On a
157
+ shared link it can disagree with the session your ops actually run under — so
158
+ an app that gates its reads on it shows a signed-in person an empty basket and
159
+ a sign-in wall while the server is holding their order. That is a real bug, seen
160
+ on a real shop.
161
+
162
+ So never gate a read. Attempt it, and let the refusal answer:
163
+
164
+ ```tsx
165
+ const wall = useSignInWall();
166
+
167
+ const load = useCallback(() => {
168
+ // `ask` runs the call whatever this page believes, and hands back `null`
169
+ // when the SERVER says there is no account — which also raises the wall.
170
+ wall.ask<Cart>(() => callPluginOp(PLUGIN_ID, "view-cart", nodeId)).then((c) => c && setCart(c));
171
+ }, [wall, nodeId]);
172
+ ```
173
+
174
+ `wall.serverSays` is `null` until the server answers, then `"account"` or
175
+ `"no-account"`. Use `viewer.signedIn` for WORDING if you like (it saves a
176
+ flicker) and `serverSays` for anything that hides a screen. And if you hold
177
+ data the server already gave you — lines in a basket — show it: a wall over a
178
+ full basket is never right.
179
+
180
+ ## Giving one person access to one app
181
+
182
+ An owner can hand another esoul account one of YOUR role words, by email:
183
+
184
+ ```ts
185
+ const r = await setAppRole(ctx, { email: "helper@example.com", role: "staff" });
186
+ if (!r.ok) throw new Error(r.error); // "no esoul account has that email"
187
+ const { people, roles } = await listAppRoles(ctx);
188
+ ```
189
+
190
+ The platform does the deciding: only the app's owner may call it, the email is
191
+ resolved once against a real (non-guest) account, and the role must be one your
192
+ manifest declares. The grant lands on this instance's own timeline, so it can be
193
+ read back, explained and scrubbed. Next time that person opens the app,
194
+ `ctx.viewer.role` is the word you gave them and your `db` rules do the rest —
195
+ it does not make them a writer of the workspace, and it can never demote the
196
+ owner.
package/llms-full.txt CHANGED
@@ -164,6 +164,15 @@ something: does the write survive?
164
164
 
165
165
  ## Versions
166
166
 
167
+ - **0.8.0** — the three things making an app LOOK like a product turned out to
168
+ need: `generateAppImage` (one call — generates, brings the bytes to web
169
+ weight, hands back an https URL; a 2.4 MB PNG is not a page), `setAppRole` /
170
+ `listAppRoles` (an owner gives one esoul account one of your role words, by
171
+ email; the platform does every check and records it on the app's timeline),
172
+ and `wall.ask()` — attempt a read and let a `login-required` refusal answer,
173
+ because `viewer.signedIn` is this page's guess and can disagree with the
174
+ session your ops run under. An app that gated on the guess showed a
175
+ signed-in shopper a sign-in wall over a full basket.
167
176
  - **0.7.0** — what a catalogue-sized app needs. **Index kinds**: a plain group is a btree,
168
177
  `{ fields: ["tags"], kind: "contains" }` answers `{ tags: { has: … } }` on a list, and
169
178
  `{ fields: ["title"], kind: "text" }` answers `{ title: { contains: … } }` — so a department
@@ -668,6 +677,34 @@ The platform is built as craft: a sticky-notes wall is linen and paper with a gu
668
677
  grid of yellow rectangles. Derive any randomness from stable ids, never `Math.random()` in
669
678
  render, so nothing twitches between renders. Motion is brief and respects reduced-motion.
670
679
 
680
+ ## Pictures
681
+
682
+ `generateAppImage` is one call: it generates, brings the bytes to web weight and
683
+ returns an **https URL** to store on your own row.
684
+
685
+ ```ts
686
+ const pic = await generateAppImage(ctx, {
687
+ name: product.sku ?? product.id,
688
+ prompt: "A small dish of loose-leaf black tea beside a glass cup of amber tea.",
689
+ // THE SAME style for every picture in the app. This is what makes a shelf
690
+ // look photographed on one table instead of bought from a library.
691
+ style: "On warm oatmeal linen, soft window light from the left, shallow depth of field.",
692
+ shape: "square", // or "wide" for a hero, "tall"
693
+ });
694
+ if (pic.ok) await db.product.update({ where: { id }, data: { imageUrl: pic.url } });
695
+ ```
696
+
697
+ It costs real money, so keep it behind an op only the owner may call. A
698
+ workbench refuses it on purpose — a preview that billed on every hot reload
699
+ would be a bad surprise, and one that returned a fake URL a worse one.
700
+
701
+ **Draw the empty case first.** A generated picture is the second version of a
702
+ screen; the first is the one with no picture at all, which is what every app
703
+ looks like on the day it is installed. An `aspect-square` box with a small
704
+ inline SVG pattern and the thing's icon reads as "not photographed yet"; a grey
705
+ rectangle reads as broken. Fix the aspect ratio in both branches and nothing
706
+ moves when a photograph arrives.
707
+
671
708
 
672
709
 
673
710
  ==============================================================================
@@ -1367,6 +1404,50 @@ record" but "does the OTHER one see it".
1367
1404
  `look_at_app` takes the same personas, so you can photograph the screen each of them gets — the
1368
1405
  one screen an author can otherwise never see, because the author is always the owner.
1369
1406
 
1407
+ ## Who has an account: ASK, never assume
1408
+
1409
+ `viewer.signedIn` is the platform's guess from THIS PAGE's capabilities. On a
1410
+ shared link it can disagree with the session your ops actually run under — so
1411
+ an app that gates its reads on it shows a signed-in person an empty basket and
1412
+ a sign-in wall while the server is holding their order. That is a real bug, seen
1413
+ on a real shop.
1414
+
1415
+ So never gate a read. Attempt it, and let the refusal answer:
1416
+
1417
+ ```tsx
1418
+ const wall = useSignInWall();
1419
+
1420
+ const load = useCallback(() => {
1421
+ // `ask` runs the call whatever this page believes, and hands back `null`
1422
+ // when the SERVER says there is no account — which also raises the wall.
1423
+ wall.ask<Cart>(() => callPluginOp(PLUGIN_ID, "view-cart", nodeId)).then((c) => c && setCart(c));
1424
+ }, [wall, nodeId]);
1425
+ ```
1426
+
1427
+ `wall.serverSays` is `null` until the server answers, then `"account"` or
1428
+ `"no-account"`. Use `viewer.signedIn` for WORDING if you like (it saves a
1429
+ flicker) and `serverSays` for anything that hides a screen. And if you hold
1430
+ data the server already gave you — lines in a basket — show it: a wall over a
1431
+ full basket is never right.
1432
+
1433
+ ## Giving one person access to one app
1434
+
1435
+ An owner can hand another esoul account one of YOUR role words, by email:
1436
+
1437
+ ```ts
1438
+ const r = await setAppRole(ctx, { email: "helper@example.com", role: "staff" });
1439
+ if (!r.ok) throw new Error(r.error); // "no esoul account has that email"
1440
+ const { people, roles } = await listAppRoles(ctx);
1441
+ ```
1442
+
1443
+ The platform does the deciding: only the app's owner may call it, the email is
1444
+ resolved once against a real (non-guest) account, and the role must be one your
1445
+ manifest declares. The grant lands on this instance's own timeline, so it can be
1446
+ read back, explained and scrubbed. Next time that person opens the app,
1447
+ `ctx.viewer.role` is the word you gave them and your `db` rules do the rest —
1448
+ it does not make them a writer of the workspace, and it can never demote the
1449
+ owner.
1450
+
1370
1451
 
1371
1452
 
1372
1453
  ==============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esoul-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Build a full product on ExternalSoul: your own tables with per-person rules, a viewer on every seam, app roles, access levels, realtime with audiences, durable tasks, and bindings to other apps.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",