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 +9 -0
- package/dist/server.d.ts +91 -0
- package/dist/server.js +50 -0
- package/docs/05-ui.md +28 -0
- package/docs/13-people-and-access.md +44 -0
- package/llms-full.txt +81 -0
- package/package.json +1 -1
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.
|
|
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",
|