@quo-systems/dock 0.2.0 → 0.2.1
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 +6 -2
- package/beings/carry.ts +101 -0
- package/beings/desk.ts +2 -2
- package/beings/index.ts +2 -0
- package/beings/link.ts +54 -0
- package/beings/look.ts +96 -0
- package/beings/quo-dock.md +129 -19
- package/beings/side.ts +10 -1
- package/beings/user.ts +31 -5
- package/cli/daemon.ts +98 -44
- package/cli/quo.ts +3 -2
- package/dist/beings/avatar.js +7 -2
- package/dist/beings/carry.d.ts +10 -0
- package/dist/beings/carry.js +106 -0
- package/dist/beings/desk.d.ts +1 -0
- package/dist/beings/desk.js +1 -1
- package/dist/beings/index.d.ts +2 -0
- package/dist/beings/index.js +2 -0
- package/dist/beings/link.d.ts +7 -0
- package/dist/beings/link.js +42 -0
- package/dist/beings/look.d.ts +27 -0
- package/dist/beings/look.js +71 -0
- package/dist/beings/side.d.ts +8 -1
- package/dist/beings/user.d.ts +26 -2
- package/dist/beings/user.js +32 -5
- package/dist/cli/daemon.d.ts +1 -1
- package/dist/cli/daemon.js +98 -44
- package/dist/cli/quo.js +3 -2
- package/dist/human/guest.d.ts +3 -0
- package/dist/human/guest.js +25 -0
- package/dist/human/html.d.ts +10 -2
- package/dist/human/html.js +61 -10
- package/dist/human/screen.d.ts +8 -3
- package/dist/human/screen.js +25 -6
- package/dist/human/tab.d.ts +2 -0
- package/dist/human/tab.js +127 -42
- package/dist/mcp/http.d.ts +4 -3
- package/dist/mcp/http.js +6 -6
- package/dist/mcp/oauth.d.ts +9 -4
- package/dist/mcp/oauth.js +15 -14
- package/dist/mcp/pilot.js +1 -1
- package/dist/mcp/server.d.ts +5 -10
- package/dist/mcp/server.js +35 -4
- package/dist/mcp/web/exchange.d.ts +5 -2
- package/dist/mcp/web/exchange.js +21 -7
- package/harbor/quo-harbor.md +37 -0
- package/human/guest.ts +26 -0
- package/human/html.ts +58 -10
- package/human/quo-human.md +123 -66
- package/human/screen.ts +28 -7
- package/human/tab.ts +153 -51
- package/mcp/http.ts +10 -9
- package/mcp/oauth.ts +20 -17
- package/mcp/pilot.ts +1 -1
- package/mcp/quo-mcp.md +18 -2
- package/mcp/server.ts +37 -18
- package/mcp/web/exchange.ts +23 -9
- package/package.json +2 -2
package/dist/mcp/server.js
CHANGED
|
@@ -11,19 +11,37 @@
|
|
|
11
11
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
12
12
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
13
13
|
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
|
|
14
|
+
import { hintFor, sanitise } from '../beings/look.js';
|
|
15
|
+
import { isSilence, isWord, digest } from '@quo-systems/quo';
|
|
14
16
|
export const NAME = 'quo';
|
|
15
17
|
export const VERSION = '0.0.0';
|
|
16
18
|
// The empty ask as a tool: her describe whole, asks and notes.
|
|
17
19
|
export const DESCRIBE = { name: 'describe', description: 'the empty ask: her describe, the asks and the notes', inputSchema: { type: 'object' } };
|
|
18
20
|
// Her describe, spoken as tools, the empty ask first. Name, description and
|
|
19
|
-
// input are verbatim; an output schema crosses when she declared one.
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// input are verbatim; an output schema crosses when she declared one. Her
|
|
22
|
+
// look, when she has one, is the hints: a title, and the annotations a host
|
|
23
|
+
// reads. The `look` ask itself is presentation, and is not a tool.
|
|
24
|
+
export function tools(bp, look = {}) {
|
|
25
|
+
const asks = bp.asks
|
|
26
|
+
.filter((a) => a.name !== 'look')
|
|
27
|
+
.map((a) => {
|
|
22
28
|
const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
|
|
23
29
|
if (a.description !== undefined)
|
|
24
30
|
t.description = a.description;
|
|
25
31
|
if (a.output !== undefined)
|
|
26
32
|
t.outputSchema = { ...a.output, type: 'object' };
|
|
33
|
+
const h = hintFor(bp, look, a.name);
|
|
34
|
+
if (h.title !== undefined)
|
|
35
|
+
t.title = h.title;
|
|
36
|
+
const notes = {};
|
|
37
|
+
if (h.readOnly !== undefined)
|
|
38
|
+
notes.readOnlyHint = h.readOnly;
|
|
39
|
+
if (h.destructive !== undefined)
|
|
40
|
+
notes.destructiveHint = h.destructive;
|
|
41
|
+
if (h.idempotent !== undefined)
|
|
42
|
+
notes.idempotentHint = h.idempotent;
|
|
43
|
+
if (Object.keys(notes).length)
|
|
44
|
+
t.annotations = notes;
|
|
27
45
|
return t;
|
|
28
46
|
});
|
|
29
47
|
return [DESCRIBE, ...asks];
|
|
@@ -32,11 +50,24 @@ export function tools(bp) {
|
|
|
32
50
|
// changed hooks it, since a same-ward ask never crosses the harbor.
|
|
33
51
|
export async function mcpSide(avatar, transport, after = async () => { }) {
|
|
34
52
|
const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: { listChanged: true }, logging: {} } });
|
|
53
|
+
// Her look, asked once per digest of her describe.
|
|
54
|
+
let seen = null;
|
|
55
|
+
let look = {};
|
|
35
56
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
36
57
|
const bp = await avatar.tools();
|
|
37
58
|
if ('error' in bp && !('asks' in bp))
|
|
38
59
|
return { tools: [] }; // not joined: nothing to show, and nothing to call
|
|
39
|
-
|
|
60
|
+
const d = await digest(bp);
|
|
61
|
+
if (d !== seen) {
|
|
62
|
+
seen = d;
|
|
63
|
+
if (bp.asks.some((a) => a.name === 'look')) {
|
|
64
|
+
const l = await avatar.call('look', {});
|
|
65
|
+
look = isSilence(l) || isWord(l) ? {} : sanitise(l);
|
|
66
|
+
}
|
|
67
|
+
else
|
|
68
|
+
look = {};
|
|
69
|
+
}
|
|
70
|
+
return { tools: tools(bp, look) };
|
|
40
71
|
});
|
|
41
72
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
42
73
|
const w = word(req.params.name === DESCRIBE.name ? await avatar.tools() : await avatar.call(req.params.name, (req.params.arguments ?? {})));
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { OAuth } from '../oauth.ts';
|
|
3
3
|
export declare const SESSION_TTL: number;
|
|
4
|
-
export type Admit = (identity: string, wake: boolean) => Promise<{
|
|
4
|
+
export type Admit = (identity: string, wake: boolean, reach: boolean, ward: string) => Promise<{
|
|
5
5
|
error?: string;
|
|
6
6
|
}>;
|
|
7
7
|
export type Options = {
|
|
8
8
|
oauth: OAuth;
|
|
9
9
|
password: () => string | undefined;
|
|
10
10
|
admit: Admit;
|
|
11
|
-
|
|
11
|
+
worlds: () => {
|
|
12
|
+
ward: string;
|
|
13
|
+
user: string;
|
|
14
|
+
}[];
|
|
12
15
|
now?: () => number;
|
|
13
16
|
};
|
|
14
17
|
export declare const suggest: (name: string) => string;
|
package/dist/mcp/web/exchange.js
CHANGED
|
@@ -90,7 +90,7 @@ export class Exchange {
|
|
|
90
90
|
return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`), true;
|
|
91
91
|
if (!this.valid(req))
|
|
92
92
|
return go(`/login?request=${encodeURIComponent(request)}`), true;
|
|
93
|
-
return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.
|
|
93
|
+
return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.worlds())), true;
|
|
94
94
|
}
|
|
95
95
|
if (rest === '/allow' && req.method === 'POST') {
|
|
96
96
|
const f = await readForm(req);
|
|
@@ -104,13 +104,17 @@ export class Exchange {
|
|
|
104
104
|
const out = await this.o.oauth.deny(request);
|
|
105
105
|
return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`), true;
|
|
106
106
|
}
|
|
107
|
+
const worlds = this.o.worlds();
|
|
108
|
+
const world = worlds.find((w) => w.ward === (f.get('ward') ?? worlds[0]?.ward));
|
|
109
|
+
if (!world)
|
|
110
|
+
return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), worlds, 'That is not a world of this harbor.')), true;
|
|
107
111
|
const identity = word(f.get('identity'));
|
|
108
|
-
if (identity === null || identity ===
|
|
109
|
-
return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name),
|
|
110
|
-
const admitted = await this.o.admit(identity, f.get('wake') === 'on');
|
|
112
|
+
if (identity === null || identity === world.user || identity === 'desk')
|
|
113
|
+
return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), worlds, 'An identity is one word, and not the user or the desk.')), true;
|
|
114
|
+
const admitted = await this.o.admit(identity, f.get('wake') === 'on', f.get('reach') === 'on', world.ward);
|
|
111
115
|
if (admitted.error)
|
|
112
116
|
return page(500, `<h1>Not admitted</h1><p>${esc(admitted.error)}</p>`), true;
|
|
113
|
-
const out = await this.o.oauth.complete(request, identity);
|
|
117
|
+
const out = await this.o.oauth.complete(request, identity, world.ward);
|
|
114
118
|
return 'redirect' in out ? go(out.redirect, { 'set-cookie': `${COOKIE}=; Path=/; Max-Age=0` }) : page(400, `<h1>Gone</h1>`), true;
|
|
115
119
|
}
|
|
116
120
|
return false;
|
|
@@ -125,12 +129,22 @@ ${err ? `<p class="err">${esc(err)}</p>` : ''}
|
|
|
125
129
|
<form method="post" action="/login"><input type="hidden" name="request" value="${esc(request)}">
|
|
126
130
|
<label for="p">Owner password</label><input id="p" name="password" type="password" autocomplete="current-password" autofocus required>
|
|
127
131
|
<button type="submit">Log in</button></form>`;
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
// The world is a choice when the harbor has more than one; the user named
|
|
133
|
+
// is the first world's, and the page says which world each identity lands in.
|
|
134
|
+
const allowForm = (request, client, redirect, identity, worlds, err = '') => {
|
|
135
|
+
const user = worlds[0]?.user ?? '';
|
|
136
|
+
const pick = worlds.length > 1
|
|
137
|
+
? `<label for="wd">World</label><select id="wd" name="ward">${worlds.map((w) => `<option value="${esc(w.ward)}">${esc(w.ward)}, ${esc(w.user)}'s</option>`).join('')}</select>`
|
|
138
|
+
: `<input type="hidden" name="ward" value="${esc(worlds[0]?.ward ?? 'main')}">`;
|
|
139
|
+
return `<h1>Allow ${esc(client)}?</h1>
|
|
140
|
+
<div class="who"><p><strong>${esc(client)}</strong> asks to be an occupant of <strong>${esc(user)}</strong>${worlds.length > 1 ? ', or of another world below' : ''}.</p>
|
|
130
141
|
<p>It will see exactly what ${esc(user)} shows the identity below, and nothing else. You can remove it any time.</p>
|
|
131
142
|
<p>It returns to <code>${esc(redirect)}</code>.</p></div>
|
|
132
143
|
${err ? `<p class="err">${esc(err)}</p>` : ''}
|
|
133
144
|
<form method="post" action="/allow"><input type="hidden" name="request" value="${esc(request)}">
|
|
145
|
+
${pick}
|
|
134
146
|
<label for="i">Identity</label><input id="i" name="identity" value="${esc(identity)}" pattern="[\\w.-]{1,40}" required>
|
|
147
|
+
<label for="r"><input id="r" name="reach" type="checkbox" style="width:auto"> May reach what ${esc(user)} holds: her standings, acme and the rest, as asks of hers</label>
|
|
135
148
|
<label for="w"><input id="w" name="wake" type="checkbox" style="width:auto"> May wake your other devices: hand an agent an event through ${esc(user)}</label>
|
|
136
149
|
<button type="submit" name="decision" value="allow">Allow</button><button type="submit" name="decision" value="deny">Deny</button></form>`;
|
|
150
|
+
};
|
package/harbor/quo-harbor.md
CHANGED
|
@@ -88,6 +88,38 @@ the new harbor pointed at the same code. Same seed, same pk, every standing
|
|
|
88
88
|
anyone holds still points at her. The old harbor deletes its copies first:
|
|
89
89
|
two harbors holding one seed are two wards with one pk, diverging silently.
|
|
90
90
|
|
|
91
|
+
What a move costs is not the three parts, which are bytes, but how many
|
|
92
|
+
peers wrote down where the ward was. A pk is permanent by arithmetic; a
|
|
93
|
+
location is soft state in every other harbor's directory, and only that goes
|
|
94
|
+
stale. So the bill is proportional to who hard-coded it, and there are two
|
|
95
|
+
kinds of ward.
|
|
96
|
+
|
|
97
|
+
A ward whose harbor dials out is free. It announces what it holds when a
|
|
98
|
+
line opens and whenever that changes, the listener proves each claim at the
|
|
99
|
+
door and binds it, and a ward that arrives on a new box is reachable as soon
|
|
100
|
+
as its harbor dials. Nobody is told and no peer acts.
|
|
101
|
+
|
|
102
|
+
A ward that is dialed keeps a second name. Peers hold a hint, pk at a URL,
|
|
103
|
+
in their own stores and across their own restarts, so that URL is a promise
|
|
104
|
+
the same way the pk is. Moving boxes does not break it, because a hostname
|
|
105
|
+
is not a location: DNS and the proxy point it wherever the harbor now runs,
|
|
106
|
+
which is the whole reason the route is a hostname and not an address.
|
|
107
|
+
Changing the URL itself is the move that costs, and it cannot be announced
|
|
108
|
+
to a peer this ward cannot reach.
|
|
109
|
+
|
|
110
|
+
When it must change, what is republished is a hint and never an invitation.
|
|
111
|
+
The relation, its keys and its count are untouched, and the invitation is
|
|
112
|
+
as good as it was: that is why the two travel separately, a link being one
|
|
113
|
+
next to the other. Peers on a rendezvous this ward also dials relearn by
|
|
114
|
+
themselves, since a binding is proven at the door and names no URL; peers
|
|
115
|
+
holding a hint need a new one, and a hint is not a capability, so it may be
|
|
116
|
+
published in the open to all of them at once. Nobody is invited twice.
|
|
117
|
+
|
|
118
|
+
The rule, then: a dialable ward keeps two names and owes the second the same
|
|
119
|
+
care as the first, and a ward that only dials keeps one, which is why a
|
|
120
|
+
laptop, a tab and a phone move home without ceremony and the droplet does
|
|
121
|
+
not.
|
|
122
|
+
|
|
91
123
|
### Code is a harbor decision
|
|
92
124
|
|
|
93
125
|
A ward's code has an origin: a folder the owner wrote, a bundle a world
|
|
@@ -235,6 +267,11 @@ request POST <url>/<pk>, the sealed ask as the body, the sealed reply as th
|
|
|
235
267
|
<url> is the world's quo. route, which the proxy maps onto the daemon's /quo
|
|
236
268
|
404 is "no reach for that pk" and comes back as nothing; a line lost after
|
|
237
269
|
sending answers nothing at all, and the ward's bound ends the ask
|
|
270
|
+
at most 1 MiB is read, and a body over it is answered by that same 404: an
|
|
271
|
+
ask too big for this door is nothing delivered, never a silence, so it is
|
|
272
|
+
safe to send again and it will fail again. The number is this daemon's and
|
|
273
|
+
is on no wire; a caller cannot ask for it and is never told which of the
|
|
274
|
+
two a 404 was
|
|
238
275
|
socket a WebSocket at <url>, held by the dialer, used both ways
|
|
239
276
|
text frame { announce: [pk, ...] } the ward pks this side holds
|
|
240
277
|
ask frame [0][id 4][pk 64][bytes] a frame id matches the reply
|
package/human/guest.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A guest at a world's door: the world's public being, whatever class she
|
|
3
|
+
// is, as a subject the screen can render. Her describe for a stranger is the
|
|
4
|
+
// guest page; a form on it is a knock on the public invitation; and an
|
|
5
|
+
// answer that is an invitation is the way in, which the tab takes by
|
|
6
|
+
// joining. Nothing here knows the desk: the desk's `device` is one ask
|
|
7
|
+
// that answers an invitation, and any being may write another.
|
|
8
|
+
import { isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
9
|
+
import type { Answer, Blueprint, Invitation, JsonObject, Wanted } from '@quo-systems/quo';
|
|
10
|
+
import type { Avatar } from '../beings/avatar.ts';
|
|
11
|
+
import type { Subject } from '../beings/side.ts';
|
|
12
|
+
|
|
13
|
+
export function guest(avatar: Avatar, ward: string): Subject {
|
|
14
|
+
const at: Invitation = { ward };
|
|
15
|
+
return {
|
|
16
|
+
tools: async () => {
|
|
17
|
+
const bp = await avatar.knock(at);
|
|
18
|
+
if (isSilence(bp)) return { error: 'silence' };
|
|
19
|
+
if (isWord(bp)) return { error: wordOf(bp) };
|
|
20
|
+
if (bp === null || typeof bp !== 'object' || Array.isArray(bp) || !Array.isArray((bp as Blueprint).asks)) return { error: 'nobody is home' };
|
|
21
|
+
return bp as Blueprint;
|
|
22
|
+
},
|
|
23
|
+
call: (name: string, args: JsonObject = {}, wanted?: Wanted): Promise<Answer> => avatar.knock(at, name, args, wanted),
|
|
24
|
+
ears: new Set(),
|
|
25
|
+
};
|
|
26
|
+
}
|
package/human/html.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// page(model) the whole page: title, notice, forms, views, pushes
|
|
12
12
|
import type { Ask, Blueprint, Json, JsonObject } from '@quo-systems/quo';
|
|
13
13
|
import { SILENCE_TEXT, UNREACHED_TEXT, wordText, type Word } from '../beings/side.ts';
|
|
14
|
+
import { hint, hintFor, sanitise, groups as grouped, type Look, type Hint } from '../beings/look.ts';
|
|
14
15
|
|
|
15
16
|
// One property of an input schema, as the form needs it.
|
|
16
17
|
export type Property = { type?: string; description?: string; enum?: Json[]; format?: string; default?: Json };
|
|
@@ -47,12 +48,14 @@ export function field(name: string, p: Property, must: boolean): string {
|
|
|
47
48
|
|
|
48
49
|
// The ask as a form: its name is the button, its description the legend's
|
|
49
50
|
// small print, and `data-ask` is how the surface says which ask was sent.
|
|
50
|
-
export function form(ask: Ask): string {
|
|
51
|
+
export function form(ask: Ask, h: Hint = {}): string {
|
|
51
52
|
const must = required(ask.input);
|
|
52
53
|
const fields = props(ask.input)
|
|
53
54
|
.map(([name, p]) => field(name, p, must.has(name)))
|
|
54
55
|
.join('');
|
|
55
|
-
|
|
56
|
+
const label = `${h.icon ? `${escape(h.icon)} ` : ''}${escape(h.title ?? ask.name)}`;
|
|
57
|
+
const care = h.destructive ? ' data-confirm="true"' : '';
|
|
58
|
+
return `<form data-ask="${escape(ask.name)}"${care}><fieldset><legend>${label}${ask.description ? ` <small>${escape(ask.description)}</small>` : ''}</legend>${fields}<p><button>${label}</button></p></fieldset></form>`;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
// ---- the form heard back
|
|
@@ -136,6 +139,7 @@ export function face(w: Word, schema?: JsonObject): string {
|
|
|
136
139
|
// hands it here after every change; the page is a function of it.
|
|
137
140
|
export type Model = {
|
|
138
141
|
blueprint: Blueprint | null; // her describe for this human, or nothing yet
|
|
142
|
+
look: Look; // her own look, from her `look` ask, or nothing
|
|
139
143
|
notice: string; // one line about where the human stands: in, not in, an error before an ask
|
|
140
144
|
answers: Record<string, Word>; // the last answer per ask, shown under its form
|
|
141
145
|
pushes: JsonObject[]; // every push from the world, newest last
|
|
@@ -143,22 +147,66 @@ export type Model = {
|
|
|
143
147
|
|
|
144
148
|
// The title is the one hint the notes may carry: a string named `name`.
|
|
145
149
|
// The rest of the notes is shown as a view and read as nothing else.
|
|
146
|
-
export function title(bp: Blueprint | null): string {
|
|
150
|
+
export function title(bp: Blueprint | null, l: Look = {}): string {
|
|
151
|
+
if (l.name) return l.name;
|
|
147
152
|
const n = bp?.notes;
|
|
148
153
|
return n !== null && typeof n === 'object' && !Array.isArray(n) && typeof n.name === 'string' && n.name ? n.name : 'quo';
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
// ---- a look
|
|
157
|
+
|
|
158
|
+
// A look as the page paints it: the tokens become CSS variables on one
|
|
159
|
+
// section, and the name and the logo its heading. The shape of every token
|
|
160
|
+
// is the dock's, `beings/look.ts`; this only writes what survived it.
|
|
161
|
+
export function look(l: Look | undefined): { style: string; head: string } {
|
|
162
|
+
if (!l) return { style: '', head: '' };
|
|
163
|
+
const vars: string[] = [];
|
|
164
|
+
if (l.accent) vars.push(`--accent:${l.accent}`);
|
|
165
|
+
if (l.background) vars.push(`--bg:${l.background}`);
|
|
166
|
+
if (l.foreground) vars.push(`--fg:${l.foreground}`);
|
|
167
|
+
if (l.font) vars.push(`--font:${l.font}`);
|
|
168
|
+
if (l.radius !== undefined) vars.push(`--radius:${l.radius}px`);
|
|
169
|
+
const logo = l.logo ? `<img class="logo" alt="" src="${l.logo}">` : '';
|
|
170
|
+
const name = l.name ? escape(l.name) : '';
|
|
171
|
+
return { style: vars.length ? ` style="${vars.join(';')}"` : '', head: logo || name ? `<h2>${logo}${name}</h2>` : '' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Her asks in the order her look asks for, the rest after in her own order.
|
|
175
|
+
export function ordered(asks: Ask[], l: Look | undefined): Ask[] {
|
|
176
|
+
const want = l?.order ?? [];
|
|
177
|
+
return [...want.map((n) => asks.find((a) => a.name === n)).filter((a): a is Ask => a !== undefined), ...asks.filter((a) => !want.includes(a.name))];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export { hintFor };
|
|
181
|
+
|
|
151
182
|
export function page(m: Model): string {
|
|
152
183
|
const bp = m.blueprint;
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
184
|
+
const one = (l: Look | undefined, prefix = '') => (a: Ask) => {
|
|
185
|
+
const w = m.answers[a.name];
|
|
186
|
+
const h = hint(l, a.name.startsWith(prefix) ? a.name.slice(prefix.length) : a.name);
|
|
187
|
+
return `<section>${form(a, h)}${w ? face(w, a.output) : ''}</section>`;
|
|
188
|
+
};
|
|
189
|
+
const groups = grouped(bp);
|
|
190
|
+
const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
|
|
191
|
+
const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && a.name !== 'look'), m.look).map(one(m.look)).join('') : '';
|
|
192
|
+
const far = bp
|
|
193
|
+
? Object.entries(groups)
|
|
194
|
+
.map(([id, g]) => {
|
|
195
|
+
const asks = bp.asks.filter((a) => g.asks?.includes(a.name));
|
|
196
|
+
if (!asks.length) return '';
|
|
197
|
+
const kept = sanitise(g.look);
|
|
198
|
+
const l = look(kept);
|
|
199
|
+
const prefix = `${id}-`;
|
|
200
|
+
const want = (kept.order ?? []).map((n) => prefix + n);
|
|
201
|
+
const inOrder = ordered(asks, { order: want });
|
|
202
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
|
|
158
203
|
})
|
|
159
204
|
.join('')
|
|
160
205
|
: '';
|
|
161
|
-
const
|
|
206
|
+
const asks = own + far;
|
|
207
|
+
const shown = bp && bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? Object.fromEntries(Object.entries(bp.notes).filter(([k]) => k !== 'standings' && k !== 'name')) : bp?.notes;
|
|
208
|
+
const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown as Json)}</aside>` : '';
|
|
162
209
|
const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
|
|
163
|
-
|
|
210
|
+
const mine = look(m.look);
|
|
211
|
+
return `<header${mine.style}>${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1><p class="notice">${escape(m.notice)}</p></header>${notes}<main${mine.style}>${asks}</main>${pushes}`;
|
|
164
212
|
}
|
package/human/quo-human.md
CHANGED
|
@@ -1,86 +1,143 @@
|
|
|
1
1
|
# The human side
|
|
2
2
|
|
|
3
3
|
This is how a Quo world speaks to a human, and how a human speaks back. It
|
|
4
|
-
is one side of the avatar described in `packages/dock/beings/quo-dock.md`,
|
|
5
|
-
owns the avatar, the front desk, the credential exchange
|
|
6
|
-
invariants; this document assumes all of that and
|
|
7
|
-
needs. It names no transport and no model. A tab is
|
|
8
|
-
of its own, and where its bytes go is
|
|
4
|
+
is one side of the avatar described in `packages/dock/beings/quo-dock.md`,
|
|
5
|
+
which owns the avatar, the front desk, the credential exchange, carrying,
|
|
6
|
+
the look and the shared invariants; this document assumes all of that and
|
|
7
|
+
adds only what a screen needs. It names no transport and no model. A tab is
|
|
8
|
+
a device with a harbor of its own, and where its bytes go is
|
|
9
|
+
`packages/dock/harbor/quo-harbor.md`.
|
|
9
10
|
|
|
10
|
-
The claim
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
The claim, made good on: a human needs no page written for them. A screen
|
|
12
|
+
is a blueprint spoken as HTML. A being's describe for this asker becomes the
|
|
13
|
+
forms the human sees, a submitted form becomes a named ask, an answer
|
|
14
|
+
becomes a view, a push becomes a notification, and the three words for "no
|
|
15
|
+
object" each have a face. Nothing the screen shows exists anywhere but in a
|
|
16
|
+
blueprint and a look, so every world gets a screen for free and no world
|
|
17
|
+
gets a screen it did not describe.
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
Three sides, one describe. The CLI, the model side and the screen are three
|
|
20
|
+
views of the same beings, and the screen adds no vocabulary of its own:
|
|
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.
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
- how an output schema, and an answer, become a view;
|
|
22
|
-
- what the blueprint's notes may carry as rendering hints without becoming
|
|
23
|
-
state, and what they may never carry;
|
|
24
|
-
- how a standing is a page, and a digest change is a refreshed page;
|
|
25
|
-
- how the same screen is a guest one minute and an occupant the next, and
|
|
26
|
-
what the human sees at each of the three words;
|
|
27
|
-
- how a native app shows many worlds: one screen per ward, a switcher
|
|
28
|
-
between them, and the device's own beings offered to each through gates;
|
|
29
|
-
- what one avatar class, one `side` implementation and one suite look like
|
|
30
|
-
for a screen, proven on the memory harbor with no browser at all.
|
|
27
|
+
## What the twelfth sitting decided
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
the human is an occupant, reached through an avatar, seeing what the gate
|
|
34
|
-
shows.
|
|
29
|
+
Read with the trunk's "Carrying" and "The look", which it applies.
|
|
35
30
|
|
|
36
|
-
|
|
31
|
+
- **A form from a schema, without a designer.** An input schema is a form
|
|
32
|
+
with one field per property by type: a string is a text input, and
|
|
33
|
+
`password`, `date`, `email` and `time` are the input's type; a number is a
|
|
34
|
+
number input; a boolean a checkbox; an enum a select; an object or an
|
|
35
|
+
array a box for JSON. The submitted strings come back as arguments typed by
|
|
36
|
+
the schema, an empty optional field left out. A hint from her look puts a
|
|
37
|
+
title and an icon on the button and marks a destructive ask.
|
|
38
|
+
- **A view from an answer.** A primitive is text, an object a table of its
|
|
39
|
+
keys in the output schema's order when she declared one, a list of objects
|
|
40
|
+
one table, null the word nothing. An error object is marked; silence and
|
|
41
|
+
each of the ward's words say what the side says of them.
|
|
42
|
+
- **A standing is a section.** The user being carries her standings, and
|
|
43
|
+
her notes say which asks are whose. The page draws her own asks first,
|
|
44
|
+
then one section per standing, headed by that being's name and logo and
|
|
45
|
+
painted with her tokens as CSS variables on that section and nowhere
|
|
46
|
+
else. A form in acme's section is an ask on the human's standing at acme,
|
|
47
|
+
and acme sees the human, never the device. The same section appears on
|
|
48
|
+
acme's own web, under the same look: one component, two relations.
|
|
49
|
+
- **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.
|
|
53
|
+
- **What a look may carry, and never.** The trunk's table is the whole
|
|
54
|
+
list: a name, a logo as a data URI, three colours, a font, a radius, an
|
|
55
|
+
order, a hint per ask. No CSS, no URL, no script, ever. A token that fails
|
|
56
|
+
its shape is dropped. The page owns layout, typography and its own
|
|
57
|
+
palette; a far being paints inside her section and cannot reach out of it.
|
|
58
|
+
- **The tab shell.** One stylesheet honouring the variables, light and dark,
|
|
59
|
+
served by the daemon beside the bundle. Nothing else is designed.
|
|
60
|
+
- **A world is a ward with a public being, and the guest page is her
|
|
61
|
+
describe.** Whatever class she is. The tab knocks the ward's public
|
|
62
|
+
invitation, its pk alone, with the empty ask, and renders what she shows
|
|
63
|
+
strangers with this same screen: the desk shows `hello` and `device`, a
|
|
64
|
+
shop could show its availability, a garden its `enter`. A form on that
|
|
65
|
+
page is a knock on the public invitation, and an answer that is an
|
|
66
|
+
invitation is the way in: the side hands it to `admit`, the avatar joins,
|
|
67
|
+
and the page becomes hers. Nothing in the screen knows the word desk.
|
|
68
|
+
- **Worlds have addresses.** `web./<ward>` is that world's page, `web./`
|
|
69
|
+
lists the worlds, and a hostname per world is one proxy line, the
|
|
70
|
+
estate's choice. `quo.` stays one per harbor, since bytes route by pk.
|
|
71
|
+
- **A link is the page with the invitation in the fragment**, under the one
|
|
72
|
+
reserved key `quo`, the invitation compact as `ward.heir.secret` or
|
|
73
|
+
`ward` alone: `web.acme.com/shop#quo=...`. The fragment never leaves the
|
|
74
|
+
browser, where a query string would reach the server, its logs and every
|
|
75
|
+
referer. The tab reads it, strips it from the address bar before anything
|
|
76
|
+
else runs, and joins. A page's own routing keeps the query, the path and
|
|
77
|
+
the rest of the fragment; only `quo` is taken. `beings/link.ts`.
|
|
78
|
+
- **The tab is a browser of worlds.** One harbor per origin, one ward per
|
|
79
|
+
world joined, each on the tab's seed with one avatar, and a switcher over
|
|
80
|
+
them. A world on another origin is another page, and the switcher links
|
|
81
|
+
there.
|
|
82
|
+
- **The policy.** The page carries a content security policy: scripts from
|
|
83
|
+
its origin only and never inline, connections to its origin and the `quo.`
|
|
84
|
+
route it dials, images from data URIs and its origin, no frames, no base.
|
|
85
|
+
The config crosses in a JSON script, which the policy allows. A bug in a
|
|
86
|
+
renderer cannot become a script, and no look can reach a server.
|
|
37
87
|
|
|
38
|
-
The
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
`packages/dock/human/`, and the tab that holds them:
|
|
88
|
+
## The pieces
|
|
89
|
+
|
|
90
|
+
Three files under `packages/dock/human/`, and the tab that holds them:
|
|
42
91
|
|
|
43
92
|
- `html.ts` is pure, strings in and strings out, and is the screen's whole
|
|
44
|
-
vocabulary
|
|
45
|
-
|
|
46
|
-
`time` are the input's type; a number is a number input; a boolean is a
|
|
47
|
-
checkbox; an enum is a select; an object or an array is a box for JSON.
|
|
48
|
-
The submitted strings come back as arguments typed by the schema, an
|
|
49
|
-
empty optional field left out. An answer is a view: a primitive is text,
|
|
50
|
-
an object a table of its keys in the output schema's order when she
|
|
51
|
-
declared one, a list of objects one table, null the word nothing. The
|
|
52
|
-
three words for "no object" each have a face, an error object marked,
|
|
53
|
-
silence and each of the ward's words saying what the side says of them.
|
|
54
|
-
The notes may carry one hint, a string `name` that titles the page; the
|
|
55
|
-
rest is shown as a view and read as nothing else.
|
|
93
|
+
vocabulary: `form`, `values`, `view`, `face`, `look`, `page`. It reads the
|
|
94
|
+
look's vocabulary from `beings/look.ts` and adds none.
|
|
56
95
|
- `screen.ts` is the side: one avatar and one surface, where a surface can
|
|
57
|
-
only show a page and hand back a submitted form.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
describe that fails leaves the last page standing and says so in
|
|
62
|
-
notice.
|
|
96
|
+
only show a page and hand back a submitted form. It keeps the model,
|
|
97
|
+
speaks it as a page after every change, calls her when a form comes back,
|
|
98
|
+
re-asks her describe after every call, asks her look once per digest, and
|
|
99
|
+
runs her read-only asks on open. A push is appended and shown as it
|
|
100
|
+
lands. A describe that fails leaves the last page standing and says so in
|
|
101
|
+
the notice.
|
|
63
102
|
- `dom.ts` is the surface on an element, the one file that touches one.
|
|
64
|
-
- `
|
|
65
|
-
|
|
103
|
+
- `guest.ts` is a world's public being as a subject the screen renders:
|
|
104
|
+
her describe for a stranger, a form as a knock on the public invitation.
|
|
105
|
+
- `tab.ts` is the harbor in the tab, the worlds and the three ways in, and
|
|
106
|
+
hands the avatar to the side.
|
|
66
107
|
|
|
67
108
|
Proven in `packages/dock/test/human.test.ts` on the memory harbor with a fake
|
|
68
|
-
surface and no browser
|
|
69
|
-
|
|
109
|
+
surface and no browser: the user being carrying a shop with a look, a guest
|
|
110
|
+
at a door that is not a desk let in by a form, and the link read, stripped
|
|
111
|
+
and refused; and in `packages/dock/test/terrain/browser.test.ts` in a real
|
|
112
|
+
Chromium against a daemon on loopback, behind `npm run check:terrain`: the
|
|
113
|
+
guest page, the password way in, a push, a reload, and the policy header.
|
|
114
|
+
|
|
115
|
+
## Security, by structure
|
|
70
116
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
117
|
+
- Nothing executable crosses. What a screen receives is I-JSON, by the
|
|
118
|
+
spec: asks, args, answers, the three words, and a look. Every string is
|
|
119
|
+
escaped before it is written into the page; a JSON box is parsed and never
|
|
120
|
+
evaluated.
|
|
121
|
+
- A look is a closed vocabulary held to shapes. A logo is a data URI, so no
|
|
122
|
+
request leaves the page towards a far being's server; a colour is a hex
|
|
123
|
+
colour; there is no token for a stylesheet, a URL or a script.
|
|
124
|
+
- The gate is the permission. What a page can ask is what the being shows
|
|
125
|
+
that device, already. No page adds a right, and a form for an ask she does
|
|
126
|
+
not show is nothing.
|
|
127
|
+
- Origin is the boundary. A world's own page runs only on that world's
|
|
128
|
+
origin; on the human's own web only the dock's bundle and the estate's
|
|
129
|
+
classes run. A world can only ever damage itself.
|
|
75
130
|
|
|
76
131
|
## The tab
|
|
77
132
|
|
|
78
|
-
The tab is a device. The page boots a browser harbor on a
|
|
79
|
-
the tab's store the first time, one ward
|
|
80
|
-
the world's `quo.` route.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
and
|
|
133
|
+
The tab is a device. The page at `web./<ward>` boots a browser harbor on a
|
|
134
|
+
seed minted into the tab's store the first time, one ward for this world
|
|
135
|
+
with one avatar in it, and dials the world's `quo.` route. Three ways in,
|
|
136
|
+
one act: a link's invitation, joined at once; a guest form on the public
|
|
137
|
+
being's page whose answer is an invitation; and, when the desk is at the
|
|
138
|
+
door, the dock's own way, the owner password to `web./<ward>/login` for a
|
|
139
|
+
nonce, and the desk's `device` form submitted with it under the `tab` proof
|
|
140
|
+
kind. A tab opened with the owner password reaches what she holds, since the
|
|
141
|
+
owner opened it. From then on there is no cookie and no token anywhere, and
|
|
85
142
|
a reload asks nothing: the seed and the standing are in the tab's store.
|
|
86
|
-
Live on the lab at `web.lab.quo.systems/
|
|
143
|
+
Live on the lab at `web.lab.quo.systems/main`.
|
package/human/screen.ts
CHANGED
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
// moved. A push is appended and shown the moment it lands.
|
|
9
9
|
import { digest } from '@quo-systems/quo';
|
|
10
10
|
import type { Blueprint, JsonObject } from '@quo-systems/quo';
|
|
11
|
-
import type
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
11
|
+
import { word, type Serving, type Subject } from '../beings/side.ts';
|
|
12
|
+
import { isInvitation } from '../beings/link.ts';
|
|
13
|
+
import type { Invitation } from '@quo-systems/quo';
|
|
14
|
+
import { page, values, hintFor, type Model, type Raw } from './html.ts';
|
|
15
|
+
import { sanitise } from '../beings/look.ts';
|
|
16
|
+
import { isSilence, isWord } from '@quo-systems/quo';
|
|
17
|
+
import type { Json } from '@quo-systems/quo';
|
|
14
18
|
|
|
15
19
|
export type Surface = {
|
|
16
20
|
show(html: string): void;
|
|
@@ -20,9 +24,14 @@ export type Surface = {
|
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
// `after` runs when a call is done, as it does for the model side: a harbor
|
|
23
|
-
// that must write what the ward changed hooks it.
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
// that must write what the ward changed hooks it. `admit` is what to do
|
|
28
|
+
// with an answer that is an invitation: a guest at a world's door is let in
|
|
29
|
+
// by it, and a side with no `admit` shows it as any answer.
|
|
30
|
+
export type Options = { after?: () => Promise<void>; notice?: string; admit?: (invitation: Invitation) => Promise<void> };
|
|
31
|
+
export async function screenSide(avatar: Subject, surface: Surface, options: Options = {}): Promise<Serving & { model: Model; refresh(): Promise<void> }> {
|
|
32
|
+
const after = options.after ?? (async () => {});
|
|
33
|
+
const notice = options.notice ?? '';
|
|
34
|
+
const model: Model = { blueprint: null, look: {}, notice, answers: {}, pushes: [] };
|
|
26
35
|
let seen: string | null = null;
|
|
27
36
|
const show = () => surface.show(page(model));
|
|
28
37
|
|
|
@@ -39,6 +48,16 @@ export async function screenSide(avatar: Avatar, surface: Surface, after: () =>
|
|
|
39
48
|
seen = d;
|
|
40
49
|
model.blueprint = bp as Blueprint;
|
|
41
50
|
for (const k of Object.keys(model.answers)) if (!(bp as Blueprint).asks.some((a) => a.name === k)) delete model.answers[k];
|
|
51
|
+
// her look, once per digest: how she wants her page painted, and what each ask is
|
|
52
|
+
if ((bp as Blueprint).asks.some((a) => a.name === 'look')) {
|
|
53
|
+
const l = await avatar.call('look');
|
|
54
|
+
model.look = isSilence(l) || isWord(l) ? {} : sanitise(l as Json);
|
|
55
|
+
} else model.look = {};
|
|
56
|
+
// a read-only ask that needs nothing typed is run on her behalf, so the page opens with what it shows
|
|
57
|
+
for (const a of (bp as Blueprint).asks) {
|
|
58
|
+
const needs = ((a.input as { required?: string[] }).required ?? []).length > 0;
|
|
59
|
+
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, {}));
|
|
60
|
+
}
|
|
42
61
|
}
|
|
43
62
|
}
|
|
44
63
|
show();
|
|
@@ -53,8 +72,10 @@ export async function screenSide(avatar: Avatar, surface: Surface, after: () =>
|
|
|
53
72
|
model.answers[name] = { word: 'error', value: { error: v.error } };
|
|
54
73
|
return show();
|
|
55
74
|
}
|
|
56
|
-
|
|
75
|
+
const out = await avatar.call(name, v.args);
|
|
57
76
|
await after();
|
|
77
|
+
if (options.admit && isInvitation(out)) return options.admit(out);
|
|
78
|
+
model.answers[name] = word(out);
|
|
58
79
|
await refresh();
|
|
59
80
|
};
|
|
60
81
|
|