@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/human/html.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SILENCE_TEXT, UNREACHED_TEXT, wordText } from '../beings/side.js';
|
|
2
|
+
import { hint, hintFor, sanitise, groups as grouped } from '../beings/look.js';
|
|
2
3
|
export const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
3
4
|
const props = (schema) => Object.entries((schema?.properties ?? {}));
|
|
4
5
|
const required = (schema) => new Set((schema?.required ?? []));
|
|
@@ -28,12 +29,14 @@ export function field(name, p, must) {
|
|
|
28
29
|
}
|
|
29
30
|
// The ask as a form: its name is the button, its description the legend's
|
|
30
31
|
// small print, and `data-ask` is how the surface says which ask was sent.
|
|
31
|
-
export function form(ask) {
|
|
32
|
+
export function form(ask, h = {}) {
|
|
32
33
|
const must = required(ask.input);
|
|
33
34
|
const fields = props(ask.input)
|
|
34
35
|
.map(([name, p]) => field(name, p, must.has(name)))
|
|
35
36
|
.join('');
|
|
36
|
-
|
|
37
|
+
const label = `${h.icon ? `${escape(h.icon)} ` : ''}${escape(h.title ?? ask.name)}`;
|
|
38
|
+
const care = h.destructive ? ' data-confirm="true"' : '';
|
|
39
|
+
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>`;
|
|
37
40
|
}
|
|
38
41
|
// The strings typed by the schema. An empty field that is not required is
|
|
39
42
|
// left out, so the being sees what the human said and nothing else; an
|
|
@@ -116,21 +119,69 @@ export function face(w, schema) {
|
|
|
116
119
|
}
|
|
117
120
|
// The title is the one hint the notes may carry: a string named `name`.
|
|
118
121
|
// The rest of the notes is shown as a view and read as nothing else.
|
|
119
|
-
export function title(bp) {
|
|
122
|
+
export function title(bp, l = {}) {
|
|
123
|
+
if (l.name)
|
|
124
|
+
return l.name;
|
|
120
125
|
const n = bp?.notes;
|
|
121
126
|
return n !== null && typeof n === 'object' && !Array.isArray(n) && typeof n.name === 'string' && n.name ? n.name : 'quo';
|
|
122
127
|
}
|
|
128
|
+
// ---- a look
|
|
129
|
+
// A look as the page paints it: the tokens become CSS variables on one
|
|
130
|
+
// section, and the name and the logo its heading. The shape of every token
|
|
131
|
+
// is the dock's, `beings/look.ts`; this only writes what survived it.
|
|
132
|
+
export function look(l) {
|
|
133
|
+
if (!l)
|
|
134
|
+
return { style: '', head: '' };
|
|
135
|
+
const vars = [];
|
|
136
|
+
if (l.accent)
|
|
137
|
+
vars.push(`--accent:${l.accent}`);
|
|
138
|
+
if (l.background)
|
|
139
|
+
vars.push(`--bg:${l.background}`);
|
|
140
|
+
if (l.foreground)
|
|
141
|
+
vars.push(`--fg:${l.foreground}`);
|
|
142
|
+
if (l.font)
|
|
143
|
+
vars.push(`--font:${l.font}`);
|
|
144
|
+
if (l.radius !== undefined)
|
|
145
|
+
vars.push(`--radius:${l.radius}px`);
|
|
146
|
+
const logo = l.logo ? `<img class="logo" alt="" src="${l.logo}">` : '';
|
|
147
|
+
const name = l.name ? escape(l.name) : '';
|
|
148
|
+
return { style: vars.length ? ` style="${vars.join(';')}"` : '', head: logo || name ? `<h2>${logo}${name}</h2>` : '' };
|
|
149
|
+
}
|
|
150
|
+
// Her asks in the order her look asks for, the rest after in her own order.
|
|
151
|
+
export function ordered(asks, l) {
|
|
152
|
+
const want = l?.order ?? [];
|
|
153
|
+
return [...want.map((n) => asks.find((a) => a.name === n)).filter((a) => a !== undefined), ...asks.filter((a) => !want.includes(a.name))];
|
|
154
|
+
}
|
|
155
|
+
export { hintFor };
|
|
123
156
|
export function page(m) {
|
|
124
157
|
const bp = m.blueprint;
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
158
|
+
const one = (l, prefix = '') => (a) => {
|
|
159
|
+
const w = m.answers[a.name];
|
|
160
|
+
const h = hint(l, a.name.startsWith(prefix) ? a.name.slice(prefix.length) : a.name);
|
|
161
|
+
return `<section>${form(a, h)}${w ? face(w, a.output) : ''}</section>`;
|
|
162
|
+
};
|
|
163
|
+
const groups = grouped(bp);
|
|
164
|
+
const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
|
|
165
|
+
const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && a.name !== 'look'), m.look).map(one(m.look)).join('') : '';
|
|
166
|
+
const far = bp
|
|
167
|
+
? Object.entries(groups)
|
|
168
|
+
.map(([id, g]) => {
|
|
169
|
+
const asks = bp.asks.filter((a) => g.asks?.includes(a.name));
|
|
170
|
+
if (!asks.length)
|
|
171
|
+
return '';
|
|
172
|
+
const kept = sanitise(g.look);
|
|
173
|
+
const l = look(kept);
|
|
174
|
+
const prefix = `${id}-`;
|
|
175
|
+
const want = (kept.order ?? []).map((n) => prefix + n);
|
|
176
|
+
const inOrder = ordered(asks, { order: want });
|
|
177
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
|
|
130
178
|
})
|
|
131
179
|
.join('')
|
|
132
180
|
: '';
|
|
133
|
-
const
|
|
181
|
+
const asks = own + far;
|
|
182
|
+
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;
|
|
183
|
+
const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown)}</aside>` : '';
|
|
134
184
|
const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
|
|
135
|
-
|
|
185
|
+
const mine = look(m.look);
|
|
186
|
+
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}`;
|
|
136
187
|
}
|
package/dist/human/screen.d.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
1
|
+
import { type Serving, type Subject } from '../beings/side.ts';
|
|
2
|
+
import type { Invitation } from '@quo-systems/quo';
|
|
3
3
|
import { type Model, type Raw } from './html.ts';
|
|
4
4
|
export type Surface = {
|
|
5
5
|
show(html: string): void;
|
|
6
6
|
onSubmit: ((ask: string, raw: Raw) => void) | undefined;
|
|
7
7
|
};
|
|
8
|
-
export
|
|
8
|
+
export type Options = {
|
|
9
|
+
after?: () => Promise<void>;
|
|
10
|
+
notice?: string;
|
|
11
|
+
admit?: (invitation: Invitation) => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
export declare function screenSide(avatar: Subject, surface: Surface, options?: Options): Promise<Serving & {
|
|
9
14
|
model: Model;
|
|
10
15
|
refresh(): Promise<void>;
|
|
11
16
|
}>;
|
package/dist/human/screen.js
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
// moved. A push is appended and shown the moment it lands.
|
|
9
9
|
import { digest } from '@quo-systems/quo';
|
|
10
10
|
import { word } from '../beings/side.js';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
import { isInvitation } from '../beings/link.js';
|
|
12
|
+
import { page, values, hintFor } from './html.js';
|
|
13
|
+
import { sanitise } from '../beings/look.js';
|
|
14
|
+
import { isSilence, isWord } from '@quo-systems/quo';
|
|
15
|
+
export async function screenSide(avatar, surface, options = {}) {
|
|
16
|
+
const after = options.after ?? (async () => { });
|
|
17
|
+
const notice = options.notice ?? '';
|
|
18
|
+
const model = { blueprint: null, look: {}, notice, answers: {}, pushes: [] };
|
|
16
19
|
let seen = null;
|
|
17
20
|
const show = () => surface.show(page(model));
|
|
18
21
|
// Her describe, again: the page follows the digest.
|
|
@@ -31,6 +34,19 @@ export async function screenSide(avatar, surface, after = async () => { }, notic
|
|
|
31
34
|
for (const k of Object.keys(model.answers))
|
|
32
35
|
if (!bp.asks.some((a) => a.name === k))
|
|
33
36
|
delete model.answers[k];
|
|
37
|
+
// her look, once per digest: how she wants her page painted, and what each ask is
|
|
38
|
+
if (bp.asks.some((a) => a.name === 'look')) {
|
|
39
|
+
const l = await avatar.call('look');
|
|
40
|
+
model.look = isSilence(l) || isWord(l) ? {} : sanitise(l);
|
|
41
|
+
}
|
|
42
|
+
else
|
|
43
|
+
model.look = {};
|
|
44
|
+
// a read-only ask that needs nothing typed is run on her behalf, so the page opens with what it shows
|
|
45
|
+
for (const a of bp.asks) {
|
|
46
|
+
const needs = (a.input.required ?? []).length > 0;
|
|
47
|
+
if (hintFor(bp, model.look, a.name).readOnly && !needs && !(a.name in model.answers))
|
|
48
|
+
model.answers[a.name] = word(await avatar.call(a.name, {}));
|
|
49
|
+
}
|
|
34
50
|
}
|
|
35
51
|
}
|
|
36
52
|
show();
|
|
@@ -45,8 +61,11 @@ export async function screenSide(avatar, surface, after = async () => { }, notic
|
|
|
45
61
|
model.answers[name] = { word: 'error', value: { error: v.error } };
|
|
46
62
|
return show();
|
|
47
63
|
}
|
|
48
|
-
|
|
64
|
+
const out = await avatar.call(name, v.args);
|
|
49
65
|
await after();
|
|
66
|
+
if (options.admit && isInvitation(out))
|
|
67
|
+
return options.admit(out);
|
|
68
|
+
model.answers[name] = word(out);
|
|
50
69
|
await refresh();
|
|
51
70
|
};
|
|
52
71
|
const ear = (object) => {
|
package/dist/human/tab.d.ts
CHANGED
package/dist/human/tab.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import { BrowserHarbor } from '../harbor/browser.js';
|
|
2
2
|
import { Avatar, USER } from '../beings/avatar.js';
|
|
3
|
+
import { parse, strip, isInvitation } from '../beings/link.js';
|
|
3
4
|
import { screenSide } from './screen.js';
|
|
4
5
|
import { domSurface } from './dom.js';
|
|
6
|
+
import { guest } from './guest.js';
|
|
7
|
+
import { escape } from './html.js';
|
|
8
|
+
const WORLDS = 'quo-worlds';
|
|
9
|
+
const worlds = () => {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(localStorage.getItem(WORLDS) ?? '{}');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const remember = (pk, url, name) => {
|
|
18
|
+
try {
|
|
19
|
+
localStorage.setItem(WORLDS, JSON.stringify({ ...worlds(), [pk]: { url, name } }));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
/* a tab with no storage still works, and forgets */
|
|
23
|
+
}
|
|
24
|
+
};
|
|
5
25
|
const el = (tag, text = '', attrs = {}) => {
|
|
6
26
|
const e = document.createElement(tag);
|
|
7
27
|
if (text)
|
|
@@ -11,58 +31,123 @@ const el = (tag, text = '', attrs = {}) => {
|
|
|
11
31
|
return e;
|
|
12
32
|
};
|
|
13
33
|
export async function start(cfg, root = document.body) {
|
|
34
|
+
// The link, first and once: the invitation leaves the address bar before
|
|
35
|
+
// any other code sees it.
|
|
36
|
+
const linked = parse(location.hash);
|
|
37
|
+
if (linked)
|
|
38
|
+
history.replaceState(null, '', location.pathname + location.search + strip(location.hash));
|
|
39
|
+
const nav = el('nav', '', { class: 'worlds' });
|
|
14
40
|
const status = el('p', 'booting');
|
|
15
41
|
const screen = el('div');
|
|
16
|
-
root.append(status, screen);
|
|
42
|
+
root.append(nav, status, screen);
|
|
17
43
|
const say = (s) => (status.textContent = s);
|
|
18
|
-
// The harbor in the tab: one database
|
|
44
|
+
// The harbor in the tab: one database per origin, one ward per world.
|
|
19
45
|
const harbor = new BrowserHarbor('quo');
|
|
20
46
|
await harbor.boot();
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
47
|
+
const name = `w-${cfg.pk.slice(0, 16)}`;
|
|
48
|
+
const ward = harbor.wards.get(name) ?? (await harbor.create(name, 'me'));
|
|
49
|
+
if (!ward.being('me'))
|
|
50
|
+
await ward.ask('boot', { key: 'me', class: 'Avatar' });
|
|
51
|
+
const me = ward.being('me');
|
|
25
52
|
harbor.dial(cfg.quo);
|
|
26
|
-
|
|
53
|
+
const switcher = () => {
|
|
54
|
+
nav.replaceChildren();
|
|
55
|
+
const known = worlds();
|
|
56
|
+
for (const [pk, w] of Object.entries(known)) {
|
|
57
|
+
const a = el('a', w.name || pk.slice(0, 8), { href: w.url });
|
|
58
|
+
if (pk === cfg.pk)
|
|
59
|
+
a.setAttribute('aria-current', 'page');
|
|
60
|
+
nav.append(a);
|
|
61
|
+
}
|
|
62
|
+
const here = worlds()[cfg.pk];
|
|
63
|
+
if (!here)
|
|
64
|
+
nav.append(el('span', `${cfg.ward} at ${new URL(cfg.web).host}`));
|
|
65
|
+
};
|
|
66
|
+
switcher();
|
|
67
|
+
// Joined: her page. Every call rotates her keys and a same-ward ask never
|
|
27
68
|
// crosses the harbor, so the side saves after each one.
|
|
28
|
-
const
|
|
69
|
+
const inside = async (notice) => {
|
|
29
70
|
status.remove();
|
|
30
|
-
|
|
71
|
+
screen.replaceChildren();
|
|
72
|
+
const side = await screenSide(me, domSurface(screen), { after: () => ward.save(), notice });
|
|
73
|
+
remember(cfg.pk, location.origin + location.pathname, side.model.look.name || (typeof side.model.blueprint?.notes?.name === 'string' ? side.model.blueprint.notes.name : cfg.ward));
|
|
74
|
+
switcher();
|
|
31
75
|
};
|
|
32
|
-
//
|
|
33
|
-
const
|
|
34
|
-
if ('asks' in had)
|
|
35
|
-
return show('in, as before');
|
|
36
|
-
// The exchange, once: the owner password for a nonce, the nonce for an
|
|
37
|
-
// invitation the user being mints, the invitation for a standing.
|
|
38
|
-
const form = el('form');
|
|
39
|
-
const identity = el('input', '', { name: 'identity', value: 'tab', placeholder: 'identity' });
|
|
40
|
-
const password = el('input', '', { name: 'password', type: 'password', placeholder: 'owner password' });
|
|
41
|
-
const go = el('button', 'enter');
|
|
42
|
-
form.append(identity, password, go);
|
|
43
|
-
root.insertBefore(form, screen);
|
|
44
|
-
say(`not in (${had.error}): the owner password opens the world`);
|
|
45
|
-
form.onsubmit = async (ev) => {
|
|
46
|
-
ev.preventDefault();
|
|
47
|
-
say('asking the world');
|
|
48
|
-
let res;
|
|
49
|
-
try {
|
|
50
|
-
res = await fetch(`${cfg.web}/tab/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: password.value, identity: identity.value }) });
|
|
51
|
-
}
|
|
52
|
-
catch (e) {
|
|
53
|
-
return say(`the world did not answer: ${e.message}`);
|
|
54
|
-
}
|
|
55
|
-
if (!res.ok)
|
|
56
|
-
return say(`refused: ${(await res.json().catch(() => ({ error: res.status }))).error ?? res.status}`);
|
|
57
|
-
const { nonce, ward } = (await res.json());
|
|
58
|
-
password.value = '';
|
|
76
|
+
// The way in, from any of the three: join, save, and be inside.
|
|
77
|
+
const admit = async (inv, notice) => {
|
|
59
78
|
say('knocking');
|
|
60
|
-
const got = await me.
|
|
61
|
-
await
|
|
62
|
-
if (!('asks' in got))
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
const got = await me.join(inv);
|
|
80
|
+
await ward.save(); // the knock went through her own door, which the harbor never sees
|
|
81
|
+
if (!('asks' in got)) {
|
|
82
|
+
say(`not in: ${got.error}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
await inside(notice);
|
|
66
86
|
};
|
|
87
|
+
// Already in: reconnect, nothing minted.
|
|
88
|
+
if (me.standings[USER]) {
|
|
89
|
+
const had = await me.tools();
|
|
90
|
+
if ('asks' in had)
|
|
91
|
+
return inside('in, as before');
|
|
92
|
+
say(`not in (${had.error})`);
|
|
93
|
+
}
|
|
94
|
+
if (linked)
|
|
95
|
+
return admit(linked, 'in, by the link');
|
|
96
|
+
// A guest: the public being's describe as a page; a form whose answer is
|
|
97
|
+
// an invitation lets the guest in.
|
|
98
|
+
say(`at the door of ${cfg.ward}`);
|
|
99
|
+
const door = guest(me, cfg.pk);
|
|
100
|
+
const side = await screenSide(door, domSurface(screen), {
|
|
101
|
+
after: () => ward.save(),
|
|
102
|
+
notice: `a guest at ${cfg.ward}: what she shows strangers`,
|
|
103
|
+
admit: async (inv) => {
|
|
104
|
+
await side.close();
|
|
105
|
+
await admit(inv, `in, as a guest of ${cfg.ward}`);
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
// The dock's own way in, when the desk is at the door: the owner password
|
|
109
|
+
// for a nonce, and the desk's `device` form submitted with it.
|
|
110
|
+
if (side.model.blueprint?.asks.some((a) => a.name === 'device')) {
|
|
111
|
+
const form = el('form', '', { class: 'password' });
|
|
112
|
+
const identity = el('input', '', { name: 'identity', value: 'tab', placeholder: 'identity' });
|
|
113
|
+
const password = el('input', '', { name: 'password', type: 'password', placeholder: 'owner password' });
|
|
114
|
+
const go = el('button', 'enter as the owner');
|
|
115
|
+
form.append(identity, password, go);
|
|
116
|
+
root.insertBefore(form, screen);
|
|
117
|
+
form.onsubmit = (ev) => {
|
|
118
|
+
ev.preventDefault();
|
|
119
|
+
void (async () => {
|
|
120
|
+
say('asking the world');
|
|
121
|
+
let res;
|
|
122
|
+
try {
|
|
123
|
+
res = await fetch(`${cfg.web}/${encodeURIComponent(cfg.ward)}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: password.value, identity: identity.value }) });
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
say(`the world did not answer: ${e.message}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
password.value = '';
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
say(`refused: ${(await res.json().catch(() => ({ error: res.status }))).error ?? res.status}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const { nonce } = (await res.json());
|
|
135
|
+
const inv = await door.call('device', { proof: { kind: 'tab', nonce } });
|
|
136
|
+
await ward.save();
|
|
137
|
+
if (!isInvitation(inv)) {
|
|
138
|
+
say(`not in: ${escape(JSON.stringify(inv))}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
form.remove();
|
|
142
|
+
await side.close();
|
|
143
|
+
await admit(inv, `in, as ${identity.value}`);
|
|
144
|
+
})();
|
|
145
|
+
};
|
|
146
|
+
}
|
|
67
147
|
}
|
|
148
|
+
// The page hands the config in a JSON script, which a content security
|
|
149
|
+
// policy allows where an inline script is not.
|
|
150
|
+
const config = document.getElementById('quo');
|
|
151
|
+
if (config?.textContent)
|
|
152
|
+
void start(JSON.parse(config.textContent));
|
|
68
153
|
export { USER };
|
package/dist/mcp/http.d.ts
CHANGED
|
@@ -4,12 +4,13 @@ import type { Avatar } from '../beings/avatar.ts';
|
|
|
4
4
|
import type { Serving } from '../beings/side.ts';
|
|
5
5
|
export type Session = {
|
|
6
6
|
identity: string;
|
|
7
|
+
ward: string;
|
|
7
8
|
transport: StreamableHTTPServerTransport;
|
|
8
9
|
serving: Serving;
|
|
9
10
|
touched: number;
|
|
10
11
|
};
|
|
11
12
|
export declare const SESSION_IDLE: number;
|
|
12
|
-
export type Resolve = (identity: string) => Promise<{
|
|
13
|
+
export type Resolve = (identity: string, ward: string) => Promise<{
|
|
13
14
|
avatar?: Avatar;
|
|
14
15
|
error?: string;
|
|
15
16
|
}>;
|
|
@@ -18,9 +19,9 @@ export declare class McpHttp {
|
|
|
18
19
|
readonly resolve: Resolve;
|
|
19
20
|
readonly after: () => Promise<void>;
|
|
20
21
|
now: () => number;
|
|
21
|
-
gone: (identity: string) => Promise<void>;
|
|
22
|
+
gone: (identity: string, ward: string) => Promise<void>;
|
|
22
23
|
constructor(resolve: Resolve, after?: () => Promise<void>);
|
|
23
|
-
handle(req: IncomingMessage, res: ServerResponse, identity: string): Promise<void>;
|
|
24
|
+
handle(req: IncomingMessage, res: ServerResponse, identity: string, ward?: string): Promise<void>;
|
|
24
25
|
sweep(): void;
|
|
25
26
|
drop(sid: string): void;
|
|
26
27
|
close(): Promise<void>;
|
package/dist/mcp/http.js
CHANGED
|
@@ -28,7 +28,7 @@ export class McpHttp {
|
|
|
28
28
|
// `identity` is what the bearer named; the caller has already turned a
|
|
29
29
|
// stranger away. A request on a known session goes to it. A request with
|
|
30
30
|
// no session opens one, if it is an initialize; anything else is 400.
|
|
31
|
-
async handle(req, res, identity) {
|
|
31
|
+
async handle(req, res, identity, ward = 'main') {
|
|
32
32
|
const id = req.headers['mcp-session-id'];
|
|
33
33
|
const sid = Array.isArray(id) ? id[0] : id;
|
|
34
34
|
const json = (status, body) => {
|
|
@@ -40,7 +40,7 @@ export class McpHttp {
|
|
|
40
40
|
const s = this.sessions.get(sid);
|
|
41
41
|
if (!s)
|
|
42
42
|
return json(404, { jsonrpc: '2.0', error: { code: -32001, message: 'no such session' }, id: null });
|
|
43
|
-
if (s.identity !== identity)
|
|
43
|
+
if (s.identity !== identity || s.ward !== ward)
|
|
44
44
|
return json(403, { jsonrpc: '2.0', error: { code: -32003, message: 'not your session' }, id: null });
|
|
45
45
|
s.touched = this.now();
|
|
46
46
|
await s.transport.handleRequest(req, res);
|
|
@@ -50,16 +50,16 @@ export class McpHttp {
|
|
|
50
50
|
}
|
|
51
51
|
if (req.method !== 'POST')
|
|
52
52
|
return json(400, { jsonrpc: '2.0', error: { code: -32000, message: 'no session' }, id: null });
|
|
53
|
-
const found = await this.resolve(identity);
|
|
53
|
+
const found = await this.resolve(identity, ward);
|
|
54
54
|
// A session opens on her describe, and admit already asked it. The one
|
|
55
55
|
// word the door says for an identity the user being removed is
|
|
56
56
|
// `removed`, under the key it bound for her avatar: in MCP's vocabulary
|
|
57
57
|
// that is 401, the client drops its token and starts the exchange again,
|
|
58
58
|
// and the route forgets the grant.
|
|
59
59
|
if (found.error === 'removed') {
|
|
60
|
-
await this.gone(identity);
|
|
60
|
+
await this.gone(identity, ward);
|
|
61
61
|
for (const [sid, s] of this.sessions)
|
|
62
|
-
if (s.identity === identity)
|
|
62
|
+
if (s.identity === identity && s.ward === ward)
|
|
63
63
|
this.drop(sid);
|
|
64
64
|
res.writeHead(401, { 'content-type': 'application/json' });
|
|
65
65
|
return void res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'removed: the user being removed this identity' }, id: null }));
|
|
@@ -69,7 +69,7 @@ export class McpHttp {
|
|
|
69
69
|
const transport = new StreamableHTTPServerTransport({
|
|
70
70
|
sessionIdGenerator: () => randomUUID(),
|
|
71
71
|
onsessioninitialized: (s) => {
|
|
72
|
-
this.sessions.set(s, { identity, transport, serving, touched: this.now() });
|
|
72
|
+
this.sessions.set(s, { identity, ward, transport, serving, touched: this.now() });
|
|
73
73
|
},
|
|
74
74
|
onsessionclosed: (s) => this.drop(s),
|
|
75
75
|
});
|
package/dist/mcp/oauth.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type Pending = {
|
|
|
18
18
|
};
|
|
19
19
|
export type Grant = {
|
|
20
20
|
identity: string;
|
|
21
|
+
ward: string;
|
|
21
22
|
client_id: string;
|
|
22
23
|
exp: number;
|
|
23
24
|
};
|
|
@@ -26,6 +27,7 @@ export type Store = {
|
|
|
26
27
|
pending: Record<string, Pending>;
|
|
27
28
|
codes: Record<string, Pending & {
|
|
28
29
|
identity: string;
|
|
30
|
+
ward: string;
|
|
29
31
|
}>;
|
|
30
32
|
access: Record<string, Grant>;
|
|
31
33
|
refresh: Record<string, Grant>;
|
|
@@ -72,7 +74,7 @@ export declare class OAuth {
|
|
|
72
74
|
pending(id: string): (Pending & {
|
|
73
75
|
client: Client;
|
|
74
76
|
}) | null;
|
|
75
|
-
complete(id: string, identity: string): Promise<{
|
|
77
|
+
complete(id: string, identity: string, ward?: string): Promise<{
|
|
76
78
|
redirect: string;
|
|
77
79
|
} | {
|
|
78
80
|
error: string;
|
|
@@ -83,15 +85,18 @@ export declare class OAuth {
|
|
|
83
85
|
error: string;
|
|
84
86
|
}>;
|
|
85
87
|
token(body: URLSearchParams): Promise<Record<string, unknown>>;
|
|
86
|
-
issue(identity: string, client_id: string): Promise<{
|
|
88
|
+
issue(identity: string, ward: string, client_id: string): Promise<{
|
|
87
89
|
access_token: string;
|
|
88
90
|
token_type: string;
|
|
89
91
|
expires_in: number;
|
|
90
92
|
refresh_token: string;
|
|
91
93
|
scope: string;
|
|
92
94
|
}>;
|
|
93
|
-
bearer(req: IncomingMessage):
|
|
94
|
-
|
|
95
|
+
bearer(req: IncomingMessage): {
|
|
96
|
+
identity: string;
|
|
97
|
+
ward: string;
|
|
98
|
+
} | null;
|
|
99
|
+
revoke(identity: string, ward?: string): Promise<void>;
|
|
95
100
|
sweep(): void;
|
|
96
101
|
handle(req: IncomingMessage, res: ServerResponse, rest: string): Promise<boolean>;
|
|
97
102
|
challenge(res: ServerResponse): void;
|
package/dist/mcp/oauth.js
CHANGED
|
@@ -99,14 +99,15 @@ export class OAuth {
|
|
|
99
99
|
const client = this.client(p.client_id);
|
|
100
100
|
return client ? { ...p, client } : null;
|
|
101
101
|
}
|
|
102
|
-
// The web route's last step: the human allowed this client as this
|
|
103
|
-
|
|
102
|
+
// The web route's last step: the human allowed this client as this
|
|
103
|
+
// identity, into this world.
|
|
104
|
+
async complete(id, identity, ward = 'main') {
|
|
104
105
|
const p = this.pending(id);
|
|
105
106
|
if (!p)
|
|
106
107
|
return { error: 'expired' };
|
|
107
108
|
delete this.store.pending[id];
|
|
108
109
|
const code = token();
|
|
109
|
-
this.store.codes[code] = { ...p, identity, exp: this.now() + CODE_TTL };
|
|
110
|
+
this.store.codes[code] = { ...p, identity, ward, exp: this.now() + CODE_TTL };
|
|
110
111
|
await this.o.persist(this.store);
|
|
111
112
|
return { redirect: withQuery(p.redirect_uri, { code, state: p.state }) };
|
|
112
113
|
}
|
|
@@ -131,7 +132,7 @@ export class OAuth {
|
|
|
131
132
|
await this.o.persist(this.store);
|
|
132
133
|
return { error: 'invalid_grant' };
|
|
133
134
|
}
|
|
134
|
-
return this.issue(c.identity, c.client_id);
|
|
135
|
+
return this.issue(c.identity, c.ward ?? 'main', c.client_id);
|
|
135
136
|
}
|
|
136
137
|
if (grant === 'refresh_token') {
|
|
137
138
|
const rt = body.get('refresh_token') ?? '';
|
|
@@ -139,14 +140,14 @@ export class OAuth {
|
|
|
139
140
|
if (!r || r.exp < this.now())
|
|
140
141
|
return { error: 'invalid_grant' };
|
|
141
142
|
delete this.store.refresh[rt]; // rotated: the old one is gone with the new one's birth
|
|
142
|
-
return this.issue(r.identity, r.client_id);
|
|
143
|
+
return this.issue(r.identity, r.ward ?? 'main', r.client_id);
|
|
143
144
|
}
|
|
144
145
|
return { error: 'unsupported_grant_type' };
|
|
145
146
|
}
|
|
146
|
-
async issue(identity, client_id) {
|
|
147
|
+
async issue(identity, ward, client_id) {
|
|
147
148
|
const access_token = token(), refresh_token = token();
|
|
148
|
-
this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
|
|
149
|
-
this.store.refresh[refresh_token] = { identity, client_id, exp: this.now() + REFRESH_TTL };
|
|
149
|
+
this.store.access[access_token] = { identity, ward, client_id, exp: this.now() + ACCESS_TTL };
|
|
150
|
+
this.store.refresh[refresh_token] = { identity, ward, client_id, exp: this.now() + REFRESH_TTL };
|
|
150
151
|
const c = own(this.store.clients, client_id);
|
|
151
152
|
if (c)
|
|
152
153
|
c.exp = this.now() + REFRESH_TTL; // allowed: the client lives as long as what it was granted
|
|
@@ -154,19 +155,19 @@ export class OAuth {
|
|
|
154
155
|
await this.o.persist(this.store);
|
|
155
156
|
return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
|
|
156
157
|
}
|
|
157
|
-
// The bearer on an MCP request, to a client identity. Null is 401.
|
|
158
|
+
// The bearer on an MCP request, to a client identity in a world. Null is 401.
|
|
158
159
|
bearer(req) {
|
|
159
160
|
const h = req.headers.authorization ?? '';
|
|
160
161
|
const t = h.startsWith('Bearer ') ? h.slice(7) : '';
|
|
161
162
|
const g = own(this.store.access, t);
|
|
162
|
-
return g && g.exp >= this.now() && typeof g.identity === 'string' ? g.identity : null;
|
|
163
|
+
return g && g.exp >= this.now() && typeof g.identity === 'string' ? { identity: g.identity, ward: g.ward ?? 'main' } : null;
|
|
163
164
|
}
|
|
164
|
-
// Every grant an identity holds, gone: the route's half of
|
|
165
|
-
// The other half is the user being removing the occupant.
|
|
166
|
-
async revoke(identity) {
|
|
165
|
+
// Every grant an identity holds in a world, gone: the route's half of
|
|
166
|
+
// revocation. The other half is the user being removing the occupant.
|
|
167
|
+
async revoke(identity, ward = 'main') {
|
|
167
168
|
for (const k of ['access', 'refresh'])
|
|
168
169
|
for (const [t, g] of Object.entries(this.store[k]))
|
|
169
|
-
if (g.identity === identity)
|
|
170
|
+
if (g.identity === identity && (g.ward ?? 'main') === ward)
|
|
170
171
|
delete this.store[k][t];
|
|
171
172
|
await this.o.persist(this.store);
|
|
172
173
|
}
|
package/dist/mcp/pilot.js
CHANGED
|
@@ -15,7 +15,7 @@ export function owner(dir, ward, via) {
|
|
|
15
15
|
return { error: r.word };
|
|
16
16
|
return r;
|
|
17
17
|
};
|
|
18
|
-
return { tools: () => one(undefined, {}), call: (name, args) => one(name, args), ears: new Set() };
|
|
18
|
+
return { tools: () => one(undefined, {}), call: (name, args) => one(name, args ?? {}), ears: new Set() };
|
|
19
19
|
}
|
|
20
20
|
export function pilotSide(dir, ward, transport, via) {
|
|
21
21
|
return mcpSide(owner(dir, ward, via), transport);
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
2
|
import { type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
-
import type {
|
|
4
|
-
import { type Serving } from '../beings/side.ts';
|
|
3
|
+
import type { Blueprint } from '@quo-systems/quo';
|
|
4
|
+
import { type Serving, type Subject } from '../beings/side.ts';
|
|
5
|
+
import { type Look } from '../beings/look.ts';
|
|
5
6
|
export declare const NAME = "quo";
|
|
6
7
|
export declare const VERSION = "0.0.0";
|
|
7
|
-
export type Subject
|
|
8
|
-
tools(): Promise<Blueprint | {
|
|
9
|
-
error: string;
|
|
10
|
-
}>;
|
|
11
|
-
call(name: string, args: JsonObject): Promise<Answer>;
|
|
12
|
-
ears: Set<(object: JsonObject) => void>;
|
|
13
|
-
};
|
|
8
|
+
export type { Subject } from '../beings/side.ts';
|
|
14
9
|
export declare const DESCRIBE: Tool;
|
|
15
|
-
export declare function tools(bp: Blueprint): Tool[];
|
|
10
|
+
export declare function tools(bp: Blueprint, look?: Look): Tool[];
|
|
16
11
|
export declare function mcpSide(avatar: Subject, transport: Transport, after?: () => Promise<void>): Promise<Serving>;
|