@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/cli/daemon.d.ts
CHANGED
|
@@ -1,21 +1,13 @@
|
|
|
1
|
-
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
-
import { Socket as Held, type Dialer } from '@quo-systems/quo/harbor';
|
|
3
1
|
import { DiskHarbor, type Hosted } from '../harbor/disk.ts';
|
|
4
2
|
import { type Avatar } from '../beings/index.ts';
|
|
5
3
|
import { type Agent } from '../mcp/agent.ts';
|
|
6
|
-
import { OAuth } from '../mcp/oauth.ts';
|
|
4
|
+
import type { OAuth } from '../mcp/oauth.ts';
|
|
7
5
|
import { Exchange } from '../mcp/web/exchange.ts';
|
|
8
6
|
import { McpHttp } from '../mcp/http.ts';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
mount(prefix: string, handler: Handler): void;
|
|
14
|
-
};
|
|
15
|
-
export type Quo = {
|
|
16
|
-
sockets: Set<Held>;
|
|
17
|
-
dialers: Dialer[];
|
|
18
|
-
};
|
|
7
|
+
import { type Routes } from '../mcp/route.ts';
|
|
8
|
+
import { type Http, type Quo } from './http.ts';
|
|
9
|
+
export type { Handler, Http, Quo } from './http.ts';
|
|
10
|
+
export type { Routes } from '../mcp/route.ts';
|
|
19
11
|
export type Serving = {
|
|
20
12
|
harbor: DiskHarbor;
|
|
21
13
|
sock: string;
|
|
@@ -28,11 +20,6 @@ export type Serving = {
|
|
|
28
20
|
quo: Quo;
|
|
29
21
|
close(): Promise<void>;
|
|
30
22
|
};
|
|
31
|
-
export type Routes = {
|
|
32
|
-
mcp: string;
|
|
33
|
-
web: string;
|
|
34
|
-
quo?: string;
|
|
35
|
-
};
|
|
36
23
|
export type Agents = Record<string, {
|
|
37
24
|
command: string;
|
|
38
25
|
args?: string[];
|
|
@@ -55,3 +42,4 @@ export declare function admit(hosted: Hosted, identity: string, kind: 'local' |
|
|
|
55
42
|
error?: string;
|
|
56
43
|
}>;
|
|
57
44
|
export declare function serve(dir: string, options?: Options): Promise<Serving>;
|
|
45
|
+
export declare function readRoutes(dir: string): Promise<Routes | null>;
|
package/dist/cli/daemon.js
CHANGED
|
@@ -37,28 +37,20 @@
|
|
|
37
37
|
// her from the user being runs the command in the folder with the event.
|
|
38
38
|
//
|
|
39
39
|
// A third door, for occupants from elsewhere, is one HTTP listener on
|
|
40
|
-
// localhost that a reverse proxy fronts. Routes mount on it by
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// socket for, and an upgrade at `/quo` is a dialer's held socket,
|
|
49
|
-
// announcing its pks to be bound. With
|
|
50
|
-
// `--dial URL`, or `<dir>/dial.json`, this daemon is itself a dialer: it
|
|
51
|
-
// holds one socket to each URL, announces its wards, and reconnects with
|
|
40
|
+
// localhost that a reverse proxy fronts, `http.ts`. Routes mount on it by
|
|
41
|
+
// path, and each lives in the folder whose truth it is: `/web`, the worlds'
|
|
42
|
+
// pages and the exchange, in `human/web.ts` and `mcp/web/`; `/mcp`, the
|
|
43
|
+
// model side behind its credential exchange, in `mcp/route.ts`; `/quo`, the
|
|
44
|
+
// socket door, in `harbor/quo.ts`. It listens only when asked, and only on
|
|
45
|
+
// loopback: the proxy is what faces the world, and the daemon never does.
|
|
46
|
+
// With `--dial URL`, or `<dir>/dial.json`, this daemon is itself a dialer:
|
|
47
|
+
// it holds one socket to each URL, announces its wards, and reconnects with
|
|
52
48
|
// backoff, so a Mac behind NAT is reached through the droplet it dialed.
|
|
53
49
|
import { createServer } from 'node:net';
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
56
|
-
import { build } from 'esbuild';
|
|
57
|
-
import { fileURLToPath } from 'node:url';
|
|
58
|
-
import { Socket as Held, dial, SUITE } from '@quo-systems/quo/harbor';
|
|
59
|
-
import { unlink, chmod, readFile, writeFile, rename } from 'node:fs/promises';
|
|
50
|
+
import { dial } from '@quo-systems/quo/harbor';
|
|
51
|
+
import { unlink, chmod, readFile } from 'node:fs/promises';
|
|
60
52
|
import { existsSync } from 'node:fs';
|
|
61
|
-
import { createHash, randomBytes
|
|
53
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
62
54
|
import { tmpdir } from 'node:os';
|
|
63
55
|
import { join, resolve } from 'node:path';
|
|
64
56
|
import { createInterface } from 'node:readline';
|
|
@@ -68,9 +60,12 @@ import { Desk } from '../beings/index.js';
|
|
|
68
60
|
import { mcpSide } from '../mcp/server.js';
|
|
69
61
|
import { runnerSide } from '../mcp/runner.js';
|
|
70
62
|
import { agentSide, processRun } from '../mcp/agent.js';
|
|
71
|
-
import { OAuth, emptyStore } from '../mcp/oauth.js';
|
|
72
63
|
import { Exchange } from '../mcp/web/exchange.js';
|
|
73
64
|
import { McpHttp } from '../mcp/http.js';
|
|
65
|
+
import { mcpRoute } from '../mcp/route.js';
|
|
66
|
+
import { webRoute } from '../human/web.js';
|
|
67
|
+
import { quoRoute } from '../harbor/quo.js';
|
|
68
|
+
import { listenHttp } from './http.js';
|
|
74
69
|
// The sockets live in the harbor's folder, where the folder's own permissions
|
|
75
70
|
// guard them. A unix socket path is short by law, about a hundred bytes on
|
|
76
71
|
// macOS, so a deep folder gets its sockets in the temp dir under a name only
|
|
@@ -101,21 +96,6 @@ const honour = (kind) => (proof) => {
|
|
|
101
96
|
};
|
|
102
97
|
Desk.verifiers.local = honour('local');
|
|
103
98
|
Desk.verifiers.web = honour('web');
|
|
104
|
-
Desk.verifiers.tab = honour('tab'); // a tab that gave the owner password at the web route
|
|
105
|
-
// What a request may carry, on the doors that read a body: enough for any ask
|
|
106
|
-
// a ward seals, and not a body chosen by whoever is on the other end.
|
|
107
|
-
const BODY = 1 << 20;
|
|
108
|
-
async function readAll(req) {
|
|
109
|
-
const chunks = [];
|
|
110
|
-
let size = 0;
|
|
111
|
-
for await (const c of req) {
|
|
112
|
-
size += c.length;
|
|
113
|
-
if (size > BODY)
|
|
114
|
-
return undefined;
|
|
115
|
-
chunks.push(c);
|
|
116
|
-
}
|
|
117
|
-
return Buffer.concat(chunks);
|
|
118
|
-
}
|
|
119
99
|
// Admit a client identity into a ward: find or boot her avatar, and enter
|
|
120
100
|
// her with a nonce the desk honours once. On a reconnect she already holds
|
|
121
101
|
// `user` and nothing is minted, so `wake`, the human's word that this
|
|
@@ -175,12 +155,12 @@ export async function serve(dir, options = {}) {
|
|
|
175
155
|
const { server, ...door } = await listenHttp(harbor, options.http.host ?? '127.0.0.1', options.http.port, quo);
|
|
176
156
|
servers.push(server);
|
|
177
157
|
http = door;
|
|
178
|
-
|
|
158
|
+
http.mount('/quo', quoRoute(harbor, server, quo));
|
|
179
159
|
const routes = options.routes ?? (await readRoutes(harbor.dir));
|
|
180
160
|
const password = options.password ?? (() => process.env.QUO_OWNER_PASSWORD);
|
|
181
161
|
const here = `http://${http.host}:${http.port}`;
|
|
182
162
|
// the worlds' pages: always, on the daemon's own door when no route names a public one
|
|
183
|
-
const
|
|
163
|
+
const web = webRoute(harbor, { at: { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` } });
|
|
184
164
|
if (routes) {
|
|
185
165
|
// the worlds a client may be allowed into: every ward with a public being, main first
|
|
186
166
|
const worlds = () => [...harbor.wards]
|
|
@@ -195,7 +175,9 @@ export async function serve(dir, options = {}) {
|
|
|
195
175
|
for (const h of harbor.wards.values())
|
|
196
176
|
await h.save();
|
|
197
177
|
});
|
|
198
|
-
|
|
178
|
+
const route = await mcpRoute(harbor.dir, routes, mcp);
|
|
179
|
+
oauth = route.oauth;
|
|
180
|
+
http.mount('/mcp', route.handler);
|
|
199
181
|
mcp.gone = (identity, ward) => oauth.revoke(identity, ward); // removal at the ward ends the grant at the route
|
|
200
182
|
exchange = new Exchange({
|
|
201
183
|
oauth,
|
|
@@ -212,7 +194,7 @@ export async function serve(dir, options = {}) {
|
|
|
212
194
|
}
|
|
213
195
|
const ex = exchange;
|
|
214
196
|
http.mount('/web', async (req, res, rest) => {
|
|
215
|
-
if (await
|
|
197
|
+
if (await web(req, res, rest))
|
|
216
198
|
return;
|
|
217
199
|
if (ex && (await ex.handle(req, res, rest)))
|
|
218
200
|
return;
|
|
@@ -268,48 +250,6 @@ export async function serve(dir, options = {}) {
|
|
|
268
250
|
},
|
|
269
251
|
};
|
|
270
252
|
}
|
|
271
|
-
// The HTTP door. Loopback only; the proxy faces the world. A route mounts
|
|
272
|
-
// under a prefix and gets the rest of the path. `/health` is the daemon's
|
|
273
|
-
// own: the wards it hosts, by name and pk, and nothing a stranger can use.
|
|
274
|
-
async function listenHttp(harbor, host, port, quo) {
|
|
275
|
-
const mounts = new Map();
|
|
276
|
-
const server = createHttp((req, res) => {
|
|
277
|
-
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
278
|
-
if (url.pathname === '/health') {
|
|
279
|
-
// the wards it hosts, and the directory: pks and whether each is a
|
|
280
|
-
// socket held here, which is what an operator needs to see a dialer.
|
|
281
|
-
// Open to any origin: a tab reads it, and it holds nothing a stranger can use.
|
|
282
|
-
res.writeHead(200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
|
|
283
|
-
res.end(JSON.stringify({
|
|
284
|
-
ok: true,
|
|
285
|
-
wards: Object.fromEntries([...harbor.wards].map(([n, h]) => [n, h.pk])),
|
|
286
|
-
reaches: Object.fromEntries([...harbor.reaches].map(([pk, b]) => [pk, b.held ? 'held' : 'reach'])),
|
|
287
|
-
sockets: quo.sockets.size,
|
|
288
|
-
dialers: quo.dialers.map((d) => ({ url: d.url, open: d.socket !== null })),
|
|
289
|
-
}));
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
for (const [prefix, handler] of mounts) {
|
|
293
|
-
if (url.pathname === prefix || url.pathname.startsWith(prefix + '/')) {
|
|
294
|
-
void Promise.resolve(handler(req, res, url.pathname.slice(prefix.length))).catch(() => {
|
|
295
|
-
if (!res.headersSent)
|
|
296
|
-
res.writeHead(500);
|
|
297
|
-
res.end();
|
|
298
|
-
});
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
res.writeHead(404, { 'content-type': 'application/json' });
|
|
303
|
-
res.end(JSON.stringify({ error: 'no such route' }));
|
|
304
|
-
});
|
|
305
|
-
await new Promise((ok, no) => {
|
|
306
|
-
server.once('error', no);
|
|
307
|
-
server.listen(port, host, () => ok());
|
|
308
|
-
});
|
|
309
|
-
const address = server.address();
|
|
310
|
-
const bound = typeof address === 'object' && address !== null ? address.port : port;
|
|
311
|
-
return { server, host, port: bound, mount: (prefix, handler) => mounts.set(prefix.replace(/\/$/, ''), handler) };
|
|
312
|
-
}
|
|
313
253
|
// A side connection: hello, then MCP over the same lines, or, with `run`,
|
|
314
254
|
// the human's lines in and the model's final texts out.
|
|
315
255
|
async function sideConnection(harbor, c) {
|
|
@@ -417,7 +357,7 @@ async function handle(harbor, line) {
|
|
|
417
357
|
}
|
|
418
358
|
}
|
|
419
359
|
// `<dir>/routes.json`: { mcp, web, quo? }, the public origins the proxy serves.
|
|
420
|
-
async function readRoutes(dir) {
|
|
360
|
+
export async function readRoutes(dir) {
|
|
421
361
|
const p = join(dir, 'routes.json');
|
|
422
362
|
if (!existsSync(p))
|
|
423
363
|
return null;
|
|
@@ -429,163 +369,6 @@ async function readRoutes(dir) {
|
|
|
429
369
|
out.quo = r.quo.replace(/\/$/, '');
|
|
430
370
|
return out;
|
|
431
371
|
}
|
|
432
|
-
// The worlds on the web route. A world is a ward with a public being, and
|
|
433
|
-
// its address is `/web/<ward>`: the tab page, `human/tab.ts`, told which
|
|
434
|
-
// ward and which pk, so that its guest is that ward's public being rendered
|
|
435
|
-
// by the screen, whatever class she is. `/web/` lists the worlds. The bundle
|
|
436
|
-
// is built once from the source beside this file, `.ts` in the tree, `.js`
|
|
437
|
-
// once emitted into the package's dist. `/web/<ward>/login` is the one call
|
|
438
|
-
// of the dock's own way in a tab makes: the owner password for a nonce the
|
|
439
|
-
// desk honours under `tab`. The avatar that knocks with it lives in the tab,
|
|
440
|
-
// not here, so nothing is admitted on this side.
|
|
441
|
-
//
|
|
442
|
-
// The page carries a content security policy: scripts from this origin
|
|
443
|
-
// only and never inline, connections to this origin and the quo. route the
|
|
444
|
-
// tab dials, images from data URIs and this origin, and nothing else. So
|
|
445
|
-
// even a bug in a renderer cannot become a script, and no look can reach a
|
|
446
|
-
// server. The config crosses in a JSON script, which the policy allows.
|
|
447
|
-
const RESERVED_PATHS = new Set(['login', 'allow', 'tab.js']);
|
|
448
|
-
function worldPages(harbor, at, password) {
|
|
449
|
-
const tabEntry = () => {
|
|
450
|
-
const js = fileURLToPath(new URL('../human/tab.js', import.meta.url));
|
|
451
|
-
return existsSync(js) ? js : fileURLToPath(new URL('../human/tab.ts', import.meta.url));
|
|
452
|
-
};
|
|
453
|
-
let bundle;
|
|
454
|
-
const built = () => (bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((o) => o.outputFiles[0].text));
|
|
455
|
-
const quoOrigin = (() => {
|
|
456
|
-
try {
|
|
457
|
-
const u = new URL(at.quo);
|
|
458
|
-
return `${u.origin} ${u.origin.replace(/^http/, 'ws')}`;
|
|
459
|
-
}
|
|
460
|
-
catch {
|
|
461
|
-
return '';
|
|
462
|
-
}
|
|
463
|
-
})();
|
|
464
|
-
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'`;
|
|
465
|
-
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>
|
|
466
|
-
<style>${CSS}</style>
|
|
467
|
-
</head><body>${body}</body></html>`;
|
|
468
|
-
const esc = (v) => v.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
469
|
-
const publicOf = (h) => h.partition.public ?? null;
|
|
470
|
-
const html = (status, res, body) => {
|
|
471
|
-
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'no-referrer' });
|
|
472
|
-
res.end(shell(body));
|
|
473
|
-
return true;
|
|
474
|
-
};
|
|
475
|
-
return async (req, res, rest) => {
|
|
476
|
-
const parts = rest.split('/').filter(Boolean);
|
|
477
|
-
if (req.method === 'GET' && (rest === '' || rest === '/')) {
|
|
478
|
-
const list = [...harbor.wards]
|
|
479
|
-
.filter(([, h]) => publicOf(h) !== null)
|
|
480
|
-
.map(([n, h]) => `<li><a href="${at.web}/${encodeURIComponent(n)}">${esc(n)}</a> <small>${esc(publicOf(h) ?? '')} at the door, <code>${h.pk.slice(0, 16)}…</code></small></li>`)
|
|
481
|
-
.join('');
|
|
482
|
-
return html(200, res, `<h1>worlds</h1>${list ? `<ul>${list}</ul>` : '<p>no ward here has a public being</p>'}`);
|
|
483
|
-
}
|
|
484
|
-
if (rest === '/tab.js' && req.method === 'GET') {
|
|
485
|
-
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
|
|
486
|
-
res.end(await built());
|
|
487
|
-
return true;
|
|
488
|
-
}
|
|
489
|
-
const wardName = parts[0] ?? '';
|
|
490
|
-
if (!wardName || RESERVED_PATHS.has(wardName))
|
|
491
|
-
return false;
|
|
492
|
-
const hosted = harbor.wards.get(wardName);
|
|
493
|
-
if (!hosted)
|
|
494
|
-
return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
|
|
495
|
-
if (parts.length === 1 && req.method === 'GET') {
|
|
496
|
-
if (publicOf(hosted) === null)
|
|
497
|
-
return html(404, res, `<h1>not a world</h1><p>ward ${esc(wardName)} has no public being, so nobody is at its door.</p>`);
|
|
498
|
-
const cfg = { quo: at.quo, web: at.web, ward: wardName, pk: hosted.pk };
|
|
499
|
-
return html(200, res, `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${at.web}/tab.js"></script>`);
|
|
500
|
-
}
|
|
501
|
-
if (parts.length === 2 && parts[1] === 'login' && req.method === 'POST') {
|
|
502
|
-
const raw = await readAll(req);
|
|
503
|
-
let body = {};
|
|
504
|
-
try {
|
|
505
|
-
body = JSON.parse(raw?.toString() || '{}');
|
|
506
|
-
}
|
|
507
|
-
catch {
|
|
508
|
-
/* nothing to read */
|
|
509
|
-
}
|
|
510
|
-
const json = (status, v) => {
|
|
511
|
-
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
512
|
-
res.end(JSON.stringify(v));
|
|
513
|
-
return true;
|
|
514
|
-
};
|
|
515
|
-
const want = password();
|
|
516
|
-
if (want === undefined)
|
|
517
|
-
return json(503, { error: 'closed: no owner password is set' });
|
|
518
|
-
const got = typeof body.password === 'string' ? body.password : '';
|
|
519
|
-
if (got.length !== want.length || !timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
|
|
520
|
-
await new Promise((ok) => setTimeout(ok, 300));
|
|
521
|
-
return json(401, { error: 'that is not the password' });
|
|
522
|
-
}
|
|
523
|
-
const identity = typeof body.identity === 'string' ? body.identity : '';
|
|
524
|
-
if (!/^[\w.-]{1,40}$/.test(identity) || identity === hosted.record.user || identity === 'desk')
|
|
525
|
-
return json(400, { error: 'an identity is one word, and not the user or the desk' });
|
|
526
|
-
const nonce = randomBytes(16).toString('hex');
|
|
527
|
-
NONCES.set(nonce, { kind: 'tab', user: hosted.record.user, client: identity, reach: true });
|
|
528
|
-
return json(200, { nonce, ward: hosted.pk });
|
|
529
|
-
}
|
|
530
|
-
return false;
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
// The tab's stylesheet: one, light and dark, honouring the variables a look
|
|
534
|
-
// sets on a section. The page owns layout; a far being paints inside her
|
|
535
|
-
// section and nowhere else.
|
|
536
|
-
const CSS = ":root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px}body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}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}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}fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}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}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}";
|
|
537
|
-
// The quo. route. A request carries one ask to a pk; an upgrade is a held
|
|
538
|
-
// socket. Bytes from here go to an own door or a held socket, never onward.
|
|
539
|
-
function mountQuo(harbor, http, server, quo) {
|
|
540
|
-
const pks = () => [...harbor.doors.keys()];
|
|
541
|
-
// The route is public and carries sealed bytes, so any origin may POST
|
|
542
|
-
// to it: a tab on a world's web. reaching another world's quo. is the
|
|
543
|
-
// ordinary case, and a binary POST needs the preflight answered.
|
|
544
|
-
const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, quo-suite' };
|
|
545
|
-
http.mount('/quo', async (req, res, rest) => {
|
|
546
|
-
if (req.method === 'OPTIONS') {
|
|
547
|
-
res.writeHead(204, open);
|
|
548
|
-
return void res.end();
|
|
549
|
-
}
|
|
550
|
-
// The wire suite the caller speaks. Absent is this one, because a caller
|
|
551
|
-
// older than the header is this one. Anything else this door cannot open
|
|
552
|
-
// and says so as nothing delivered, rather than taking bytes that will
|
|
553
|
-
// never open and answering a silence that names no reason.
|
|
554
|
-
const suite = req.headers['quo-suite'];
|
|
555
|
-
if (suite !== undefined && suite !== String(SUITE)) {
|
|
556
|
-
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
557
|
-
return void res.end(JSON.stringify({ error: `this door speaks wire suite ${SUITE}` }));
|
|
558
|
-
}
|
|
559
|
-
const pk = rest.slice(1);
|
|
560
|
-
if (req.method !== 'POST' || !/^[0-9a-f]{128}$/.test(pk)) {
|
|
561
|
-
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
562
|
-
return void res.end(JSON.stringify({ error: 'POST /quo/<pk>' }));
|
|
563
|
-
}
|
|
564
|
-
const raw = await readAll(req);
|
|
565
|
-
const back = raw === undefined ? undefined : await harbor.deliver(pk, new Uint8Array(raw));
|
|
566
|
-
if (back === undefined) {
|
|
567
|
-
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
568
|
-
return void res.end(JSON.stringify({ error: 'no reach for that pk' }));
|
|
569
|
-
}
|
|
570
|
-
res.writeHead(200, { 'content-type': 'application/octet-stream', ...open });
|
|
571
|
-
res.end(Buffer.from(back));
|
|
572
|
-
});
|
|
573
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
574
|
-
server.on('upgrade', (req, socket, head) => {
|
|
575
|
-
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
576
|
-
if (url.pathname.replace(/\/$/, '') !== '/quo')
|
|
577
|
-
return void socket.destroy(); // the proxy rewrites the route root to /quo/
|
|
578
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
579
|
-
const s = new Held(ws, (pk, bytes) => harbor.deliver(pk, bytes), (far) => void harbor.bind(far, s, true), // a dialer's claims, each proven at its door: held here, reachable through this socket
|
|
580
|
-
() => {
|
|
581
|
-
harbor.unbind(s);
|
|
582
|
-
quo.sockets.delete(s);
|
|
583
|
-
});
|
|
584
|
-
quo.sockets.add(s);
|
|
585
|
-
s.announce(pks());
|
|
586
|
-
});
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
372
|
// `<dir>/dial.json`: [url, ...], the quo. routes this daemon holds a socket to.
|
|
590
373
|
async function readDial(dir) {
|
|
591
374
|
const p = join(dir, 'dial.json');
|
|
@@ -607,29 +390,3 @@ async function readAgents(dir) {
|
|
|
607
390
|
}
|
|
608
391
|
return out;
|
|
609
392
|
}
|
|
610
|
-
// The mcp route: OAuth today, the MCP endpoint at step 9. Its store is
|
|
611
|
-
// `<dir>/oauth.json`, the route's own and never a ward's: clients, pending
|
|
612
|
-
// requests, codes and tokens, each mapping to a client identity at most.
|
|
613
|
-
async function mountOAuth(dir, http, routes, mcp) {
|
|
614
|
-
const file = join(dir, 'oauth.json');
|
|
615
|
-
const store = existsSync(file) ? { ...emptyStore(), ...JSON.parse(await readFile(file, 'utf8')) } : emptyStore();
|
|
616
|
-
let queue = Promise.resolve();
|
|
617
|
-
const persist = (s) => (queue = queue.then(async () => {
|
|
618
|
-
await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
|
|
619
|
-
await rename(file + '.tmp', file);
|
|
620
|
-
}));
|
|
621
|
-
const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/login?request=${id}`, store, persist });
|
|
622
|
-
http.mount('/mcp', async (req, res, rest) => {
|
|
623
|
-
if (await oauth.handle(req, res, rest))
|
|
624
|
-
return;
|
|
625
|
-
if (rest === '/mcp') {
|
|
626
|
-
const grant = oauth.bearer(req);
|
|
627
|
-
if (grant === null || !mcp)
|
|
628
|
-
return oauth.challenge(res);
|
|
629
|
-
return mcp.handle(req, res, grant.identity, grant.ward);
|
|
630
|
-
}
|
|
631
|
-
res.writeHead(404, { 'content-type': 'application/json' });
|
|
632
|
-
res.end(JSON.stringify({ error: 'no such route' }));
|
|
633
|
-
});
|
|
634
|
-
return oauth;
|
|
635
|
-
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse, type Server as HttpServer } from 'node:http';
|
|
2
|
+
import type { Socket as Held, Dialer } from '@quo-systems/quo/harbor';
|
|
3
|
+
import type { DiskHarbor } from '../harbor/disk.ts';
|
|
4
|
+
export type Handler = (req: IncomingMessage, res: ServerResponse, rest: string) => void | Promise<void>;
|
|
5
|
+
export type Http = {
|
|
6
|
+
port: number;
|
|
7
|
+
host: string;
|
|
8
|
+
mount(prefix: string, handler: Handler): void;
|
|
9
|
+
};
|
|
10
|
+
export type Quo = {
|
|
11
|
+
sockets: Set<Held>;
|
|
12
|
+
dialers: Dialer[];
|
|
13
|
+
};
|
|
14
|
+
export declare function readAll(req: AsyncIterable<Buffer>): Promise<Buffer | undefined>;
|
|
15
|
+
export declare function listenHttp(harbor: DiskHarbor, host: string, port: number, quo: Quo): Promise<Http & {
|
|
16
|
+
server: HttpServer;
|
|
17
|
+
}>;
|
package/dist/cli/http.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The HTTP door: one listener on loopback that a reverse proxy fronts, and
|
|
3
|
+
// the routes mount on it by path prefix. `/health` is the daemon's own: the
|
|
4
|
+
// wards it hosts, by name and pk, and nothing a stranger can use. Each route
|
|
5
|
+
// lives in its own folder, `human/web.ts`, `harbor/quo.ts`, `mcp/route.ts`,
|
|
6
|
+
// and this file knows none of them: a route is a handler under a prefix.
|
|
7
|
+
import { createServer as createHttp } from 'node:http';
|
|
8
|
+
// What a request may carry, on the doors that read a body: enough for any ask
|
|
9
|
+
// a ward seals, and not a body chosen by whoever is on the other end.
|
|
10
|
+
const BODY = 1 << 20;
|
|
11
|
+
export async function readAll(req) {
|
|
12
|
+
const chunks = [];
|
|
13
|
+
let size = 0;
|
|
14
|
+
for await (const c of req) {
|
|
15
|
+
size += c.length;
|
|
16
|
+
if (size > BODY)
|
|
17
|
+
return undefined;
|
|
18
|
+
chunks.push(c);
|
|
19
|
+
}
|
|
20
|
+
return Buffer.concat(chunks);
|
|
21
|
+
}
|
|
22
|
+
export async function listenHttp(harbor, host, port, quo) {
|
|
23
|
+
const mounts = new Map();
|
|
24
|
+
const server = createHttp((req, res) => {
|
|
25
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
26
|
+
if (url.pathname === '/health') {
|
|
27
|
+
// the wards it hosts, and the directory: pks and whether each is a
|
|
28
|
+
// socket held here, which is what an operator needs to see a dialer.
|
|
29
|
+
// Open to any origin: a tab reads it, and it holds nothing a stranger can use.
|
|
30
|
+
res.writeHead(200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
|
|
31
|
+
res.end(JSON.stringify({
|
|
32
|
+
ok: true,
|
|
33
|
+
wards: Object.fromEntries([...harbor.wards].map(([n, h]) => [n, h.pk])),
|
|
34
|
+
reaches: Object.fromEntries([...harbor.reaches].map(([pk, b]) => [pk, b.held ? 'held' : 'reach'])),
|
|
35
|
+
sockets: quo.sockets.size,
|
|
36
|
+
dialers: quo.dialers.map((d) => ({ url: d.url, open: d.socket !== null })),
|
|
37
|
+
}));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const [prefix, handler] of mounts) {
|
|
41
|
+
if (url.pathname === prefix || url.pathname.startsWith(prefix + '/')) {
|
|
42
|
+
void Promise.resolve(handler(req, res, url.pathname.slice(prefix.length))).catch(() => {
|
|
43
|
+
if (!res.headersSent)
|
|
44
|
+
res.writeHead(500);
|
|
45
|
+
res.end();
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
51
|
+
res.end(JSON.stringify({ error: 'no such route' }));
|
|
52
|
+
});
|
|
53
|
+
await new Promise((ok, no) => {
|
|
54
|
+
server.once('error', no);
|
|
55
|
+
server.listen(port, host, () => ok());
|
|
56
|
+
});
|
|
57
|
+
const address = server.address();
|
|
58
|
+
const bound = typeof address === 'object' && address !== null ? address.port : port;
|
|
59
|
+
return { server, host, port: bound, mount: (prefix, handler) => mounts.set(prefix.replace(/\/$/, ''), handler) };
|
|
60
|
+
}
|
package/dist/cli/quo.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// quo serve [--dir D] [--http PORT] [--dial URL] the daemon: harbor from disk, the two sockets, the HTTP door on loopback, a socket held to URL
|
|
9
9
|
// quo census [--dir D] [--ward W] [--via S] the empty ask: pk and every being
|
|
10
10
|
// quo boot [--dir D] [--ward W] [--via S] <json> { key, class, public? }
|
|
11
|
-
// quo invite [--dir D] [--ward W] [--via S] <json> { being, id }
|
|
11
|
+
// quo invite [--dir D] [--ward W] [--via S] <json> { being, id }; with routes.json naming web., the answer carries the link
|
|
12
12
|
// quo knock [--dir D] [--ward W] [--via S] <json> { being | { boot, key }, id, invitation, method?, args?, wanted? }
|
|
13
13
|
// quo remove [--dir D] [--ward W] [--via S] <json> { being, id }: a relation out of a being; on the ward pk, an owner
|
|
14
14
|
// quo unboot [--dir D] [--ward W] [--via S] <json> { being }: a being out of the ward, with every relation she holds
|
|
@@ -30,8 +30,9 @@ import { homedir, userInfo } from 'node:os';
|
|
|
30
30
|
import { join } from 'node:path';
|
|
31
31
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
32
32
|
import { DiskHarbor } from '../harbor/disk.js';
|
|
33
|
-
import { serve } from './daemon.js';
|
|
33
|
+
import { serve, readRoutes } from './daemon.js';
|
|
34
34
|
import { ask, side, reach } from './client.js';
|
|
35
|
+
import { link, isInvitation } from '../beings/link.js';
|
|
35
36
|
import { pilotSide } from '../mcp/pilot.js';
|
|
36
37
|
import { estate } from './estate.js';
|
|
37
38
|
const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove', 'unboot']);
|
|
@@ -114,7 +115,15 @@ async function main(argv) {
|
|
|
114
115
|
if (cmd === 'census' || (cmd !== undefined && OWNER_ASKS.has(cmd))) {
|
|
115
116
|
const args = json ? JSON.parse(json) : {};
|
|
116
117
|
const out = await ask(dir, cmd === 'census' ? undefined : cmd, args, ward, via);
|
|
117
|
-
|
|
118
|
+
// an invitation minted here is handed on as a link when the harbor's
|
|
119
|
+
// routes name a web. origin: the world's page with the invitation in
|
|
120
|
+
// the fragment, one string to send to the person it is for
|
|
121
|
+
const minted = cmd === 'invite' && !via && 'result' in out && isInvitation(out.result) ? out.result : null;
|
|
122
|
+
const routes = minted ? await readRoutes(dir) : null;
|
|
123
|
+
if (minted && routes)
|
|
124
|
+
print({ ...minted, link: link(`${routes.web}/${encodeURIComponent(ward)}`, minted) });
|
|
125
|
+
else
|
|
126
|
+
print('error' in out ? { error: out.error } : out.result);
|
|
118
127
|
// an error object the ward answered is an ordinary answer, and still a
|
|
119
128
|
// non-zero exit, so a script can tell a boot that happened from one that did not.
|
|
120
129
|
const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { Socket as Held, SUITE } from '@quo-systems/quo/harbor';
|
|
3
|
+
import { readAll } from '../cli/http.js';
|
|
4
|
+
export function quoRoute(harbor, server, quo) {
|
|
5
|
+
const pks = () => [...harbor.doors.keys()];
|
|
6
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
7
|
+
server.on('upgrade', (req, socket, head) => {
|
|
8
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
9
|
+
if (url.pathname.replace(/\/$/, '') !== '/quo')
|
|
10
|
+
return void socket.destroy(); // the proxy rewrites the route root to /quo/
|
|
11
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
12
|
+
const s = new Held(ws, (pk, bytes) => harbor.deliver(pk, bytes), (far) => void harbor.bind(far, s, true), // a dialer's claims, each proven at its door: held here, reachable through this socket
|
|
13
|
+
() => {
|
|
14
|
+
harbor.unbind(s);
|
|
15
|
+
quo.sockets.delete(s);
|
|
16
|
+
});
|
|
17
|
+
quo.sockets.add(s);
|
|
18
|
+
s.announce(pks());
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
// The route is public and carries sealed bytes, so any origin may POST
|
|
22
|
+
// to it: a tab on a world's web. reaching another world's quo. is the
|
|
23
|
+
// ordinary case, and a binary POST needs the preflight answered.
|
|
24
|
+
const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, quo-suite' };
|
|
25
|
+
return async (req, res, rest) => {
|
|
26
|
+
if (req.method === 'OPTIONS') {
|
|
27
|
+
res.writeHead(204, open);
|
|
28
|
+
return void res.end();
|
|
29
|
+
}
|
|
30
|
+
// The wire suite the caller speaks. Absent is this one, because a caller
|
|
31
|
+
// older than the header is this one. Anything else this door cannot open
|
|
32
|
+
// and says so as nothing delivered, rather than taking bytes that will
|
|
33
|
+
// never open and answering a silence that names no reason.
|
|
34
|
+
const suite = req.headers['quo-suite'];
|
|
35
|
+
if (suite !== undefined && suite !== String(SUITE)) {
|
|
36
|
+
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
37
|
+
return void res.end(JSON.stringify({ error: `this door speaks wire suite ${SUITE}` }));
|
|
38
|
+
}
|
|
39
|
+
const pk = rest.slice(1);
|
|
40
|
+
if (req.method !== 'POST' || !/^[0-9a-f]{128}$/.test(pk)) {
|
|
41
|
+
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
42
|
+
return void res.end(JSON.stringify({ error: 'POST /quo/<pk>' }));
|
|
43
|
+
}
|
|
44
|
+
const raw = await readAll(req);
|
|
45
|
+
const back = raw === undefined ? undefined : await harbor.deliver(pk, new Uint8Array(raw));
|
|
46
|
+
if (back === undefined) {
|
|
47
|
+
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
48
|
+
return void res.end(JSON.stringify({ error: 'no reach for that pk' }));
|
|
49
|
+
}
|
|
50
|
+
res.writeHead(200, { 'content-type': 'application/octet-stream', ...open });
|
|
51
|
+
res.end(Buffer.from(back));
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The door page: what a stranger sees where nothing lets them in. No
|
|
3
|
+
// invitation in the fragment, no relation in this tab's harbor, and nobody
|
|
4
|
+
// at the ward's door; or a harbor's root with no world to list. It is
|
|
5
|
+
// Quo's page and not the estate's: it says what this is and that a world
|
|
6
|
+
// here is entered by a link someone sends you, and it asks for nothing,
|
|
7
|
+
// since there is nothing a stranger could type that would let them in. A
|
|
8
|
+
// world that wants a face of its own has a public being with a look, or
|
|
9
|
+
// its own page on its own origin; this is the default and nothing more.
|
|
10
|
+
// Pure, strings in and strings out, under the tab's policy: no script, no
|
|
11
|
+
// image that is not a data URI, styles from the one stylesheet.
|
|
12
|
+
import { escape } from './html.js';
|
|
13
|
+
// The mark: a ring with a gap, one being's voice reaching another's door.
|
|
14
|
+
const MARK = 'data:image/svg+xml;base64,' +
|
|
15
|
+
btoa(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"><circle cx="32" cy="32" r="22" stroke-dasharray="110 30" transform="rotate(-60 32 32)"/><circle cx="32" cy="32" r="5" fill="currentColor" stroke="none"/></svg>`);
|
|
16
|
+
export function door(at) {
|
|
17
|
+
const where = at.world ? `<p class="where">${escape(at.world)} <span>at ${escape(at.host)}</span></p>` : `<p class="where">${escape(at.host)}</p>`;
|
|
18
|
+
return `<main class="door"><img class="mark" alt="" src="${MARK}"><h1>quo</h1>${where}<p class="lead">${at.world ? 'This world is entered by invitation.' : 'The worlds here are entered by invitation.'}</p><p>An invitation is a link someone sends you. Open it here, and you are in: no account, no password, nothing to type. What you can do inside is what the world shows you, and it is yours to keep on this device.</p><p class="quiet">Nothing on this page asks anything of you. If you were sent here without a link, ask the person who sent you for one.</p></main>`;
|
|
19
|
+
}
|
package/dist/human/screen.js
CHANGED
package/dist/human/tab.d.ts
CHANGED
|
@@ -2,8 +2,11 @@ import { USER } from '../beings/avatar.ts';
|
|
|
2
2
|
export type Config = {
|
|
3
3
|
quo: string;
|
|
4
4
|
web: string;
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
wards: Record<string, {
|
|
6
|
+
pk: string;
|
|
7
|
+
public: boolean;
|
|
8
|
+
}>;
|
|
9
|
+
ward?: string;
|
|
7
10
|
};
|
|
8
11
|
export declare function start(cfg: Config, root?: HTMLElement): Promise<void>;
|
|
9
12
|
export { USER };
|