@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.
@@ -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
- export type Handler = (req: IncomingMessage, res: ServerResponse, rest: string) => void | Promise<void>;
10
- export type Http = {
11
- port: number;
12
- host: string;
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>;
@@ -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 path: `/mcp`
41
- // for the model side, `/web` for the exchange pages, `/quo` for other
42
- // harbors. It listens only when asked, and only on loopback: the proxy is
43
- // what faces the world, and the daemon never does.
44
- //
45
- // `/quo` is the socket door, the quo. route, and the proxy maps the quo.
46
- // hostname onto it, so a URL of that route is the base of every reach:
47
- // POST `/quo/<pk>` carries one ask to a pk this harbor holds or holds a
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 { createServer as createHttp } from 'node:http';
55
- import { WebSocketServer } from 'ws';
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, timingSafeEqual } from 'node:crypto';
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
- mountQuo(harbor, http, server, quo);
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 tab = worldPages(harbor, { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` }, password);
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
- oauth = await mountOAuth(harbor.dir, http, routes, mcp);
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 tab(req, res, rest))
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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
+ }>;
@@ -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
- print('error' in out ? { error: out.error } : out.result);
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,4 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import type { DiskHarbor } from './disk.ts';
3
+ import { type Handler, type Quo } from '../cli/http.ts';
4
+ export declare function quoRoute(harbor: DiskHarbor, server: HttpServer, quo: Quo): Handler;
@@ -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,5 @@
1
+ export type Door = {
2
+ world?: string;
3
+ host: string;
4
+ };
5
+ export declare function door(at: Door): string;
@@ -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
+ }
@@ -49,6 +49,7 @@ export async function screenSide(avatar, surface, options = {}) {
49
49
  }
50
50
  }
51
51
  }
52
+ await after();
52
53
  show();
53
54
  };
54
55
  surface.onSubmit = (name, raw) => void submit(name, raw);
@@ -2,8 +2,11 @@ import { USER } from '../beings/avatar.ts';
2
2
  export type Config = {
3
3
  quo: string;
4
4
  web: string;
5
- ward: string;
6
- pk: string;
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 };