@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.
- package/beings/avatar.ts +8 -1
- package/beings/carry.ts +1 -1
- package/beings/quo-dock.md +53 -39
- package/beings/setup.ts +2 -1
- package/beings/user.ts +48 -1
- package/cli/daemon.ts +51 -25
- package/cli/estate/quo.service +0 -1
- package/dist/beings/avatar.js +9 -2
- package/dist/beings/carry.js +2 -2
- package/dist/beings/setup.js +2 -1
- package/dist/beings/user.d.ts +89 -0
- package/dist/beings/user.js +47 -1
- package/dist/cli/daemon.d.ts +10 -2
- package/dist/cli/daemon.js +51 -23
- package/dist/cli/estate/quo.service +0 -1
- package/dist/harbor/capacitor.d.ts +16 -0
- package/dist/harbor/capacitor.js +135 -0
- package/dist/harbor/disk.js +8 -3
- package/dist/harbor/edge/edge.js +2 -1
- package/dist/harbor/edge/exercise.js +2 -1
- package/dist/harbor/edge/storage.d.ts +0 -3
- package/dist/harbor/edge/storage.js +6 -25
- package/dist/harbor/files.d.ts +2 -1
- package/dist/harbor/files.js +87 -22
- package/dist/harbor/seal.d.ts +3 -0
- package/dist/harbor/seal.js +25 -0
- package/dist/human/door.d.ts +2 -0
- package/dist/human/door.js +19 -7
- package/dist/human/html.d.ts +2 -0
- package/dist/human/html.js +43 -17
- package/dist/human/local.d.ts +10 -0
- package/dist/human/local.js +10 -0
- package/dist/human/screen.js +26 -8
- package/dist/human/tab.d.ts +1 -0
- package/dist/human/tab.js +59 -3
- package/dist/human/tree.d.ts +40 -0
- package/dist/human/tree.js +89 -0
- package/dist/human/web.d.ts +1 -0
- package/dist/human/web.js +36 -8
- package/dist/mcp/oauth.js +2 -2
- package/dist/mcp/route.js +1 -1
- package/dist/mcp/runner.js +5 -2
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/web/exchange.d.ts +5 -9
- package/dist/mcp/web/exchange.js +47 -98
- package/harbor/capacitor.ts +142 -0
- package/harbor/disk.ts +8 -3
- package/harbor/edge/edge.ts +2 -1
- package/harbor/edge/exercise.ts +2 -1
- package/harbor/edge/storage.ts +6 -25
- package/harbor/files.ts +79 -19
- package/harbor/quo-harbor.md +58 -15
- package/harbor/seal.ts +26 -0
- package/human/door.ts +20 -7
- package/human/html.ts +41 -17
- package/human/local.ts +26 -0
- package/human/quo-human.md +114 -19
- package/human/screen.ts +21 -7
- package/human/tab.ts +50 -5
- package/human/tree.ts +129 -0
- package/human/web.ts +36 -9
- package/mcp/oauth.ts +2 -2
- package/mcp/quo-mcp.md +27 -20
- package/mcp/route.ts +1 -1
- package/mcp/runner.ts +9 -6
- package/mcp/server.ts +1 -1
- package/mcp/web/exchange.ts +62 -96
- package/package.json +13 -2
package/human/quo-human.md
CHANGED
|
@@ -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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
writes
|
|
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
|
|
51
|
-
that moved
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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,
|
|
40
|
-
|
|
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
|
-
//
|
|
142
|
-
//
|
|
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
|
|
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(['
|
|
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
|
-
|
|
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 .
|
|
118
|
-
'main.door
|
|
119
|
-
'main.door .
|
|
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
|
|
18
|
-
// carries the code back to the client.
|
|
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
|
|