@quo-systems/dock 0.2.2 → 0.2.4

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 (68) hide show
  1. package/beings/avatar.ts +8 -1
  2. package/beings/carry.ts +1 -1
  3. package/beings/quo-dock.md +53 -39
  4. package/beings/setup.ts +2 -1
  5. package/beings/user.ts +48 -1
  6. package/cli/daemon.ts +51 -25
  7. package/cli/estate/quo.service +0 -1
  8. package/dist/beings/avatar.js +9 -2
  9. package/dist/beings/carry.js +2 -2
  10. package/dist/beings/setup.js +2 -1
  11. package/dist/beings/user.d.ts +89 -0
  12. package/dist/beings/user.js +47 -1
  13. package/dist/cli/daemon.d.ts +10 -2
  14. package/dist/cli/daemon.js +51 -23
  15. package/dist/cli/estate/quo.service +0 -1
  16. package/dist/harbor/capacitor.d.ts +16 -0
  17. package/dist/harbor/capacitor.js +135 -0
  18. package/dist/harbor/disk.js +8 -3
  19. package/dist/harbor/edge/edge.js +2 -1
  20. package/dist/harbor/edge/exercise.js +2 -1
  21. package/dist/harbor/edge/storage.d.ts +0 -3
  22. package/dist/harbor/edge/storage.js +6 -25
  23. package/dist/harbor/files.d.ts +2 -1
  24. package/dist/harbor/files.js +87 -22
  25. package/dist/harbor/seal.d.ts +3 -0
  26. package/dist/harbor/seal.js +25 -0
  27. package/dist/human/door.d.ts +2 -0
  28. package/dist/human/door.js +19 -7
  29. package/dist/human/html.d.ts +2 -0
  30. package/dist/human/html.js +43 -17
  31. package/dist/human/local.d.ts +10 -0
  32. package/dist/human/local.js +10 -0
  33. package/dist/human/screen.js +26 -8
  34. package/dist/human/tab.d.ts +1 -0
  35. package/dist/human/tab.js +59 -3
  36. package/dist/human/tree.d.ts +40 -0
  37. package/dist/human/tree.js +89 -0
  38. package/dist/human/web.d.ts +1 -0
  39. package/dist/human/web.js +36 -8
  40. package/dist/mcp/oauth.js +2 -2
  41. package/dist/mcp/route.js +1 -1
  42. package/dist/mcp/runner.js +5 -2
  43. package/dist/mcp/server.js +1 -1
  44. package/dist/mcp/web/exchange.d.ts +5 -9
  45. package/dist/mcp/web/exchange.js +47 -98
  46. package/harbor/capacitor.ts +142 -0
  47. package/harbor/disk.ts +8 -3
  48. package/harbor/edge/edge.ts +2 -1
  49. package/harbor/edge/exercise.ts +2 -1
  50. package/harbor/edge/storage.ts +6 -25
  51. package/harbor/files.ts +79 -19
  52. package/harbor/quo-harbor.md +58 -15
  53. package/harbor/seal.ts +26 -0
  54. package/human/door.ts +20 -7
  55. package/human/html.ts +41 -17
  56. package/human/local.ts +26 -0
  57. package/human/quo-human.md +114 -19
  58. package/human/screen.ts +21 -7
  59. package/human/tab.ts +50 -5
  60. package/human/tree.ts +129 -0
  61. package/human/web.ts +36 -9
  62. package/mcp/oauth.ts +2 -2
  63. package/mcp/quo-mcp.md +27 -20
  64. package/mcp/route.ts +1 -1
  65. package/mcp/runner.ts +9 -6
  66. package/mcp/server.ts +1 -1
  67. package/mcp/web/exchange.ts +62 -96
  68. package/package.json +13 -2
@@ -19,10 +19,89 @@ gets a screen it did not describe.
19
19
  Three sides, one describe. The CLI, the model side and the screen are three
20
20
  views of the same beings, and the screen adds no vocabulary of its own:
21
21
  what a being can be asked is her blueprint, how she wants to be shown is
22
- her `look`, and both are values that travel through the relation. A world
23
- that wants a page of its own writes any HTML, CSS and JavaScript it likes on
24
- its own origin, against the avatar's handles and nothing else; a world that
25
- writes none gets this screen.
22
+ her `look`, and both are values that travel through the relation. A being
23
+ who wants more than forms answers `page`, her page as a tree of values in
24
+ one closed grammar, and the screen paints it; a being who answers nothing
25
+ gets the forms. A world that wants a page of its own writes any HTML, CSS
26
+ and JavaScript it likes on its own origin, against the avatar's handles and
27
+ nothing else; a world that writes none gets this screen.
28
+
29
+ ## The page
30
+
31
+ The front end is a ward, and the first move towards it: a page is a tree of
32
+ values in a closed grammar the dock owns, `tree.ts`, and a far being
33
+ answers it from `page` the way she answers `look`, values only. One
34
+ grammar, two sources: the same tree will be computed in the tab by a being
35
+ running there, from her cells, and painted by the same painter.
36
+
37
+ - **The grammar.** `stack` and `row` hold children, one under another or
38
+ side by side; `text` is words with a role, `title`, `lead`, `body`,
39
+ `quiet` or `label`; `image` is a picture as a data URI, never a URL;
40
+ `form` is one of her asks by name, painted as the form the schema gives;
41
+ `answer` is one of her asks' last answer, shown as `auto`, `text`,
42
+ `table`, `cards` or `list`; `standing` is one standing she carries,
43
+ painted as its section; `standings` is every one of them. No selector, no
44
+ URL, no stylesheet, no script, and no node beyond these: a node that fails
45
+ its shape is dropped, not fixed, and a container with nothing left is kept
46
+ empty, since an empty region is a design and not a fault.
47
+ - **A page adds no right.** Every name a tree uses, an ask or a standing,
48
+ is looked up in her describe for this asker; a name that is not there
49
+ paints as nothing. Her page says what is shown and where, and her gate
50
+ says what may be asked; a device that may not wake sees no `push` form
51
+ however her page names it, and an ask her describe holds but her page
52
+ does not name has no form. `hello` and `report` are wiring, a device's
53
+ first word and an agent's callback, and stay under the gate and off her
54
+ page.
55
+ - **What opens with the page.** The answers a tree shows are run on open
56
+ when the ask needs nothing typed, beside the read-only asks her look
57
+ marks, so the page opens with what it shows.
58
+ - **The page is presentation.** `page` is asked once per digest, like
59
+ `look`; it is never a form, never a tool, and a carrier never carries it.
60
+ The user being answers one: her name as the title, who this device is as
61
+ an answer, her standings as sections, and the two things a human does
62
+ here by hand, waking another device and calling her by a name, as forms.
63
+ A being does not know her own key, so her name is given: by the desk's
64
+ first hello at the root's setup, or by a device the root minted, through
65
+ her `name` ask, the trunk's rule.
66
+ - **The door is the first page in the grammar**, painted with nobody behind
67
+ it: no ask, no answer, no standing, only the mark, a word and a sentence.
68
+
69
+ ## The tab ward
70
+
71
+ A world's code runs in the tab, as beings. The second source of the one
72
+ grammar: a being booted in the tab's local ward for a world answers `page`
73
+ from her cells, and the same side paints her with the same painter. No
74
+ second verb, no framework, no script of the world's on the page: only
75
+ classes, and the ward runs them.
76
+
77
+ - **The world's code is a module the harbor folder holds**, `tab/index.ts`,
78
+ the twin of `classes/index.ts`: the classes meant for the tab, exported
79
+ by name. The web route bundles it as `beings.js` on the world's origin
80
+ and tells the page it exists; a folder with none has no such path. Code
81
+ crosses the origin, never a relation, and one origin is one world's code.
82
+ - **The boot at join.** The tab loads the world's classes before its harbor
83
+ boots, since a being kept from the last visit is booted by her class
84
+ name. When a relation goes on screen, the tab boots one being of each
85
+ class under that relation's key, `r1-Notes`, the first time, and finds
86
+ her there after: her cells are in the ward, kept in the tab's store after
87
+ every ask and migrating with the ward. Two relations in one world are two
88
+ of each, since what she keeps is one person's.
89
+ - **A subject with no standing.** The side holds her object and speaks to
90
+ her door in-process under the tab's own asker, `local.ts`, the way it
91
+ holds an avatar: no relation, no key rotation, since the local ward is
92
+ the human's and she is on it. Her describe is what she shows the tab, her
93
+ forms are her asks, and a push never reaches her, since nothing outside
94
+ the tab holds her.
95
+ - **Her page follows every ask.** `page` is asked again after each call
96
+ and the answers it shows are run again with it, so a cell moved is a page
97
+ moved whether or not her describe moved. The describe, the look and the
98
+ read-only asks off the page stay once per digest. A far being pays the
99
+ same: her page is never more than one ask behind her.
100
+ - **One section each.** Under the far being's page, each being of the world
101
+ has her own section and her own side; the shell's own heading is dropped
102
+ wherever a page carries its title. Slots that bind her page to a
103
+ standing she holds, and the shadow root that isolates a far page, are
104
+ the next part of the same design.
26
105
 
27
106
  ## How a screen is made
28
107
 
@@ -47,12 +126,13 @@ Read with the trunk's "Carrying" and "The look", which this applies.
47
126
  and acme sees the human, never the device. The same section appears on
48
127
  acme's own web, under the same look: one component, two relations.
49
128
  - **The page follows the digest.** The side asks the describe again after
50
- every call and asks `look` once per digest; a digest that moved is a page
51
- that moved. A read-only ask with nothing to type is run on open, so a page
52
- can open with what it shows and not only with buttons. Every one of those
53
- asks rotates her keys, so the side saves after each, not only after a
54
- form: a relation brought back from a reload with a stale count is refused
55
- at the far door and hears silence.
129
+ every call, asks `look` once per digest and `page` after every call; a
130
+ digest that moved is a page that moved, and so is a cell that moved
131
+ behind a page. A read-only ask with nothing to type is run on open,
132
+ so a page can open with what it shows and not only with buttons. Every one
133
+ of those asks rotates her keys, so the side saves after each, not only
134
+ after a form: a relation brought back from a reload with a stale count is
135
+ refused at the far door and hears silence.
56
136
  - **What a look may carry, and never.** The trunk's table is the whole
57
137
  list: a name, a logo as a data URI, three colours, a font, a radius, an
58
138
  order, a hint per ask. No CSS, no URL, no script, ever. A token that fails
@@ -104,18 +184,29 @@ Read with the trunk's "Carrying" and "The look", which this applies.
104
184
 
105
185
  ## The pieces
106
186
 
107
- Eight files under `packages/dock/human/`:
187
+ Ten files under `packages/dock/human/`:
188
+
189
+ - `local.ts` is a being of the world in the tab's own ward as a subject:
190
+ her door spoken to in-process under the tab's asker, no standing.
108
191
 
192
+ - `tree.ts` is the page grammar and its painter, pure: `sanitiseTree`
193
+ holds a value to the grammar or drops it, `answersIn` names what a tree
194
+ shows the answers of, `paint` writes a tree as HTML through a painter,
195
+ and `nobody` is the painter with nothing behind it.
109
196
  - `html.ts` is pure, strings in and strings out, and is the screen's whole
110
197
  vocabulary: `form`, `values`, `view`, `face`, `look`, `page`. It reads the
111
- look's vocabulary from `beings/look.ts` and adds none.
198
+ look's vocabulary from `beings/look.ts` and the grammar from `tree.ts`
199
+ and adds none; its painter looks every name a tree uses up in her
200
+ describe.
112
201
  - `screen.ts` is the side: one avatar and one surface, where a surface can
113
202
  only show a page and hand back a submitted form. It keeps the model,
114
203
  speaks it as a page after every change, calls her when a form comes back,
115
- re-asks her describe after every call, asks her look once per digest, and
116
- runs her read-only asks on open. A push is appended and shown as it
117
- lands. A describe that fails leaves the last page standing and says so in
118
- the notice.
204
+ re-asks her describe after every call, asks her look once per digest and
205
+ her page after every call, and runs her read-only asks once per digest
206
+ and the ones her page shows the answers of after every call. A push is
207
+ appended and shown as it lands. A
208
+ describe that fails leaves the last page standing and says so in the
209
+ notice.
119
210
  - `dom.ts` is the surface on an element, the one file that touches one.
120
211
  - `guest.ts` is a world's public being as a subject the screen renders:
121
212
  her describe for a stranger, a form as a knock on the public invitation.
@@ -123,7 +214,8 @@ Eight files under `packages/dock/human/`:
123
214
  sight: the local ward for a far pk, the relations in it, and a fresh
124
215
  avatar for the next way in.
125
216
  - `door.ts` is the door page, pure: what a stranger sees where nothing
126
- lets them in, Quo's page and not the estate's, asking for nothing.
217
+ lets them in, Quo's page and not the estate's, asking for nothing,
218
+ written as a tree and painted with nobody behind it.
127
219
  - `tab.ts` is the shell: the harbor in the tab, the config, the two
128
220
  switchers and the two ways in, and hands one avatar at a time to the
129
221
  side.
@@ -134,7 +226,10 @@ Eight files under `packages/dock/human/`:
134
226
  it does not take fall through to the exchange pages.
135
227
 
136
228
  Proven in `packages/dock/test/human.test.ts` on the memory harbor with a fake
137
- surface and no browser: the user being carrying a shop with a look, a guest
229
+ surface and no browser: the grammar held and painted, the user being's page
230
+ with what her gate hides painted as nothing, a being of the world in the
231
+ tab's ward paged from her cells and kept with them, the user being
232
+ carrying a shop with a look, a guest
138
233
  at a door that is not a desk let in by a form, the link read, stripped and
139
234
  refused, and the worlds and relations on a harbor core over the memory
140
235
  store, two invitations into one world being two avatars; and in
@@ -148,7 +243,7 @@ door, and a link at the root.
148
243
  ## Security, by structure
149
244
 
150
245
  - Nothing executable crosses. What a screen receives is I-JSON, by the
151
- spec: asks, args, answers, the three words, and a look. Every string is
246
+ spec: asks, args, answers, the three words, a look and a page. Every string is
152
247
  escaped before it is written into the page; a JSON box is parsed and never
153
248
  evaluated.
154
249
  - A look is a closed vocabulary held to shapes. A logo is a data URI, so no
package/human/screen.ts CHANGED
@@ -12,6 +12,7 @@ import { word, type Serving, type Subject } from '../beings/side.ts';
12
12
  import { isInvitation } from '../beings/link.ts';
13
13
  import type { Invitation } from '@quo-systems/quo';
14
14
  import { page, values, hintFor, type Model, type Raw } from './html.ts';
15
+ import { sanitiseTree, answersIn } from './tree.ts';
15
16
  import { sanitise } from '../beings/look.ts';
16
17
  import { isSilence, isWord } from '@quo-systems/quo';
17
18
  import type { Json } from '@quo-systems/quo';
@@ -35,7 +36,7 @@ export type Options = { after?: () => Promise<void>; notice?: string; admit?: (i
35
36
  export async function screenSide(avatar: Subject, surface: Surface, options: Options = {}): Promise<Serving & { model: Model; refresh(): Promise<void> }> {
36
37
  const after = options.after ?? (async () => {});
37
38
  const notice = options.notice ?? '';
38
- const model: Model = { blueprint: null, look: {}, notice, answers: {}, pushes: [] };
39
+ const model: Model = { blueprint: null, look: {}, tree: null, notice, answers: {}, pushes: [] };
39
40
  let seen: string | null = null;
40
41
  const show = () => surface.show(page(model));
41
42
 
@@ -48,7 +49,8 @@ export async function screenSide(avatar: Subject, surface: Surface, options: Opt
48
49
  model.notice = `not in: ${bp.error}`;
49
50
  } else {
50
51
  const d = await digest(bp as Blueprint);
51
- if (d !== seen) {
52
+ const moved = d !== seen;
53
+ if (moved) {
52
54
  seen = d;
53
55
  model.blueprint = bp as Blueprint;
54
56
  for (const k of Object.keys(model.answers)) if (!(bp as Blueprint).asks.some((a) => a.name === k)) delete model.answers[k];
@@ -57,11 +59,23 @@ export async function screenSide(avatar: Subject, surface: Surface, options: Opt
57
59
  const l = await avatar.call('look');
58
60
  model.look = isSilence(l) || isWord(l) ? {} : sanitise(l as Json);
59
61
  } else model.look = {};
60
- // a read-only ask that needs nothing typed is run on her behalf, so the page opens with what it shows
61
- for (const a of (bp as Blueprint).asks) {
62
- const needs = ((a.input as { required?: string[] }).required ?? []).length > 0;
63
- if (hintFor(bp as Blueprint, model.look, a.name).readOnly && !needs && !(a.name in model.answers)) model.answers[a.name] = word(await avatar.call(a.name, {}));
64
- }
62
+ }
63
+ // her page, after every ask: a tree of values from what she holds now, or nothing and her asks
64
+ // are forms. A cell moved is a page moved, and her describe need not have; so the answers the
65
+ // page shows are run again with it, and the page never stands more than one ask behind her
66
+ const shows = new Set(model.tree ? answersIn(model.tree) : []);
67
+ if ((bp as Blueprint).asks.some((a) => a.name === 'page')) {
68
+ const t = await avatar.call('page');
69
+ model.tree = isSilence(t) || isWord(t) ? null : sanitiseTree(t as Json);
70
+ } else model.tree = null;
71
+ const wanted = new Set(model.tree ? answersIn(model.tree) : []);
72
+ for (const n of shows) if (wanted.has(n)) delete model.answers[n];
73
+ // a read-only ask that needs nothing typed is run on her behalf once per digest, and one her page
74
+ // shows the answer of after every ask, so the page opens and stays with what it shows
75
+ for (const a of (bp as Blueprint).asks) {
76
+ const needs = ((a.input as { required?: string[] }).required ?? []).length > 0;
77
+ const run = wanted.has(a.name) || (moved && hintFor(bp as Blueprint, model.look, a.name).readOnly);
78
+ if (run && !needs && !(a.name in model.answers)) model.answers[a.name] = word(await avatar.call(a.name, {}));
65
79
  }
66
80
  }
67
81
  await after();
package/human/tab.ts CHANGED
@@ -33,11 +33,14 @@ import { domSurface } from './dom.ts';
33
33
  import { guest } from './guest.ts';
34
34
  import { door } from './door.ts';
35
35
  import { world, relations, fresh, type Relation } from './worlds.ts';
36
+ import { local } from './local.ts';
37
+ import type { BeingClass } from '@quo-systems/quo';
36
38
 
37
39
  // What the page is told by the daemon that served it: the world's routes,
38
40
  // every ward on that harbor by name, its pk and whether a public being is
39
- // at its door, and which of them this page is, if it is one's.
40
- export type Config = { quo: string; web: string; wards: Record<string, { pk: string; public: boolean }>; ward?: string };
41
+ // at its door, which of them this page is, if it is one's, and whether the
42
+ // world serves code for the tab.
43
+ export type Config = { quo: string; web: string; wards: Record<string, { pk: string; public: boolean }>; ward?: string; beings?: boolean };
41
44
 
42
45
  // What the tab remembers between pages, beside the harbor: the worlds it
43
46
  // has joined, by pk, where each lives and what it is called; which relation
@@ -85,7 +88,20 @@ export async function start(cfg: Config, root: HTMLElement = document.body): Pro
85
88
  const say = (s: string) => (status.textContent = s);
86
89
 
87
90
  // The harbor in the tab: one database per origin, one ward per world.
91
+ // The world's code first, when its origin serves any: the classes for
92
+ // the tab, handed to the harbor beside the built-in ones before any ward
93
+ // boots, since a being kept from the last visit is booted by class name.
94
+ // A world that serves none has no beings in the tab, and the page is the
95
+ // far being's alone.
88
96
  const harbor = new BrowserHarbor('quo');
97
+ const classes = cfg.beings
98
+ ? await import(`${cfg.web}/beings.js`).then((mod: Record<string, unknown>) => {
99
+ const out: Record<string, BeingClass> = {};
100
+ for (const [name, v] of Object.entries(mod)) if (typeof v === 'function' && 'prototype' in v) out[name] = v as BeingClass;
101
+ return out;
102
+ })
103
+ : {};
104
+ Object.assign(harbor.classes, classes);
89
105
  await harbor.boot();
90
106
  harbor.dial(cfg.quo);
91
107
 
@@ -138,16 +154,43 @@ export async function start(cfg: Config, root: HTMLElement = document.body): Pro
138
154
  }
139
155
  };
140
156
 
141
- // In, as one relation: her page. Every call rotates her keys and a
142
- // same-ward ask never crosses the harbor, so the side saves after each.
157
+ // The world's beings in the tab, for one relation: one of each class,
158
+ // booted under her key the first time and found there after, since their
159
+ // cells are in the ward and come back with it. Each is a subject of her
160
+ // own with her own side in her own section, so a note she keeps and a
161
+ // form she shows are hers, painted by the same painter as the far page.
162
+ const locals: { close(): Promise<void> }[] = [];
163
+ const boot = async (rel: Relation) => {
164
+ const beings = (ward.partition as { beings?: Record<string, unknown> }).beings ?? {};
165
+ for (const name of Object.keys(classes)) {
166
+ const key = `${rel.key}-${name}`;
167
+ if (!beings[key]) {
168
+ const out = (await ward.ask('boot', { key, class: name })) as { error?: string };
169
+ if (out.error) continue;
170
+ await ward.save();
171
+ }
172
+ const being = ward.being(key) as Parameters<typeof local>[0] | undefined;
173
+ if (!being) continue;
174
+ const section = el('section', '', { class: 'local', 'data-being': key });
175
+ screen.append(section);
176
+ locals.push(await screenSide(local(being), domSurface(section), { after: () => ward.save() }));
177
+ }
178
+ };
179
+
180
+ // In, as one relation: her page, then the world's beings for her. Every
181
+ // call rotates her keys and a same-ward ask never crosses the harbor, so
182
+ // the side saves after each.
143
183
  const inside = async (rel: Relation, notice: string, called: string) => {
144
184
  await side?.close();
185
+ for (const l of locals.splice(0)) await l.close();
145
186
  status.remove();
146
187
  root.querySelector('form.password')?.remove();
147
188
  screen.replaceChildren();
148
189
  current = rel;
149
190
  keep(AT(pk), rel.key);
150
- const s = await screenSide(rel.avatar, domSurface(screen), { after: () => ward.save(), notice });
191
+ const mine = el('div', '', { class: 'far' });
192
+ screen.append(mine);
193
+ const s = await screenSide(rel.avatar, domSurface(mine), { after: () => ward.save(), notice });
151
194
  side = s;
152
195
  const bp = s.model.blueprint;
153
196
  const notesName = typeof (bp?.notes as JsonObject | null)?.name === 'string' ? ((bp!.notes as JsonObject).name as string) : '';
@@ -155,6 +198,7 @@ export async function start(cfg: Config, root: HTMLElement = document.body): Pro
155
198
  if (!names()[rel.key]) keep(NAMES(pk), { ...names(), [rel.key]: called });
156
199
  switcher();
157
200
  people();
201
+ await boot(rel);
158
202
  };
159
203
 
160
204
  // The way in, from any of the three: a fresh avatar joins, the ward is
@@ -177,6 +221,7 @@ export async function start(cfg: Config, root: HTMLElement = document.body): Pro
177
221
  // there, the door page: a link is the only way in, and nothing to type.
178
222
  const atDoor = async () => {
179
223
  await side?.close();
224
+ for (const l of locals.splice(0)) await l.close();
180
225
  side = null;
181
226
  current = null;
182
227
  screen.replaceChildren();
package/human/tree.ts ADDED
@@ -0,0 +1,129 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The page grammar: a page as a tree of values, and its painter. One
3
+ // grammar, two sources: a far being answers `page` with a tree, values
4
+ // only, the way she answers `look`; a being running in the tab computes one
5
+ // from her cells. The screen paints either with the painter below, and a
6
+ // being that answers nothing gets the forms `html.ts` paints on its own.
7
+ //
8
+ // The vocabulary is closed, and a node that fails its shape is dropped, not
9
+ // fixed: nothing here can carry a selector, a URL, a stylesheet or a
10
+ // script, so a far being can design her page and can never reach out of
11
+ // it. What a node names, an ask or a standing, must exist in her describe
12
+ // for this asker, or it paints as nothing: a page adds no right.
13
+ //
14
+ // stack children one under another { kind: 'stack', of: [...] }
15
+ // row children side by side, wrapping { kind: 'row', of: [...] }
16
+ // text words with a role { kind: 'text', text, role?: title | lead | body | quiet | label }
17
+ // image a picture as a data URI, never a URL { kind: 'image', src, alt? }
18
+ // form one of her asks as a form { kind: 'form', ask }
19
+ // answer one of her asks' last answer, shown { kind: 'answer', ask, as?: auto | text | table | cards | list }
20
+ // standing one standing she carries, a section { kind: 'standing', id }
21
+ // standings every standing she carries { kind: 'standings' }
22
+ import type { Json } from '@quo-systems/quo';
23
+ import { escape } from './html.ts';
24
+
25
+ export type Role = 'title' | 'lead' | 'body' | 'quiet' | 'label';
26
+ export type As = 'auto' | 'text' | 'table' | 'cards' | 'list';
27
+ export type Node =
28
+ | { kind: 'stack'; of: Node[] }
29
+ | { kind: 'row'; of: Node[] }
30
+ | { kind: 'text'; text: string; role?: Role }
31
+ | { kind: 'image'; src: string; alt?: string }
32
+ | { kind: 'form'; ask: string }
33
+ | { kind: 'answer'; ask: string; as?: As }
34
+ | { kind: 'standing'; id: string }
35
+ | { kind: 'standings' };
36
+
37
+ const ROLES = new Set<Role>(['title', 'lead', 'body', 'quiet', 'label']);
38
+ const AS = new Set<As>(['auto', 'text', 'table', 'cards', 'list']);
39
+ const TEXT = 4000; // characters a text node may carry
40
+ const DEPTH = 12; // how deep a tree may nest
41
+ const WIDE = 200; // children a container may have
42
+ const NAME = /^[\w.-]{1,80}$/;
43
+
44
+ // A value held to the grammar, or null. Children that fail are dropped and
45
+ // the container kept; a container with nothing left is kept empty, since an
46
+ // empty region is a design and not a fault.
47
+ export function sanitiseTree(v: Json, depth = 0): Node | null {
48
+ if (depth > DEPTH || v === null || typeof v !== 'object' || Array.isArray(v)) return null;
49
+ const o = v as Record<string, Json>;
50
+ switch (o.kind) {
51
+ case 'stack':
52
+ case 'row': {
53
+ const of = Array.isArray(o.of) ? o.of.slice(0, WIDE).map((c) => sanitiseTree(c, depth + 1)).filter((c): c is Node => c !== null) : [];
54
+ return { kind: o.kind, of };
55
+ }
56
+ case 'text': {
57
+ if (typeof o.text !== 'string') return null;
58
+ const n: Node = { kind: 'text', text: o.text.slice(0, TEXT) };
59
+ if (typeof o.role === 'string' && ROLES.has(o.role as Role)) n.role = o.role as Role;
60
+ return n;
61
+ }
62
+ case 'image': {
63
+ if (typeof o.src !== 'string' || !/^data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+$/i.test(o.src)) return null;
64
+ const n: Node = { kind: 'image', src: o.src };
65
+ if (typeof o.alt === 'string') n.alt = o.alt.slice(0, 200);
66
+ return n;
67
+ }
68
+ case 'form':
69
+ return typeof o.ask === 'string' && NAME.test(o.ask) ? { kind: 'form', ask: o.ask } : null;
70
+ case 'answer': {
71
+ if (typeof o.ask !== 'string' || !NAME.test(o.ask)) return null;
72
+ const n: Node = { kind: 'answer', ask: o.ask };
73
+ if (typeof o.as === 'string' && AS.has(o.as as As)) n.as = o.as as As;
74
+ return n;
75
+ }
76
+ case 'standing':
77
+ return typeof o.id === 'string' && NAME.test(o.id) ? { kind: 'standing', id: o.id } : null;
78
+ case 'standings':
79
+ return { kind: 'standings' };
80
+ default:
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // The names a tree asks the answers of: what a side runs on her behalf so
86
+ // the page opens with what it shows, when the ask needs nothing typed.
87
+ export function answersIn(n: Node, out: string[] = []): string[] {
88
+ if (n.kind === 'answer') out.push(n.ask);
89
+ if (n.kind === 'stack' || n.kind === 'row') for (const c of n.of) answersIn(c, out);
90
+ return out;
91
+ }
92
+
93
+ // What the painter needs from whoever holds the describe and the answers:
94
+ // each returns HTML for a name it knows, and the empty string for one it
95
+ // does not, so a page can never show what the gate did not.
96
+ export type Painter = {
97
+ form(ask: string): string;
98
+ answer(ask: string, as: As): string;
99
+ standing(id: string): string;
100
+ standings(): string;
101
+ };
102
+
103
+ const ROLE_TAG: Record<Role, string> = { title: 'h1', lead: 'p', body: 'p', quiet: 'p', label: 'p' };
104
+
105
+ export function paint(n: Node, p: Painter): string {
106
+ switch (n.kind) {
107
+ case 'stack':
108
+ case 'row':
109
+ return `<div class="${n.kind}">${n.of.map((c) => paint(c, p)).join('')}</div>`;
110
+ case 'text': {
111
+ const role = n.role ?? 'body';
112
+ return `<${ROLE_TAG[role]} class="t-${role}">${escape(n.text)}</${ROLE_TAG[role]}>`;
113
+ }
114
+ case 'image':
115
+ return `<img class="picture" alt="${escape(n.alt ?? '')}" src="${n.src}">`;
116
+ case 'form':
117
+ return p.form(n.ask);
118
+ case 'answer':
119
+ return p.answer(n.ask, n.as ?? 'auto');
120
+ case 'standing':
121
+ return p.standing(n.id);
122
+ case 'standings':
123
+ return p.standings();
124
+ }
125
+ }
126
+
127
+ // A painter with nothing behind it: for a page that names no ask, the door
128
+ // page and any page painted before anyone is in.
129
+ export const nobody: Painter = { form: () => '', answer: () => '', standing: () => '', standings: () => '' };
package/human/web.ts CHANGED
@@ -23,6 +23,8 @@
23
23
  import type { IncomingMessage, ServerResponse } from 'node:http';
24
24
  import { existsSync } from 'node:fs';
25
25
  import { fileURLToPath } from 'node:url';
26
+ import { join } from 'node:path';
27
+ import process from 'node:process';
26
28
  import { build } from 'esbuild';
27
29
  import type { DiskHarbor, Hosted } from '../harbor/disk.ts';
28
30
  import { door } from './door.ts';
@@ -33,16 +35,28 @@ export type WebOptions = {
33
35
  };
34
36
 
35
37
  // The paths under `/web` that are the exchange's, never a ward's name.
36
- const RESERVED_PATHS = new Set(['login', 'allow', 'tab.js']);
38
+ const RESERVED_PATHS = new Set(['allow', 'tab.js', 'beings.js']);
39
+
40
+ // The world's code for the tab: a module in the harbor folder, the twin of
41
+ // `classes/index.ts`, exporting by name the classes the tab boots into its
42
+ // local ward for a world of this harbor. Served bundled as `beings.js` on
43
+ // this origin, and one origin is one world's code.
44
+ export const TAB_CODE = 'tab/index.ts';
37
45
 
38
46
  export function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessage, res: ServerResponse, rest: string) => Promise<boolean> {
39
47
  const tabEntry = () => {
40
48
  const js = fileURLToPath(new URL('./tab.js', import.meta.url));
41
49
  return existsSync(js) ? js : fileURLToPath(new URL('./tab.ts', import.meta.url));
42
50
  };
51
+ const beingsEntry = join(harbor.dir, TAB_CODE);
52
+ const bundled = (entry: string) =>
53
+ // the entry may sit in a harbor folder with no node_modules of its own, as on a droplet whose
54
+ // estate folder is the working directory: what it imports is resolved from there too
55
+ build({ entryPoints: [entry], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false, nodePaths: [join(process.cwd(), 'node_modules')] }).then((out) => out.outputFiles[0]!.text);
43
56
  let bundle: Promise<string> | undefined;
44
- const built = () =>
45
- (bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((out) => out.outputFiles[0]!.text));
57
+ const built = () => (bundle ??= bundled(tabEntry()));
58
+ let beings: Promise<string> | undefined;
59
+ const builtBeings = () => (beings ??= bundled(beingsEntry));
46
60
  const quoOrigin = (() => {
47
61
  try {
48
62
  const u = new URL(o.at.quo);
@@ -71,7 +85,7 @@ export function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessa
71
85
  };
72
86
  const wards = () => Object.fromEntries([...harbor.wards].map(([n, h]) => [n, { pk: h.pk, public: publicOf(h) !== null }]));
73
87
  const tab = (ward?: string) => {
74
- const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), ...(ward ? { ward } : {}) };
88
+ const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), beings: existsSync(beingsEntry), ...(ward ? { ward } : {}) };
75
89
  return `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
76
90
  };
77
91
  return async (req, res, rest) => {
@@ -88,6 +102,13 @@ export function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessa
88
102
  res.end(await built());
89
103
  return true;
90
104
  }
105
+ // the world's code for the tab, when the harbor folder holds any; a harbor with none has no such path
106
+ if (rest === '/beings.js' && req.method === 'GET') {
107
+ if (!existsSync(beingsEntry)) return false;
108
+ res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
109
+ res.end(await builtBeings());
110
+ return true;
111
+ }
91
112
  const wardName = parts[0] ?? '';
92
113
  if (!wardName || RESERVED_PATHS.has(wardName)) return false;
93
114
  const hosted = harbor.wards.get(wardName);
@@ -110,14 +131,20 @@ const CSS = [
110
131
  'header,main{background:var(--bg);color:var(--fg);font-family:var(--font)}header{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}header .notice{width:100%;margin:0}',
111
132
  'input,select,textarea,button{font:inherit;padding:.4rem;margin:.2rem;border-radius:var(--radius)}button{background:var(--accent);color:#fff;border:0;padding:.4rem .9rem}',
112
133
  'fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}',
134
+ // a being of the world running in the tab: her own section under the far page
135
+ 'section.local{border-top:1px solid color-mix(in srgb,currentColor 15%,transparent);margin:1.5rem 0;padding-top:.5rem}section.local header:empty{display:none}',
113
136
  'section.standing{background:var(--bg);color:var(--fg);font-family:var(--font);border-left:4px solid var(--accent);border-radius:var(--radius);padding:.25rem 1rem;margin:1.5rem 0}section.standing h2{display:flex;align-items:center;gap:.5rem;font-size:1.1rem}img.logo{height:1.6rem}',
114
137
  'table{border-collapse:collapse}td,th{padding:.15rem .5rem;text-align:left}.answer{margin:.5rem 0 1rem;padding:.5rem;border-left:3px solid var(--accent)}.answer.error{border-color:#c33}.answer.silence,.answer.word,.answer.unreached{border-color:#c93}pre{padding:.75rem;overflow:auto}',
138
+ // a page in the grammar: regions, roles, cards
139
+ '.page .stack{display:flex;flex-direction:column;gap:.5rem}.page .row{display:flex;flex-wrap:wrap;gap:.5rem 1rem;align-items:baseline}',
140
+ '.page .t-title{font-size:2rem;font-weight:600;letter-spacing:-.02em;margin:.5rem 0}.page .t-lead{font-size:1.25rem;margin:.25rem 0}.page .t-label{font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;color:var(--mute);margin:0}.page .t-quiet{color:var(--mute);font-size:.9rem}.page .t-body{margin:.25rem 0}',
141
+ '.page .picture{max-height:6rem}.cards{display:flex;flex-wrap:wrap;gap:.75rem}.card{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);padding:.5rem .75rem}',
115
142
  // the door page: one column, generous air, the mark, a word, a sentence
116
143
  'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
117
- 'main.door .mark{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
118
- 'main.door h1{font-size:3rem;font-weight:400;letter-spacing:-.02em;line-height:1;margin:0 0 .5rem}',
119
- 'main.door .where{font-family:system-ui,sans-serif;font-size:.85rem;letter-spacing:.08em;text-transform:uppercase;color:var(--mute);margin:0 0 2rem}main.door .where span{text-transform:none;letter-spacing:0}',
120
- 'main.door .lead{font-size:1.5rem;line-height:1.3;margin:0 0 1rem}',
144
+ 'main.door .picture{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
145
+ 'main.door .t-title{font-size:3rem;font-weight:400;letter-spacing:-.02em;line-height:1;margin:0 0 .5rem}',
146
+ 'main.door .t-label{font-family:system-ui,sans-serif;font-size:.85rem;letter-spacing:.08em;text-transform:uppercase;color:var(--mute);margin:0 0 2rem}main.door .t-label span{text-transform:none;letter-spacing:0}',
147
+ 'main.door .t-lead{font-size:1.5rem;line-height:1.3;margin:0 0 1rem}',
121
148
  'main.door p{font-size:1.05rem;line-height:1.6;margin:0 0 1rem;max-width:30rem}',
122
- 'main.door .quiet{color:var(--mute);font-size:.95rem;border-top:1px solid color-mix(in srgb,currentColor 15%,transparent);padding-top:1rem;margin-top:1rem}',
149
+ 'main.door .t-quiet{color:var(--mute);font-size:.95rem;border-top:1px solid color-mix(in srgb,currentColor 15%,transparent);padding-top:1rem;margin-top:1rem}',
123
150
  ].join('');
package/mcp/oauth.ts CHANGED
@@ -14,8 +14,8 @@
14
14
  // POST /token code or refresh for an access token
15
15
  //
16
16
  // The web route finishes a request by calling `complete(request, identity)`
17
- // after its own login and allow pages; that returns the redirect that
18
- // carries the code back to the client. Step 8 builds those pages.
17
+ // after its allow page, where the human hands the invitation the root minted;
18
+ // that returns the redirect that carries the code back to the client.
19
19
  import { randomBytes, createHash } from 'node:crypto';
20
20
  import type { IncomingMessage, ServerResponse } from 'node:http';
21
21