@quo-systems/dock 0.2.0 → 0.2.2
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 +9 -3
- package/beings/carry.ts +101 -0
- package/beings/desk.ts +2 -2
- package/beings/index.ts +3 -1
- package/beings/link.ts +54 -0
- package/beings/look.ts +96 -0
- package/beings/quo-dock.md +163 -277
- package/beings/side.ts +10 -1
- package/beings/user.ts +67 -13
- package/cli/daemon.ts +58 -251
- package/cli/http.ts +70 -0
- package/cli/quo.ts +13 -5
- package/dist/beings/avatar.js +10 -3
- 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 +3 -1
- 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 +29 -3
- package/dist/beings/user.js +71 -14
- package/dist/cli/daemon.d.ts +7 -19
- package/dist/cli/daemon.js +51 -240
- package/dist/cli/http.d.ts +17 -0
- package/dist/cli/http.js +60 -0
- package/dist/cli/quo.js +15 -5
- package/dist/harbor/quo.d.ts +4 -0
- package/dist/harbor/quo.js +53 -0
- package/dist/human/door.d.ts +5 -0
- package/dist/human/door.js +19 -0
- 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 +26 -6
- package/dist/human/tab.d.ts +5 -0
- package/dist/human/tab.js +161 -43
- package/dist/human/web.d.ts +9 -0
- package/dist/human/web.js +95 -0
- package/dist/human/worlds.d.ts +10 -0
- package/dist/human/worlds.js +38 -0
- 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/route.d.ts +12 -0
- package/dist/mcp/route.js +34 -0
- 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 +67 -30
- package/harbor/quo.ts +67 -0
- package/human/door.ts +26 -0
- package/human/guest.ts +26 -0
- package/human/html.ts +58 -10
- package/human/quo-human.md +158 -67
- package/human/screen.ts +34 -8
- package/human/tab.ts +193 -53
- package/human/web.ts +123 -0
- package/human/worlds.ts +52 -0
- package/mcp/http.ts +10 -9
- package/mcp/oauth.ts +20 -17
- package/mcp/pilot.ts +1 -1
- package/mcp/quo-mcp.md +20 -2
- package/mcp/route.ts +39 -0
- package/mcp/server.ts +37 -18
- package/mcp/web/exchange.ts +23 -9
- package/package.json +2 -2
package/human/tab.ts
CHANGED
|
@@ -1,23 +1,68 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The tab: a page
|
|
3
|
-
// on a seed minted into IndexedDB the first time,
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// socket with it, joins the user being, and from then on there is no cookie
|
|
8
|
-
// and no token anywhere. The second time the page opens, she already holds
|
|
9
|
-
// `user` and nothing is asked.
|
|
2
|
+
// The tab: a page on a world's web. that is a device of its own. The tab
|
|
3
|
+
// boots a browser harbor on a seed minted into IndexedDB the first time,
|
|
4
|
+
// and is a browser of worlds and of the relations in them, `worlds.ts`: one
|
|
5
|
+
// local ward per far world, one avatar per relation, a switcher over both.
|
|
6
|
+
// A switch is a switch of avatar and nothing more.
|
|
10
7
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
8
|
+
// Two ways in, the same act, a knock that ends in a standing, and each one
|
|
9
|
+
// a fresh avatar:
|
|
10
|
+
//
|
|
11
|
+
// a link the page with the invitation in its fragment, `#quo=...`,
|
|
12
|
+
// read and stripped before anything else runs, then joined.
|
|
13
|
+
// The invitation names its ward, so it is joined in that
|
|
14
|
+
// world's local ward whatever page it was opened on, and a
|
|
15
|
+
// link opened while already in is one more relation.
|
|
16
|
+
// a guest the world's public being, whatever class she is, rendered
|
|
17
|
+
// as a page by the same screen; a form on it whose answer is
|
|
18
|
+
// an invitation is the way in.
|
|
19
|
+
//
|
|
20
|
+
// Nothing is typed to get in, ever: no password, no account. A ward with
|
|
21
|
+
// nobody at its door shows the door page, `door.ts`, and a link is the only
|
|
22
|
+
// way. From then on there is no cookie and no token anywhere; the seed and
|
|
23
|
+
// the standings are in the tab's store, and a reload asks nothing. The screen
|
|
24
|
+
// is the human side in `screen.ts` over the DOM surface in `dom.ts`. This
|
|
25
|
+
// file is the shell: the harbor, the config, the switcher and the ways in,
|
|
26
|
+
// and it hands one avatar at a time to the side.
|
|
27
|
+
import type { Invitation, JsonObject } from '@quo-systems/quo';
|
|
15
28
|
import { BrowserHarbor } from '../harbor/browser.ts';
|
|
16
|
-
import {
|
|
29
|
+
import { USER } from '../beings/avatar.ts';
|
|
30
|
+
import { parse, strip } from '../beings/link.ts';
|
|
17
31
|
import { screenSide } from './screen.ts';
|
|
18
32
|
import { domSurface } from './dom.ts';
|
|
33
|
+
import { guest } from './guest.ts';
|
|
34
|
+
import { door } from './door.ts';
|
|
35
|
+
import { world, relations, fresh, type Relation } from './worlds.ts';
|
|
36
|
+
|
|
37
|
+
// What the page is told by the daemon that served it: the world's routes,
|
|
38
|
+
// every ward on that harbor by name, its pk and whether a public being is
|
|
39
|
+
// at its door, and which of them this page is, if it is one's.
|
|
40
|
+
export type Config = { quo: string; web: string; wards: Record<string, { pk: string; public: boolean }>; ward?: string };
|
|
19
41
|
|
|
20
|
-
|
|
42
|
+
// What the tab remembers between pages, beside the harbor: the worlds it
|
|
43
|
+
// has joined, by pk, where each lives and what it is called; which relation
|
|
44
|
+
// is on screen in each; and what each relation is called. A tab with no
|
|
45
|
+
// storage still works, and forgets.
|
|
46
|
+
type Worlds = Record<string, { url: string; name: string }>;
|
|
47
|
+
const kept = <T>(key: string, fallback: T): T => {
|
|
48
|
+
try {
|
|
49
|
+
return (JSON.parse(localStorage.getItem(key) ?? 'null') as T | null) ?? fallback;
|
|
50
|
+
} catch {
|
|
51
|
+
return fallback;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const keep = (key: string, value: unknown) => {
|
|
55
|
+
try {
|
|
56
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
57
|
+
} catch {
|
|
58
|
+
/* forgets */
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const WORLDS = 'quo-worlds';
|
|
62
|
+
const worlds = (): Worlds => kept(WORLDS, {});
|
|
63
|
+
const remember = (pk: string, url: string, name: string) => keep(WORLDS, { ...worlds(), [pk]: { url, name } });
|
|
64
|
+
const AT = (pk: string) => `quo-at:${pk}`;
|
|
65
|
+
const NAMES = (pk: string) => `quo-names:${pk}`;
|
|
21
66
|
|
|
22
67
|
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, text = '', attrs: Record<string, string> = {}): HTMLElementTagNameMap[K] => {
|
|
23
68
|
const e = document.createElement(tag);
|
|
@@ -27,58 +72,153 @@ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, text = '', attrs: Rec
|
|
|
27
72
|
};
|
|
28
73
|
|
|
29
74
|
export async function start(cfg: Config, root: HTMLElement = document.body): Promise<void> {
|
|
75
|
+
// The link, first and once: the invitation leaves the address bar before
|
|
76
|
+
// any other code sees it.
|
|
77
|
+
const linked = parse(location.hash);
|
|
78
|
+
if (linked) history.replaceState(null, '', location.pathname + location.search + strip(location.hash));
|
|
79
|
+
|
|
80
|
+
const nav = el('nav', '', { class: 'worlds' });
|
|
81
|
+
const who = el('nav', '', { class: 'relations' });
|
|
30
82
|
const status = el('p', 'booting');
|
|
31
83
|
const screen = el('div');
|
|
32
|
-
root.append(status, screen);
|
|
84
|
+
root.append(nav, who, status, screen);
|
|
33
85
|
const say = (s: string) => (status.textContent = s);
|
|
34
86
|
|
|
35
|
-
// The harbor in the tab: one database
|
|
87
|
+
// The harbor in the tab: one database per origin, one ward per world.
|
|
36
88
|
const harbor = new BrowserHarbor('quo');
|
|
37
89
|
await harbor.boot();
|
|
38
|
-
const main = harbor.wards.get('main') ?? (await harbor.create('main', 'me'));
|
|
39
|
-
if (!main.being('me')) await main.ask('boot', { key: 'me', class: 'Avatar' });
|
|
40
|
-
const me = main.being('me') as Avatar;
|
|
41
90
|
harbor.dial(cfg.quo);
|
|
42
91
|
|
|
43
|
-
// The
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
92
|
+
// The world this page is about: the link's, since an invitation names
|
|
93
|
+
// its ward, or the page's own. A link for a world not on this harbor is
|
|
94
|
+
// a link to the wrong page, and says so.
|
|
95
|
+
const nameOf = (pk: string) => Object.keys(cfg.wards).find((n) => cfg.wards[n]!.pk === pk);
|
|
96
|
+
const pk = linked?.ward ?? (cfg.ward ? cfg.wards[cfg.ward]?.pk : undefined);
|
|
97
|
+
const wardName = pk ? nameOf(pk) : undefined;
|
|
98
|
+
const pageOf = (name: string) => `${new URL(cfg.web).pathname.replace(/\/$/, '')}/${encodeURIComponent(name)}`;
|
|
99
|
+
|
|
100
|
+
const switcher = () => {
|
|
101
|
+
nav.replaceChildren();
|
|
102
|
+
const known = worlds();
|
|
103
|
+
for (const [k, w] of Object.entries(known)) {
|
|
104
|
+
const a = el('a', w.name || k.slice(0, 8), { href: w.url });
|
|
105
|
+
if (k === pk) a.setAttribute('aria-current', 'page');
|
|
106
|
+
nav.append(a);
|
|
107
|
+
}
|
|
108
|
+
if (pk && !known[pk] && wardName) nav.append(el('span', `${wardName} at ${new URL(cfg.web).host}`));
|
|
48
109
|
};
|
|
110
|
+
switcher();
|
|
49
111
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
112
|
+
if (!pk || !wardName) {
|
|
113
|
+
say(linked ? 'this link is for a world that does not live here' : 'choose a world');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (linked && location.pathname !== pageOf(wardName)) history.replaceState(null, '', pageOf(wardName) + location.search + location.hash);
|
|
117
|
+
const ward = await world(harbor, pk);
|
|
118
|
+
const at = { name: wardName, pk, public: cfg.wards[wardName]!.public };
|
|
119
|
+
|
|
120
|
+
// The relations in this world, and which one is on screen.
|
|
121
|
+
let side: { close(): Promise<void> } | null = null;
|
|
122
|
+
let current: Relation | null = null;
|
|
123
|
+
const names = () => kept<Record<string, string>>(NAMES(pk), {});
|
|
124
|
+
const people = () => {
|
|
125
|
+
who.replaceChildren();
|
|
126
|
+
const rels = relations(ward);
|
|
127
|
+
const called = names();
|
|
128
|
+
for (const r of rels) {
|
|
129
|
+
const b = el('button', called[r.key] ?? r.key, { type: 'button' });
|
|
130
|
+
if (r.key === current?.key) b.setAttribute('aria-current', 'true');
|
|
131
|
+
b.onclick = () => void inside(r, `in, as ${called[r.key] ?? r.key}`, called[r.key] ?? r.key);
|
|
132
|
+
who.append(b);
|
|
133
|
+
}
|
|
134
|
+
if (rels.length) {
|
|
135
|
+
const more = el('button', 'another way in', { type: 'button', class: 'another' });
|
|
136
|
+
more.onclick = () => void atDoor();
|
|
137
|
+
who.append(more);
|
|
71
138
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// In, as one relation: her page. Every call rotates her keys and a
|
|
142
|
+
// same-ward ask never crosses the harbor, so the side saves after each.
|
|
143
|
+
const inside = async (rel: Relation, notice: string, called: string) => {
|
|
144
|
+
await side?.close();
|
|
145
|
+
status.remove();
|
|
146
|
+
root.querySelector('form.password')?.remove();
|
|
147
|
+
screen.replaceChildren();
|
|
148
|
+
current = rel;
|
|
149
|
+
keep(AT(pk), rel.key);
|
|
150
|
+
const s = await screenSide(rel.avatar, domSurface(screen), { after: () => ward.save(), notice });
|
|
151
|
+
side = s;
|
|
152
|
+
const bp = s.model.blueprint;
|
|
153
|
+
const notesName = typeof (bp?.notes as JsonObject | null)?.name === 'string' ? ((bp!.notes as JsonObject).name as string) : '';
|
|
154
|
+
remember(pk, location.origin + pageOf(at.name), s.model.look.name || notesName || at.name);
|
|
155
|
+
if (!names()[rel.key]) keep(NAMES(pk), { ...names(), [rel.key]: called });
|
|
156
|
+
switcher();
|
|
157
|
+
people();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// The way in, from any of the three: a fresh avatar joins, the ward is
|
|
161
|
+
// saved, and she is the one on screen.
|
|
162
|
+
const admit = async (inv: Invitation, notice: string, called: string) => {
|
|
75
163
|
say('knocking');
|
|
76
|
-
const
|
|
77
|
-
await
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
164
|
+
const rel = await fresh(ward);
|
|
165
|
+
const got = await rel.avatar.join(inv);
|
|
166
|
+
await ward.save(); // the knock went through her own door, which the harbor never sees
|
|
167
|
+
if (!('asks' in got)) {
|
|
168
|
+
say(`not in: ${got.error}`);
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
await inside(rel, notice, called);
|
|
172
|
+
return true;
|
|
81
173
|
};
|
|
174
|
+
|
|
175
|
+
// At the door. With a public being there, her describe as a page, and a
|
|
176
|
+
// form whose answer is an invitation lets the guest in. With nobody
|
|
177
|
+
// there, the door page: a link is the only way in, and nothing to type.
|
|
178
|
+
const atDoor = async () => {
|
|
179
|
+
await side?.close();
|
|
180
|
+
side = null;
|
|
181
|
+
current = null;
|
|
182
|
+
screen.replaceChildren();
|
|
183
|
+
if (!status.isConnected) root.insertBefore(status, screen);
|
|
184
|
+
people();
|
|
185
|
+
if (!at.public) {
|
|
186
|
+
status.remove();
|
|
187
|
+
screen.innerHTML = door({ world: at.name, host: new URL(cfg.web).host });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
say(`at the door of ${at.name}`);
|
|
191
|
+
const rel = await fresh(ward);
|
|
192
|
+
const gate = guest(rel.avatar, pk);
|
|
193
|
+
const s = await screenSide(gate, domSurface(screen), {
|
|
194
|
+
after: () => ward.save(),
|
|
195
|
+
notice: `a guest at ${at.name}: what she shows strangers`,
|
|
196
|
+
admit: async (inv) => {
|
|
197
|
+
await s.close();
|
|
198
|
+
await admit(inv, `in, as a guest of ${at.name}`, 'guest');
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
side = s;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// A link: one more relation, whoever else is in. Then whoever was on
|
|
205
|
+
// screen last, or the door.
|
|
206
|
+
if (linked && (await admit(linked, 'in, by the link', 'link'))) return;
|
|
207
|
+
const rels = relations(ward);
|
|
208
|
+
const last = kept<string | null>(AT(pk), null);
|
|
209
|
+
const back = rels.find((r) => r.key === last) ?? rels[rels.length - 1];
|
|
210
|
+
if (back) {
|
|
211
|
+
const had = await back.avatar.tools();
|
|
212
|
+
await ward.save(); // her keys rotated on that ask, whatever it answered
|
|
213
|
+
if ('asks' in had) return inside(back, linked ? `not in by the link; in, as before` : 'in, as before', names()[back.key] ?? back.key);
|
|
214
|
+
say(`not in (${had.error})`);
|
|
215
|
+
}
|
|
216
|
+
await atDoor();
|
|
82
217
|
}
|
|
83
218
|
|
|
219
|
+
// The page hands the config in a JSON script, which a content security
|
|
220
|
+
// policy allows where an inline script is not.
|
|
221
|
+
const config = document.getElementById('quo');
|
|
222
|
+
if (config?.textContent) void start(JSON.parse(config.textContent) as Config);
|
|
223
|
+
|
|
84
224
|
export { USER };
|
package/human/web.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The web route: the worlds' pages, served by the daemon under `/web` and
|
|
3
|
+
// mapped by the proxy from the web. hostname. A world is a ward with a
|
|
4
|
+
// public being, and its address is `/web/<ward>`: the tab page, `tab.ts`,
|
|
5
|
+
// told which ward this is, and every ward on the harbor by name, pk and
|
|
6
|
+
// whether a public being is at its door, so that its guest is that ward's
|
|
7
|
+
// public being rendered by the screen, whatever class she is. Every ward
|
|
8
|
+
// has a page, since a link may point into one with nobody at the door;
|
|
9
|
+
// `/web/` lists the worlds and carries the tab too, so a link at the root
|
|
10
|
+
// lands in the ward its invitation names, and with no world to list it is
|
|
11
|
+
// the door page, `door.ts`. The bundle is built once from the source
|
|
12
|
+
// beside this file, `.ts` in the tree, `.js` once emitted into the
|
|
13
|
+
// package's dist. Nothing here authenticates and nothing is admitted on
|
|
14
|
+
// this side: the way into a world is an invitation, carried by a link, and
|
|
15
|
+
// the knock is the tab's. Paths this route does not take fall through to
|
|
16
|
+
// the exchange pages, which share the prefix.
|
|
17
|
+
//
|
|
18
|
+
// The page carries a content security policy: scripts from this origin
|
|
19
|
+
// only and never inline, connections to this origin and the quo. route the
|
|
20
|
+
// tab dials, images from data URIs and this origin, and nothing else. So
|
|
21
|
+
// even a bug in a renderer cannot become a script, and no look can reach a
|
|
22
|
+
// server. The config crosses in a JSON script, which the policy allows.
|
|
23
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
24
|
+
import { existsSync } from 'node:fs';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { build } from 'esbuild';
|
|
27
|
+
import type { DiskHarbor, Hosted } from '../harbor/disk.ts';
|
|
28
|
+
import { door } from './door.ts';
|
|
29
|
+
|
|
30
|
+
export type WebOptions = {
|
|
31
|
+
// where the routes are, as a tab sees them: what it dials and where it is
|
|
32
|
+
at: { quo: string; web: string };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// The paths under `/web` that are the exchange's, never a ward's name.
|
|
36
|
+
const RESERVED_PATHS = new Set(['login', 'allow', 'tab.js']);
|
|
37
|
+
|
|
38
|
+
export function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessage, res: ServerResponse, rest: string) => Promise<boolean> {
|
|
39
|
+
const tabEntry = () => {
|
|
40
|
+
const js = fileURLToPath(new URL('./tab.js', import.meta.url));
|
|
41
|
+
return existsSync(js) ? js : fileURLToPath(new URL('./tab.ts', import.meta.url));
|
|
42
|
+
};
|
|
43
|
+
let bundle: Promise<string> | undefined;
|
|
44
|
+
const built = () =>
|
|
45
|
+
(bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((out) => out.outputFiles[0]!.text));
|
|
46
|
+
const quoOrigin = (() => {
|
|
47
|
+
try {
|
|
48
|
+
const u = new URL(o.at.quo);
|
|
49
|
+
return `${u.origin} ${u.origin.replace(/^http/, 'ws')}`;
|
|
50
|
+
} catch {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
54
|
+
const host = (() => {
|
|
55
|
+
try {
|
|
56
|
+
return new URL(o.at.web).host;
|
|
57
|
+
} catch {
|
|
58
|
+
return o.at.web;
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
const policy = `default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ${quoOrigin}; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`;
|
|
62
|
+
const shell = (body: string) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
|
|
63
|
+
<style>${CSS}</style>
|
|
64
|
+
</head><body>${body}</body></html>`;
|
|
65
|
+
const esc = (v: string) => v.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
66
|
+
const publicOf = (h: Hosted) => (h.partition as { public?: string | null }).public ?? null;
|
|
67
|
+
const html = (status: number, res: ServerResponse, body: string) => {
|
|
68
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'no-referrer' });
|
|
69
|
+
res.end(shell(body));
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
const wards = () => Object.fromEntries([...harbor.wards].map(([n, h]) => [n, { pk: h.pk, public: publicOf(h) !== null }]));
|
|
73
|
+
const tab = (ward?: string) => {
|
|
74
|
+
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), ...(ward ? { ward } : {}) };
|
|
75
|
+
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
|
+
};
|
|
77
|
+
return async (req, res, rest) => {
|
|
78
|
+
const parts = rest.split('/').filter(Boolean);
|
|
79
|
+
if (req.method === 'GET' && (rest === '' || rest === '/')) {
|
|
80
|
+
const list = [...harbor.wards]
|
|
81
|
+
.filter(([, h]) => publicOf(h) !== null)
|
|
82
|
+
.map(([n, h]) => `<li><a href="${o.at.web}/${encodeURIComponent(n)}">${esc(n)}</a> <small>${esc(publicOf(h) ?? '')} at the door, <code>${h.pk.slice(0, 16)}…</code></small></li>`)
|
|
83
|
+
.join('');
|
|
84
|
+
return html(200, res, `${list ? `<h1>worlds</h1><ul>${list}</ul>` : door({ host })}${tab()}`);
|
|
85
|
+
}
|
|
86
|
+
if (rest === '/tab.js' && req.method === 'GET') {
|
|
87
|
+
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
|
|
88
|
+
res.end(await built());
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
const wardName = parts[0] ?? '';
|
|
92
|
+
if (!wardName || RESERVED_PATHS.has(wardName)) return false;
|
|
93
|
+
const hosted = harbor.wards.get(wardName);
|
|
94
|
+
if (!hosted) return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
|
|
95
|
+
if (parts.length === 1 && req.method === 'GET') return html(200, res, tab(wardName));
|
|
96
|
+
return false;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The tab's stylesheet: one, light and dark, honouring the variables a look
|
|
101
|
+
// sets on a section. The page owns layout; a far being paints inside her
|
|
102
|
+
// section and nowhere else. The door page is the one designed thing in it.
|
|
103
|
+
const CSS = [
|
|
104
|
+
':root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px;--ink:#1c1b22;--paper:#fbfaf7;--mute:#6b6a73}',
|
|
105
|
+
'@media(prefers-color-scheme:dark){:root{--ink:#ecebe6;--paper:#141318;--mute:#9a99a2}}',
|
|
106
|
+
'html{background:var(--paper);color:var(--ink)}',
|
|
107
|
+
'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}',
|
|
108
|
+
'nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}',
|
|
109
|
+
'nav.relations{display:flex;flex-wrap:wrap;gap:.25rem;margin:.5rem 0}nav.relations button{background:transparent;color:inherit;border:1px solid color-mix(in srgb,currentColor 30%,transparent)}nav.relations button[aria-current]{border-color:var(--accent);font-weight:600}',
|
|
110
|
+
'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
|
+
'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
|
+
'fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}',
|
|
113
|
+
'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
|
+
'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}',
|
|
115
|
+
// the door page: one column, generous air, the mark, a word, a sentence
|
|
116
|
+
'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
|
|
117
|
+
'main.door .mark{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
|
|
118
|
+
'main.door h1{font-size:3rem;font-weight:400;letter-spacing:-.02em;line-height:1;margin:0 0 .5rem}',
|
|
119
|
+
'main.door .where{font-family:system-ui,sans-serif;font-size:.85rem;letter-spacing:.08em;text-transform:uppercase;color:var(--mute);margin:0 0 2rem}main.door .where span{text-transform:none;letter-spacing:0}',
|
|
120
|
+
'main.door .lead{font-size:1.5rem;line-height:1.3;margin:0 0 1rem}',
|
|
121
|
+
'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}',
|
|
123
|
+
].join('');
|
package/human/worlds.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The worlds in a tab, and the relations in each. A tab is a device with a
|
|
3
|
+
// harbor of its own, and it keeps one local ward per far world, named by
|
|
4
|
+
// the far ward's pk; inside it, one avatar per relation, since an avatar is
|
|
5
|
+
// one standing and nothing more. An invitation names its ward by pk, so an
|
|
6
|
+
// invitation is self-addressing: the local ward is chosen by the invitation
|
|
7
|
+
// and never by the page it was opened on, and a fresh invitation is always a
|
|
8
|
+
// fresh avatar, never a join on one that already holds her standing. Two
|
|
9
|
+
// humans on one family tablet are two relations in one world; a shop and a
|
|
10
|
+
// friend in one far ward are two as well. Nothing here touches a document:
|
|
11
|
+
// `tab.ts` is the shell over this, and this is what the memory harbor proves.
|
|
12
|
+
import type { Harbor, Hosted } from '@quo-systems/quo/harbor';
|
|
13
|
+
import { Avatar, USER } from '../beings/avatar.ts';
|
|
14
|
+
|
|
15
|
+
// The local ward for a far world, by its pk: created on first sight, on
|
|
16
|
+
// the tab's own seed, empty.
|
|
17
|
+
export const localName = (pk: string): string => `w-${pk.slice(0, 16)}`;
|
|
18
|
+
export async function world(harbor: Harbor, pk: string): Promise<Hosted> {
|
|
19
|
+
const name = localName(pk);
|
|
20
|
+
return harbor.wards.get(name) ?? (await harbor.create(name, 'me'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The relations held in a local ward: every avatar there who holds her
|
|
24
|
+
// standing, by key, in the order they were born.
|
|
25
|
+
export type Relation = { key: string; avatar: Avatar };
|
|
26
|
+
export function relations(ward: Hosted): Relation[] {
|
|
27
|
+
const beings = (ward.partition as { beings?: Record<string, { class?: string }> }).beings ?? {};
|
|
28
|
+
const out: Relation[] = [];
|
|
29
|
+
for (const key of Object.keys(beings)) {
|
|
30
|
+
const a = ward.being(key) as Avatar | undefined;
|
|
31
|
+
if (a instanceof Avatar && a.standings[USER]) out.push({ key, avatar: a });
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// An avatar for a relation not yet made: one who holds no standing, whether
|
|
37
|
+
// left by a guest who never came in or booted now under the next free key.
|
|
38
|
+
// She is the one who knocks, as a guest or with an invitation, and she
|
|
39
|
+
// becomes a relation the moment she takes her standing.
|
|
40
|
+
export async function fresh(ward: Hosted): Promise<Relation> {
|
|
41
|
+
const beings = (ward.partition as { beings?: Record<string, { class?: string }> }).beings ?? {};
|
|
42
|
+
for (const key of Object.keys(beings)) {
|
|
43
|
+
const a = ward.being(key) as Avatar | undefined;
|
|
44
|
+
if (a instanceof Avatar && !a.standings[USER]) return { key, avatar: a };
|
|
45
|
+
}
|
|
46
|
+
let n = 1;
|
|
47
|
+
while (beings[`r${n}`]) n++;
|
|
48
|
+
const key = `r${n}`;
|
|
49
|
+
const out = (await ward.ask('boot', { key, class: 'Avatar' })) as { error?: string };
|
|
50
|
+
if (out.error) throw new Error(`the tab could not boot an avatar: ${out.error}`);
|
|
51
|
+
return { key, avatar: ward.being(key) as Avatar };
|
|
52
|
+
}
|
package/mcp/http.ts
CHANGED
|
@@ -15,9 +15,10 @@ import type { Avatar } from '../beings/avatar.ts';
|
|
|
15
15
|
import type { Serving } from '../beings/side.ts';
|
|
16
16
|
import { mcpSide } from './server.ts';
|
|
17
17
|
|
|
18
|
-
export type Session = { identity: string; transport: StreamableHTTPServerTransport; serving: Serving; touched: number };
|
|
18
|
+
export type Session = { identity: string; ward: string; transport: StreamableHTTPServerTransport; serving: Serving; touched: number };
|
|
19
19
|
export const SESSION_IDLE = 60 * 60 * 1000;
|
|
20
|
-
|
|
20
|
+
// An identity in a world: one harbor holds many, and the grant names which.
|
|
21
|
+
export type Resolve = (identity: string, ward: string) => Promise<{ avatar?: Avatar; error?: string }>;
|
|
21
22
|
|
|
22
23
|
export class McpHttp {
|
|
23
24
|
readonly sessions = new Map<string, Session>();
|
|
@@ -27,7 +28,7 @@ export class McpHttp {
|
|
|
27
28
|
// What to do with an identity the user being has removed: the route's
|
|
28
29
|
// revoke, so the client's tokens go with the occupant. Set by whoever
|
|
29
30
|
// mounts the route beside the credential exchange.
|
|
30
|
-
gone: (identity: string) => Promise<void> = async () => {};
|
|
31
|
+
gone: (identity: string, ward: string) => Promise<void> = async () => {};
|
|
31
32
|
constructor(resolve: Resolve, after: () => Promise<void> = async () => {}) {
|
|
32
33
|
this.resolve = resolve;
|
|
33
34
|
this.after = after;
|
|
@@ -36,7 +37,7 @@ export class McpHttp {
|
|
|
36
37
|
// `identity` is what the bearer named; the caller has already turned a
|
|
37
38
|
// stranger away. A request on a known session goes to it. A request with
|
|
38
39
|
// no session opens one, if it is an initialize; anything else is 400.
|
|
39
|
-
async handle(req: IncomingMessage, res: ServerResponse, identity: string): Promise<void> {
|
|
40
|
+
async handle(req: IncomingMessage, res: ServerResponse, identity: string, ward = 'main'): Promise<void> {
|
|
40
41
|
const id = req.headers['mcp-session-id'];
|
|
41
42
|
const sid = Array.isArray(id) ? id[0] : id;
|
|
42
43
|
const json = (status: number, body: unknown) => {
|
|
@@ -47,22 +48,22 @@ export class McpHttp {
|
|
|
47
48
|
if (sid !== undefined) {
|
|
48
49
|
const s = this.sessions.get(sid);
|
|
49
50
|
if (!s) return json(404, { jsonrpc: '2.0', error: { code: -32001, message: 'no such session' }, id: null });
|
|
50
|
-
if (s.identity !== identity) return json(403, { jsonrpc: '2.0', error: { code: -32003, message: 'not your session' }, id: null });
|
|
51
|
+
if (s.identity !== identity || s.ward !== ward) return json(403, { jsonrpc: '2.0', error: { code: -32003, message: 'not your session' }, id: null });
|
|
51
52
|
s.touched = this.now();
|
|
52
53
|
await s.transport.handleRequest(req, res);
|
|
53
54
|
if (req.method === 'DELETE') this.drop(sid);
|
|
54
55
|
return;
|
|
55
56
|
}
|
|
56
57
|
if (req.method !== 'POST') return json(400, { jsonrpc: '2.0', error: { code: -32000, message: 'no session' }, id: null });
|
|
57
|
-
const found = await this.resolve(identity);
|
|
58
|
+
const found = await this.resolve(identity, ward);
|
|
58
59
|
// A session opens on her describe, and admit already asked it. The one
|
|
59
60
|
// word the door says for an identity the user being removed is
|
|
60
61
|
// `removed`, under the key it bound for her avatar: in MCP's vocabulary
|
|
61
62
|
// that is 401, the client drops its token and starts the exchange again,
|
|
62
63
|
// and the route forgets the grant.
|
|
63
64
|
if (found.error === 'removed') {
|
|
64
|
-
await this.gone(identity);
|
|
65
|
-
for (const [sid, s] of this.sessions) if (s.identity === identity) this.drop(sid);
|
|
65
|
+
await this.gone(identity, ward);
|
|
66
|
+
for (const [sid, s] of this.sessions) if (s.identity === identity && s.ward === ward) this.drop(sid);
|
|
66
67
|
res.writeHead(401, { 'content-type': 'application/json' });
|
|
67
68
|
return void res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'removed: the user being removed this identity' }, id: null }));
|
|
68
69
|
}
|
|
@@ -70,7 +71,7 @@ export class McpHttp {
|
|
|
70
71
|
const transport = new StreamableHTTPServerTransport({
|
|
71
72
|
sessionIdGenerator: () => randomUUID(),
|
|
72
73
|
onsessioninitialized: (s) => {
|
|
73
|
-
this.sessions.set(s, { identity, transport, serving, touched: this.now() });
|
|
74
|
+
this.sessions.set(s, { identity, ward, transport, serving, touched: this.now() });
|
|
74
75
|
},
|
|
75
76
|
onsessionclosed: (s) => this.drop(s),
|
|
76
77
|
});
|
package/mcp/oauth.ts
CHANGED
|
@@ -33,11 +33,13 @@ export const REFRESH_TTL = 30 * 24 * 60 * 60 * 1000; // a refresh token, thirty
|
|
|
33
33
|
// is open to anyone, and what nobody allowed must not stay on disk.
|
|
34
34
|
export type Client = { client_id: string; client_name: string; redirect_uris: string[]; exp: number };
|
|
35
35
|
export type Pending = { client_id: string; redirect_uri: string; challenge: string; state: string | null; resource: string | null; exp: number };
|
|
36
|
-
|
|
36
|
+
// A grant names the world the human allowed the client into, since one
|
|
37
|
+
// harbor holds many; a record from before there were worlds is `main`'s.
|
|
38
|
+
export type Grant = { identity: string; ward: string; client_id: string; exp: number };
|
|
37
39
|
export type Store = {
|
|
38
40
|
clients: Record<string, Client>;
|
|
39
41
|
pending: Record<string, Pending>;
|
|
40
|
-
codes: Record<string, Pending & { identity: string }>;
|
|
42
|
+
codes: Record<string, Pending & { identity: string; ward: string }>;
|
|
41
43
|
access: Record<string, Grant>;
|
|
42
44
|
refresh: Record<string, Grant>;
|
|
43
45
|
};
|
|
@@ -129,13 +131,14 @@ export class OAuth {
|
|
|
129
131
|
return client ? { ...p, client } : null;
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
// The web route's last step: the human allowed this client as this
|
|
133
|
-
|
|
134
|
+
// The web route's last step: the human allowed this client as this
|
|
135
|
+
// identity, into this world.
|
|
136
|
+
async complete(id: string, identity: string, ward = 'main'): Promise<{ redirect: string } | { error: string }> {
|
|
134
137
|
const p = this.pending(id);
|
|
135
138
|
if (!p) return { error: 'expired' };
|
|
136
139
|
delete this.store.pending[id];
|
|
137
140
|
const code = token();
|
|
138
|
-
this.store.codes[code] = { ...p, identity, exp: this.now() + CODE_TTL };
|
|
141
|
+
this.store.codes[code] = { ...p, identity, ward, exp: this.now() + CODE_TTL };
|
|
139
142
|
await this.o.persist(this.store);
|
|
140
143
|
return { redirect: withQuery(p.redirect_uri, { code, state: p.state }) };
|
|
141
144
|
}
|
|
@@ -160,23 +163,23 @@ export class OAuth {
|
|
|
160
163
|
await this.o.persist(this.store);
|
|
161
164
|
return { error: 'invalid_grant' };
|
|
162
165
|
}
|
|
163
|
-
return this.issue(c.identity, c.client_id);
|
|
166
|
+
return this.issue(c.identity, c.ward ?? 'main', c.client_id);
|
|
164
167
|
}
|
|
165
168
|
if (grant === 'refresh_token') {
|
|
166
169
|
const rt = body.get('refresh_token') ?? '';
|
|
167
170
|
const r = own(this.store.refresh, rt);
|
|
168
171
|
if (!r || r.exp < this.now()) return { error: 'invalid_grant' };
|
|
169
172
|
delete this.store.refresh[rt]; // rotated: the old one is gone with the new one's birth
|
|
170
|
-
return this.issue(r.identity, r.client_id);
|
|
173
|
+
return this.issue(r.identity, r.ward ?? 'main', r.client_id);
|
|
171
174
|
}
|
|
172
175
|
return { error: 'unsupported_grant_type' };
|
|
173
176
|
}
|
|
174
177
|
|
|
175
|
-
async issue(identity: string, client_id: string) {
|
|
178
|
+
async issue(identity: string, ward: string, client_id: string) {
|
|
176
179
|
const access_token = token(),
|
|
177
180
|
refresh_token = token();
|
|
178
|
-
this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
|
|
179
|
-
this.store.refresh[refresh_token] = { identity, client_id, exp: this.now() + REFRESH_TTL };
|
|
181
|
+
this.store.access[access_token] = { identity, ward, client_id, exp: this.now() + ACCESS_TTL };
|
|
182
|
+
this.store.refresh[refresh_token] = { identity, ward, client_id, exp: this.now() + REFRESH_TTL };
|
|
180
183
|
const c = own(this.store.clients, client_id);
|
|
181
184
|
if (c) c.exp = this.now() + REFRESH_TTL; // allowed: the client lives as long as what it was granted
|
|
182
185
|
this.sweep();
|
|
@@ -184,18 +187,18 @@ export class OAuth {
|
|
|
184
187
|
return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
|
|
185
188
|
}
|
|
186
189
|
|
|
187
|
-
// The bearer on an MCP request, to a client identity. Null is 401.
|
|
188
|
-
bearer(req: IncomingMessage): string | null {
|
|
190
|
+
// The bearer on an MCP request, to a client identity in a world. Null is 401.
|
|
191
|
+
bearer(req: IncomingMessage): { identity: string; ward: string } | null {
|
|
189
192
|
const h = req.headers.authorization ?? '';
|
|
190
193
|
const t = h.startsWith('Bearer ') ? h.slice(7) : '';
|
|
191
194
|
const g = own(this.store.access, t);
|
|
192
|
-
return g && g.exp >= this.now() && typeof g.identity === 'string' ? g.identity : null;
|
|
195
|
+
return g && g.exp >= this.now() && typeof g.identity === 'string' ? { identity: g.identity, ward: g.ward ?? 'main' } : null;
|
|
193
196
|
}
|
|
194
197
|
|
|
195
|
-
// Every grant an identity holds, gone: the route's half of
|
|
196
|
-
// The other half is the user being removing the occupant.
|
|
197
|
-
async revoke(identity: string): Promise<void> {
|
|
198
|
-
for (const k of ['access', 'refresh'] as const) for (const [t, g] of Object.entries(this.store[k])) if (g.identity === identity) delete this.store[k][t];
|
|
198
|
+
// Every grant an identity holds in a world, gone: the route's half of
|
|
199
|
+
// revocation. The other half is the user being removing the occupant.
|
|
200
|
+
async revoke(identity: string, ward = 'main'): Promise<void> {
|
|
201
|
+
for (const k of ['access', 'refresh'] as const) for (const [t, g] of Object.entries(this.store[k])) if (g.identity === identity && (g.ward ?? 'main') === ward) delete this.store[k][t];
|
|
199
202
|
await this.o.persist(this.store);
|
|
200
203
|
}
|
|
201
204
|
|
package/mcp/pilot.ts
CHANGED
|
@@ -26,7 +26,7 @@ export function owner(dir: string, ward: string, via?: string): Subject {
|
|
|
26
26
|
if (isRecord(r) && typeof r.word === 'string') return { error: r.word };
|
|
27
27
|
return r as Answer;
|
|
28
28
|
};
|
|
29
|
-
return { tools: () => one(undefined, {}) as Promise<Blueprint | { error: string }>, call: (name, args) => one(name, args), ears: new Set() };
|
|
29
|
+
return { tools: () => one(undefined, {}) as Promise<Blueprint | { error: string }>, call: (name, args) => one(name, args ?? {}), ears: new Set() };
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function pilotSide(dir: string, ward: string, transport: Transport, via?: string): Promise<Serving> {
|