@quo-systems/dock 0.2.1 → 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 +3 -1
- package/beings/index.ts +1 -1
- package/beings/quo-dock.md +77 -301
- package/beings/user.ts +39 -11
- package/cli/daemon.ts +26 -273
- package/cli/http.ts +70 -0
- package/cli/quo.ts +10 -3
- package/dist/beings/avatar.js +3 -1
- package/dist/beings/index.js +1 -1
- package/dist/beings/user.d.ts +3 -1
- package/dist/beings/user.js +42 -12
- package/dist/cli/daemon.d.ts +6 -18
- package/dist/cli/daemon.js +22 -265
- package/dist/cli/http.d.ts +17 -0
- package/dist/cli/http.js +60 -0
- package/dist/cli/quo.js +12 -3
- 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/screen.js +1 -0
- package/dist/human/tab.d.ts +5 -2
- package/dist/human/tab.js +121 -88
- 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/route.d.ts +12 -0
- package/dist/mcp/route.js +34 -0
- package/harbor/quo-harbor.md +30 -30
- package/harbor/quo.ts +67 -0
- package/human/door.ts +26 -0
- package/human/quo-human.md +58 -24
- package/human/screen.ts +7 -2
- package/human/tab.ts +140 -102
- package/human/web.ts +123 -0
- package/human/worlds.ts +52 -0
- package/mcp/quo-mcp.md +3 -1
- package/mcp/route.ts +39 -0
- package/package.json +2 -2
package/dist/human/tab.js
CHANGED
|
@@ -1,27 +1,32 @@
|
|
|
1
1
|
import { BrowserHarbor } from '../harbor/browser.js';
|
|
2
|
-
import {
|
|
3
|
-
import { parse, strip
|
|
2
|
+
import { USER } from '../beings/avatar.js';
|
|
3
|
+
import { parse, strip } from '../beings/link.js';
|
|
4
4
|
import { screenSide } from './screen.js';
|
|
5
5
|
import { domSurface } from './dom.js';
|
|
6
6
|
import { guest } from './guest.js';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
const
|
|
7
|
+
import { door } from './door.js';
|
|
8
|
+
import { world, relations, fresh } from './worlds.js';
|
|
9
|
+
const kept = (key, fallback) => {
|
|
10
10
|
try {
|
|
11
|
-
return JSON.parse(localStorage.getItem(
|
|
11
|
+
return JSON.parse(localStorage.getItem(key) ?? 'null') ?? fallback;
|
|
12
12
|
}
|
|
13
13
|
catch {
|
|
14
|
-
return
|
|
14
|
+
return fallback;
|
|
15
15
|
}
|
|
16
16
|
};
|
|
17
|
-
const
|
|
17
|
+
const keep = (key, value) => {
|
|
18
18
|
try {
|
|
19
|
-
localStorage.setItem(
|
|
19
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
20
20
|
}
|
|
21
21
|
catch {
|
|
22
|
-
/*
|
|
22
|
+
/* forgets */
|
|
23
23
|
}
|
|
24
24
|
};
|
|
25
|
+
const WORLDS = 'quo-worlds';
|
|
26
|
+
const worlds = () => kept(WORLDS, {});
|
|
27
|
+
const remember = (pk, url, name) => keep(WORLDS, { ...worlds(), [pk]: { url, name } });
|
|
28
|
+
const AT = (pk) => `quo-at:${pk}`;
|
|
29
|
+
const NAMES = (pk) => `quo-names:${pk}`;
|
|
25
30
|
const el = (tag, text = '', attrs = {}) => {
|
|
26
31
|
const e = document.createElement(tag);
|
|
27
32
|
if (text)
|
|
@@ -37,113 +42,141 @@ export async function start(cfg, root = document.body) {
|
|
|
37
42
|
if (linked)
|
|
38
43
|
history.replaceState(null, '', location.pathname + location.search + strip(location.hash));
|
|
39
44
|
const nav = el('nav', '', { class: 'worlds' });
|
|
45
|
+
const who = el('nav', '', { class: 'relations' });
|
|
40
46
|
const status = el('p', 'booting');
|
|
41
47
|
const screen = el('div');
|
|
42
|
-
root.append(nav, status, screen);
|
|
48
|
+
root.append(nav, who, status, screen);
|
|
43
49
|
const say = (s) => (status.textContent = s);
|
|
44
50
|
// The harbor in the tab: one database per origin, one ward per world.
|
|
45
51
|
const harbor = new BrowserHarbor('quo');
|
|
46
52
|
await harbor.boot();
|
|
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');
|
|
52
53
|
harbor.dial(cfg.quo);
|
|
54
|
+
// The world this page is about: the link's, since an invitation names
|
|
55
|
+
// its ward, or the page's own. A link for a world not on this harbor is
|
|
56
|
+
// a link to the wrong page, and says so.
|
|
57
|
+
const nameOf = (pk) => Object.keys(cfg.wards).find((n) => cfg.wards[n].pk === pk);
|
|
58
|
+
const pk = linked?.ward ?? (cfg.ward ? cfg.wards[cfg.ward]?.pk : undefined);
|
|
59
|
+
const wardName = pk ? nameOf(pk) : undefined;
|
|
60
|
+
const pageOf = (name) => `${new URL(cfg.web).pathname.replace(/\/$/, '')}/${encodeURIComponent(name)}`;
|
|
53
61
|
const switcher = () => {
|
|
54
62
|
nav.replaceChildren();
|
|
55
63
|
const known = worlds();
|
|
56
|
-
for (const [
|
|
57
|
-
const a = el('a', w.name ||
|
|
58
|
-
if (
|
|
64
|
+
for (const [k, w] of Object.entries(known)) {
|
|
65
|
+
const a = el('a', w.name || k.slice(0, 8), { href: w.url });
|
|
66
|
+
if (k === pk)
|
|
59
67
|
a.setAttribute('aria-current', 'page');
|
|
60
68
|
nav.append(a);
|
|
61
69
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
nav.append(el('span', `${cfg.ward} at ${new URL(cfg.web).host}`));
|
|
70
|
+
if (pk && !known[pk] && wardName)
|
|
71
|
+
nav.append(el('span', `${wardName} at ${new URL(cfg.web).host}`));
|
|
65
72
|
};
|
|
66
73
|
switcher();
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
if (!pk || !wardName) {
|
|
75
|
+
say(linked ? 'this link is for a world that does not live here' : 'choose a world');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (linked && location.pathname !== pageOf(wardName))
|
|
79
|
+
history.replaceState(null, '', pageOf(wardName) + location.search + location.hash);
|
|
80
|
+
const ward = await world(harbor, pk);
|
|
81
|
+
const at = { name: wardName, pk, public: cfg.wards[wardName].public };
|
|
82
|
+
// The relations in this world, and which one is on screen.
|
|
83
|
+
let side = null;
|
|
84
|
+
let current = null;
|
|
85
|
+
const names = () => kept(NAMES(pk), {});
|
|
86
|
+
const people = () => {
|
|
87
|
+
who.replaceChildren();
|
|
88
|
+
const rels = relations(ward);
|
|
89
|
+
const called = names();
|
|
90
|
+
for (const r of rels) {
|
|
91
|
+
const b = el('button', called[r.key] ?? r.key, { type: 'button' });
|
|
92
|
+
if (r.key === current?.key)
|
|
93
|
+
b.setAttribute('aria-current', 'true');
|
|
94
|
+
b.onclick = () => void inside(r, `in, as ${called[r.key] ?? r.key}`, called[r.key] ?? r.key);
|
|
95
|
+
who.append(b);
|
|
96
|
+
}
|
|
97
|
+
if (rels.length) {
|
|
98
|
+
const more = el('button', 'another way in', { type: 'button', class: 'another' });
|
|
99
|
+
more.onclick = () => void atDoor();
|
|
100
|
+
who.append(more);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
// In, as one relation: her page. Every call rotates her keys and a
|
|
104
|
+
// same-ward ask never crosses the harbor, so the side saves after each.
|
|
105
|
+
const inside = async (rel, notice, called) => {
|
|
106
|
+
await side?.close();
|
|
70
107
|
status.remove();
|
|
108
|
+
root.querySelector('form.password')?.remove();
|
|
71
109
|
screen.replaceChildren();
|
|
72
|
-
|
|
73
|
-
|
|
110
|
+
current = rel;
|
|
111
|
+
keep(AT(pk), rel.key);
|
|
112
|
+
const s = await screenSide(rel.avatar, domSurface(screen), { after: () => ward.save(), notice });
|
|
113
|
+
side = s;
|
|
114
|
+
const bp = s.model.blueprint;
|
|
115
|
+
const notesName = typeof bp?.notes?.name === 'string' ? bp.notes.name : '';
|
|
116
|
+
remember(pk, location.origin + pageOf(at.name), s.model.look.name || notesName || at.name);
|
|
117
|
+
if (!names()[rel.key])
|
|
118
|
+
keep(NAMES(pk), { ...names(), [rel.key]: called });
|
|
74
119
|
switcher();
|
|
120
|
+
people();
|
|
75
121
|
};
|
|
76
|
-
// The way in, from any of the three:
|
|
77
|
-
|
|
122
|
+
// The way in, from any of the three: a fresh avatar joins, the ward is
|
|
123
|
+
// saved, and she is the one on screen.
|
|
124
|
+
const admit = async (inv, notice, called) => {
|
|
78
125
|
say('knocking');
|
|
79
|
-
const
|
|
126
|
+
const rel = await fresh(ward);
|
|
127
|
+
const got = await rel.avatar.join(inv);
|
|
80
128
|
await ward.save(); // the knock went through her own door, which the harbor never sees
|
|
81
129
|
if (!('asks' in got)) {
|
|
82
130
|
say(`not in: ${got.error}`);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
await inside(rel, notice, called);
|
|
134
|
+
return true;
|
|
135
|
+
};
|
|
136
|
+
// At the door. With a public being there, her describe as a page, and a
|
|
137
|
+
// form whose answer is an invitation lets the guest in. With nobody
|
|
138
|
+
// there, the door page: a link is the only way in, and nothing to type.
|
|
139
|
+
const atDoor = async () => {
|
|
140
|
+
await side?.close();
|
|
141
|
+
side = null;
|
|
142
|
+
current = null;
|
|
143
|
+
screen.replaceChildren();
|
|
144
|
+
if (!status.isConnected)
|
|
145
|
+
root.insertBefore(status, screen);
|
|
146
|
+
people();
|
|
147
|
+
if (!at.public) {
|
|
148
|
+
status.remove();
|
|
149
|
+
screen.innerHTML = door({ world: at.name, host: new URL(cfg.web).host });
|
|
83
150
|
return;
|
|
84
151
|
}
|
|
85
|
-
|
|
152
|
+
say(`at the door of ${at.name}`);
|
|
153
|
+
const rel = await fresh(ward);
|
|
154
|
+
const gate = guest(rel.avatar, pk);
|
|
155
|
+
const s = await screenSide(gate, domSurface(screen), {
|
|
156
|
+
after: () => ward.save(),
|
|
157
|
+
notice: `a guest at ${at.name}: what she shows strangers`,
|
|
158
|
+
admit: async (inv) => {
|
|
159
|
+
await s.close();
|
|
160
|
+
await admit(inv, `in, as a guest of ${at.name}`, 'guest');
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
side = s;
|
|
86
164
|
};
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
165
|
+
// A link: one more relation, whoever else is in. Then whoever was on
|
|
166
|
+
// screen last, or the door.
|
|
167
|
+
if (linked && (await admit(linked, 'in, by the link', 'link')))
|
|
168
|
+
return;
|
|
169
|
+
const rels = relations(ward);
|
|
170
|
+
const last = kept(AT(pk), null);
|
|
171
|
+
const back = rels.find((r) => r.key === last) ?? rels[rels.length - 1];
|
|
172
|
+
if (back) {
|
|
173
|
+
const had = await back.avatar.tools();
|
|
174
|
+
await ward.save(); // her keys rotated on that ask, whatever it answered
|
|
90
175
|
if ('asks' in had)
|
|
91
|
-
return inside('in, as before');
|
|
176
|
+
return inside(back, linked ? `not in by the link; in, as before` : 'in, as before', names()[back.key] ?? back.key);
|
|
92
177
|
say(`not in (${had.error})`);
|
|
93
178
|
}
|
|
94
|
-
|
|
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
|
-
}
|
|
179
|
+
await atDoor();
|
|
147
180
|
}
|
|
148
181
|
// The page hands the config in a JSON script, which a content security
|
|
149
182
|
// policy allows where an inline script is not.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { DiskHarbor } from '../harbor/disk.ts';
|
|
3
|
+
export type WebOptions = {
|
|
4
|
+
at: {
|
|
5
|
+
quo: string;
|
|
6
|
+
web: string;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
export declare function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessage, res: ServerResponse, rest: string) => Promise<boolean>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { build } from 'esbuild';
|
|
4
|
+
import { door } from './door.js';
|
|
5
|
+
// The paths under `/web` that are the exchange's, never a ward's name.
|
|
6
|
+
const RESERVED_PATHS = new Set(['login', 'allow', 'tab.js']);
|
|
7
|
+
export function webRoute(harbor, o) {
|
|
8
|
+
const tabEntry = () => {
|
|
9
|
+
const js = fileURLToPath(new URL('./tab.js', import.meta.url));
|
|
10
|
+
return existsSync(js) ? js : fileURLToPath(new URL('./tab.ts', import.meta.url));
|
|
11
|
+
};
|
|
12
|
+
let bundle;
|
|
13
|
+
const built = () => (bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((out) => out.outputFiles[0].text));
|
|
14
|
+
const quoOrigin = (() => {
|
|
15
|
+
try {
|
|
16
|
+
const u = new URL(o.at.quo);
|
|
17
|
+
return `${u.origin} ${u.origin.replace(/^http/, 'ws')}`;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
})();
|
|
23
|
+
const host = (() => {
|
|
24
|
+
try {
|
|
25
|
+
return new URL(o.at.web).host;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return o.at.web;
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
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'`;
|
|
32
|
+
const shell = (body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
|
|
33
|
+
<style>${CSS}</style>
|
|
34
|
+
</head><body>${body}</body></html>`;
|
|
35
|
+
const esc = (v) => v.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
36
|
+
const publicOf = (h) => h.partition.public ?? null;
|
|
37
|
+
const html = (status, res, body) => {
|
|
38
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'no-referrer' });
|
|
39
|
+
res.end(shell(body));
|
|
40
|
+
return true;
|
|
41
|
+
};
|
|
42
|
+
const wards = () => Object.fromEntries([...harbor.wards].map(([n, h]) => [n, { pk: h.pk, public: publicOf(h) !== null }]));
|
|
43
|
+
const tab = (ward) => {
|
|
44
|
+
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), ...(ward ? { ward } : {}) };
|
|
45
|
+
return `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
|
|
46
|
+
};
|
|
47
|
+
return async (req, res, rest) => {
|
|
48
|
+
const parts = rest.split('/').filter(Boolean);
|
|
49
|
+
if (req.method === 'GET' && (rest === '' || rest === '/')) {
|
|
50
|
+
const list = [...harbor.wards]
|
|
51
|
+
.filter(([, h]) => publicOf(h) !== null)
|
|
52
|
+
.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>`)
|
|
53
|
+
.join('');
|
|
54
|
+
return html(200, res, `${list ? `<h1>worlds</h1><ul>${list}</ul>` : door({ host })}${tab()}`);
|
|
55
|
+
}
|
|
56
|
+
if (rest === '/tab.js' && req.method === 'GET') {
|
|
57
|
+
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
|
|
58
|
+
res.end(await built());
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
const wardName = parts[0] ?? '';
|
|
62
|
+
if (!wardName || RESERVED_PATHS.has(wardName))
|
|
63
|
+
return false;
|
|
64
|
+
const hosted = harbor.wards.get(wardName);
|
|
65
|
+
if (!hosted)
|
|
66
|
+
return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
|
|
67
|
+
if (parts.length === 1 && req.method === 'GET')
|
|
68
|
+
return html(200, res, tab(wardName));
|
|
69
|
+
return false;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// The tab's stylesheet: one, light and dark, honouring the variables a look
|
|
73
|
+
// sets on a section. The page owns layout; a far being paints inside her
|
|
74
|
+
// section and nowhere else. The door page is the one designed thing in it.
|
|
75
|
+
const CSS = [
|
|
76
|
+
':root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px;--ink:#1c1b22;--paper:#fbfaf7;--mute:#6b6a73}',
|
|
77
|
+
'@media(prefers-color-scheme:dark){:root{--ink:#ecebe6;--paper:#141318;--mute:#9a99a2}}',
|
|
78
|
+
'html{background:var(--paper);color:var(--ink)}',
|
|
79
|
+
'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}',
|
|
80
|
+
'nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}',
|
|
81
|
+
'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}',
|
|
82
|
+
'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}',
|
|
83
|
+
'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}',
|
|
84
|
+
'fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}',
|
|
85
|
+
'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}',
|
|
86
|
+
'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}',
|
|
87
|
+
// the door page: one column, generous air, the mark, a word, a sentence
|
|
88
|
+
'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}',
|
|
89
|
+
'main.door .mark{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
|
|
90
|
+
'main.door h1{font-size:3rem;font-weight:400;letter-spacing:-.02em;line-height:1;margin:0 0 .5rem}',
|
|
91
|
+
'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}',
|
|
92
|
+
'main.door .lead{font-size:1.5rem;line-height:1.3;margin:0 0 1rem}',
|
|
93
|
+
'main.door p{font-size:1.05rem;line-height:1.6;margin:0 0 1rem;max-width:30rem}',
|
|
94
|
+
'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}',
|
|
95
|
+
].join('');
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Harbor, Hosted } from '@quo-systems/quo/harbor';
|
|
2
|
+
import { Avatar } from '../beings/avatar.ts';
|
|
3
|
+
export declare const localName: (pk: string) => string;
|
|
4
|
+
export declare function world(harbor: Harbor, pk: string): Promise<Hosted>;
|
|
5
|
+
export type Relation = {
|
|
6
|
+
key: string;
|
|
7
|
+
avatar: Avatar;
|
|
8
|
+
};
|
|
9
|
+
export declare function relations(ward: Hosted): Relation[];
|
|
10
|
+
export declare function fresh(ward: Hosted): Promise<Relation>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Avatar, USER } from '../beings/avatar.js';
|
|
2
|
+
// The local ward for a far world, by its pk: created on first sight, on
|
|
3
|
+
// the tab's own seed, empty.
|
|
4
|
+
export const localName = (pk) => `w-${pk.slice(0, 16)}`;
|
|
5
|
+
export async function world(harbor, pk) {
|
|
6
|
+
const name = localName(pk);
|
|
7
|
+
return harbor.wards.get(name) ?? (await harbor.create(name, 'me'));
|
|
8
|
+
}
|
|
9
|
+
export function relations(ward) {
|
|
10
|
+
const beings = ward.partition.beings ?? {};
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const key of Object.keys(beings)) {
|
|
13
|
+
const a = ward.being(key);
|
|
14
|
+
if (a instanceof Avatar && a.standings[USER])
|
|
15
|
+
out.push({ key, avatar: a });
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
// An avatar for a relation not yet made: one who holds no standing, whether
|
|
20
|
+
// left by a guest who never came in or booted now under the next free key.
|
|
21
|
+
// She is the one who knocks, as a guest or with an invitation, and she
|
|
22
|
+
// becomes a relation the moment she takes her standing.
|
|
23
|
+
export async function fresh(ward) {
|
|
24
|
+
const beings = ward.partition.beings ?? {};
|
|
25
|
+
for (const key of Object.keys(beings)) {
|
|
26
|
+
const a = ward.being(key);
|
|
27
|
+
if (a instanceof Avatar && !a.standings[USER])
|
|
28
|
+
return { key, avatar: a };
|
|
29
|
+
}
|
|
30
|
+
let n = 1;
|
|
31
|
+
while (beings[`r${n}`])
|
|
32
|
+
n++;
|
|
33
|
+
const key = `r${n}`;
|
|
34
|
+
const out = (await ward.ask('boot', { key, class: 'Avatar' }));
|
|
35
|
+
if (out.error)
|
|
36
|
+
throw new Error(`the tab could not boot an avatar: ${out.error}`);
|
|
37
|
+
return { key, avatar: ward.being(key) };
|
|
38
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { OAuth } from './oauth.ts';
|
|
2
|
+
import type { McpHttp } from './http.ts';
|
|
3
|
+
import type { Handler } from '../cli/http.ts';
|
|
4
|
+
export type Routes = {
|
|
5
|
+
mcp: string;
|
|
6
|
+
web: string;
|
|
7
|
+
quo?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function mcpRoute(dir: string, routes: Routes, mcp: McpHttp | null): Promise<{
|
|
10
|
+
oauth: OAuth;
|
|
11
|
+
handler: Handler;
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The mcp. route: the credential exchange and the MCP endpoint, served by
|
|
3
|
+
// the daemon under `/mcp` and mapped by the proxy from the mcp. hostname.
|
|
4
|
+
// The OAuth store is `<dir>/oauth.json`, the route's own and never a
|
|
5
|
+
// ward's: clients, pending requests, codes and tokens, each mapping to a
|
|
6
|
+
// client identity at most. A bearer names an identity in a world; the
|
|
7
|
+
// endpoint hands both to the model side, which runs beside her avatar.
|
|
8
|
+
import { readFile, writeFile, rename } from 'node:fs/promises';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { OAuth, emptyStore } from './oauth.js';
|
|
12
|
+
export async function mcpRoute(dir, routes, mcp) {
|
|
13
|
+
const file = join(dir, 'oauth.json');
|
|
14
|
+
const store = existsSync(file) ? { ...emptyStore(), ...JSON.parse(await readFile(file, 'utf8')) } : emptyStore();
|
|
15
|
+
let queue = Promise.resolve();
|
|
16
|
+
const persist = (s) => (queue = queue.then(async () => {
|
|
17
|
+
await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
|
|
18
|
+
await rename(file + '.tmp', file);
|
|
19
|
+
}));
|
|
20
|
+
const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/login?request=${id}`, store, persist });
|
|
21
|
+
const handler = async (req, res, rest) => {
|
|
22
|
+
if (await oauth.handle(req, res, rest))
|
|
23
|
+
return;
|
|
24
|
+
if (rest === '/mcp') {
|
|
25
|
+
const grant = oauth.bearer(req);
|
|
26
|
+
if (grant === null || !mcp)
|
|
27
|
+
return oauth.challenge(res);
|
|
28
|
+
return mcp.handle(req, res, grant.identity, grant.ward);
|
|
29
|
+
}
|
|
30
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
31
|
+
res.end(JSON.stringify({ error: 'no such route' }));
|
|
32
|
+
};
|
|
33
|
+
return { oauth, handler };
|
|
34
|
+
}
|
package/harbor/quo-harbor.md
CHANGED
|
@@ -200,14 +200,17 @@ rotation, and nothing else.
|
|
|
200
200
|
|
|
201
201
|
## Reach, directory, rendezvous
|
|
202
202
|
|
|
203
|
-
A harbor needs
|
|
203
|
+
A harbor needs two reach kinds and nothing more, which is what
|
|
204
|
+
`packages/quo/SPEC.md` says and what `packages/quo/src/harbor/reach.ts` is:
|
|
204
205
|
|
|
205
206
|
- **request**: a URL. Send bytes, get bytes. Listener to listener.
|
|
206
207
|
- **socket**: a held connection, opened by whichever side can dial, used in
|
|
207
208
|
both directions with a frame id. A WebSocket, a WebRTC channel and a
|
|
208
209
|
native socket are one socket reach.
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
|
|
211
|
+
Two runtimes on one device are no third kind. Either they are one harbor,
|
|
212
|
+
and it calls its other door with no transport at all, or they are two
|
|
213
|
+
harbors, and one of them dials the other as any dialer does.
|
|
211
214
|
|
|
212
215
|
Plus one forwarding rule: a pk whose reach is another harbor that holds a
|
|
213
216
|
socket to it. That rule is the **rendezvous**, and with it every dialer is
|
|
@@ -260,7 +263,8 @@ rebinds it.
|
|
|
260
263
|
The reach interface and its two implementations are the library's,
|
|
261
264
|
`packages/quo/src/harbor/reach.ts`, and pass its reach suite here: the request
|
|
262
265
|
and a socket to a daemon in `packages/dock/test/wire.test.ts`, the request
|
|
263
|
-
through the worker inside the edge. The listener half is the terrain's,
|
|
266
|
+
through the worker inside the edge. The listener half is the terrain's,
|
|
267
|
+
`packages/dock/harbor/quo.ts`, the route the daemon mounts under `/quo`:
|
|
264
268
|
|
|
265
269
|
```
|
|
266
270
|
request POST <url>/<pk>, the sealed ask as the body, the sealed reply as the answer;
|
|
@@ -289,19 +293,18 @@ request. So a third harbor with a hint that a phone's pk is at acme's
|
|
|
289
293
|
`quo serve --dial URL`, or `<dir>/dial.json`, makes a daemon a dialer: it
|
|
290
294
|
holds one socket per URL, announces its wards on open, binds the listener's
|
|
291
295
|
pks, and reconnects with backoff from a second to thirty when the line
|
|
292
|
-
drops. A pk is 64 bytes on the wire, the ward key and the padlock
|
|
293
|
-
first version of the ask frame got wrong and the suite caught in one run.
|
|
296
|
+
drops. A pk is 64 bytes on the wire, the ward key and the padlock.
|
|
294
297
|
|
|
295
298
|
The proof is `packages/dock/test/wire.test.ts`: the library's conformance suite,
|
|
296
299
|
untouched, against two disk harbors in two folders with HTTP doors on
|
|
297
300
|
loopback, once with every ask a request and once with the second harbor
|
|
298
301
|
dialing the first; down is the reach refused, migrate is the ward folder
|
|
299
|
-
moved and adopted. Then the rendezvous on its own.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
302
|
+
moved and adopted. Then the rendezvous on its own.
|
|
303
|
+
|
|
304
|
+
Two rules the daemon obeys and a reader would otherwise rediscover: a harbor
|
|
305
|
+
writes the partition before it lets a folder go, because a being driven
|
|
306
|
+
in-process changes it without passing a door; and a proxy sends the route
|
|
307
|
+
root as `/quo/`, with the trailing slash, which the door takes.
|
|
305
308
|
|
|
306
309
|
## The two cores, as built
|
|
307
310
|
|
|
@@ -338,7 +341,7 @@ harbors in one tab with two databases, both dialing a Node daemon on
|
|
|
338
341
|
loopback, so every relation crosses the rendezvous; down is the reach
|
|
339
342
|
refused on both, migrate lifts seed, partition and record from one database
|
|
340
343
|
into the other and boots there, the tab-to-home scene. And the lock: a
|
|
341
|
-
second harbor over one database does not boot.
|
|
344
|
+
second harbor over one database does not boot.
|
|
342
345
|
|
|
343
346
|
## The edge, as built
|
|
344
347
|
|
|
@@ -370,11 +373,10 @@ against two edge harbors in one object over two prefixed views of its
|
|
|
370
373
|
storage, reaching each other by request through the worker; the root door
|
|
371
374
|
refused without the secret and answering under it; and a Node daemon
|
|
372
375
|
dialing the edge, its pk held there, reached by a third harbor through the
|
|
373
|
-
edge, unreached when the line drops.
|
|
374
|
-
touch another's storage,
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
which wants a plain one.
|
|
376
|
+
edge, unreached when the line drops. Two platform rules shape it: one object
|
|
377
|
+
may not touch another's storage, so the exercise's two harbors share one
|
|
378
|
+
object the way two tabs share a page; and the platform's crypto refuses a
|
|
379
|
+
Uint8Array over a shared buffer and wants a plain one.
|
|
378
380
|
|
|
379
381
|
The `ws` question the road asked is answered by this terrain: the socket
|
|
380
382
|
reach's framing and the dialer are written on the standard surface and
|
|
@@ -399,10 +401,10 @@ device
|
|
|
399
401
|
quo.sock owner asks; local only; never behind a proxy
|
|
400
402
|
side.sock local sides; local only; never behind a proxy
|
|
401
403
|
127.0.0.1:8787 the HTTP door, loopback only; routes mount by path
|
|
402
|
-
/health the wards it hosts, by name and pk
|
|
403
|
-
/mcp the model side
|
|
404
|
-
/web the
|
|
405
|
-
/quo
|
|
404
|
+
/health the wards it hosts, by name and pk; cli/http.ts
|
|
405
|
+
/mcp the model side; mcp/route.ts
|
|
406
|
+
/web the worlds' pages and the exchange; human/web.ts, mcp/web/
|
|
407
|
+
/quo the socket door: request in, sockets held, the rendezvous; harbor/quo.ts
|
|
406
408
|
reverse proxy
|
|
407
409
|
mcp.example.com -> 127.0.0.1:8787/mcp
|
|
408
410
|
web.example.com -> 127.0.0.1:8787/web
|
|
@@ -426,15 +428,13 @@ There is no `cli.` route.
|
|
|
426
428
|
|
|
427
429
|
## Acceptance
|
|
428
430
|
|
|
429
|
-
|
|
430
|
-
untouched, and by nothing else.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
day. Reach, store and the core now hold on three terrains, which is where the
|
|
434
|
-
library takes them. Every store passes the library's store suite,
|
|
431
|
+
A harbor here is accepted when it passes the library's conformance suite
|
|
432
|
+
untouched, and by nothing else. Three do: the disk harbor over the wire
|
|
433
|
+
under both reaches, the browser harbor through a daemon, and the edge harbor
|
|
434
|
+
inside the platform's runtime. Every store passes the library's store suite,
|
|
435
435
|
`packages/quo/src/conformance/store.ts`: files in
|
|
436
436
|
`packages/dock/test/store.test.ts`, IndexedDB and the edge's storage inside
|
|
437
437
|
their terrains behind `check:terrain`. Every reach passes the library's reach
|
|
438
438
|
suite, `packages/quo/src/conformance/reach.ts`. The store, the reach and the
|
|
439
|
-
core are
|
|
440
|
-
|
|
439
|
+
core are the library's, and every harbor here is one of them over its own
|
|
440
|
+
terrain.
|