@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/cli/daemon.ts CHANGED
@@ -37,28 +37,21 @@
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, type Server, type Socket } from 'node:net';
54
- import { createServer as createHttp, type IncomingMessage, type ServerResponse, type Server as HttpServer } 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, type Line, type Dialer } from '@quo-systems/quo/harbor';
59
- import { unlink, chmod, readFile, writeFile, rename } from 'node:fs/promises';
50
+ import type { Server as HttpServer } from 'node:http';
51
+ import { dial } from '@quo-systems/quo/harbor';
52
+ import { unlink, chmod, readFile } from 'node:fs/promises';
60
53
  import { existsSync } from 'node:fs';
61
- import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
54
+ import { createHash, randomBytes } from 'node:crypto';
62
55
  import { tmpdir } from 'node:os';
63
56
  import { join, resolve } from 'node:path';
64
57
  import { createInterface } from 'node:readline';
@@ -70,17 +63,17 @@ import { Desk, type Avatar, type User, type Verifier } from '../beings/index.ts'
70
63
  import { mcpSide } from '../mcp/server.ts';
71
64
  import { runnerSide, type Model } from '../mcp/runner.ts';
72
65
  import { agentSide, processRun, type Agent } from '../mcp/agent.ts';
73
- import { OAuth, emptyStore, type Store } from '../mcp/oauth.ts';
66
+ import type { OAuth } from '../mcp/oauth.ts';
74
67
  import { Exchange } from '../mcp/web/exchange.ts';
75
68
  import { McpHttp } from '../mcp/http.ts';
69
+ import { mcpRoute, type Routes } from '../mcp/route.ts';
70
+ import { webRoute } from '../human/web.ts';
71
+ import { quoRoute } from '../harbor/quo.ts';
72
+ import { listenHttp, type Http, type Quo } from './http.ts';
76
73
 
77
- export type Handler = (req: IncomingMessage, res: ServerResponse, rest: string) => void | Promise<void>;
78
- export type Http = { port: number; host: string; mount(prefix: string, handler: Handler): void };
79
- export type Quo = { sockets: Set<Held>; dialers: Dialer[] };
74
+ export type { Handler, Http, Quo } from './http.ts';
75
+ export type { Routes } from '../mcp/route.ts';
80
76
  export type Serving = { harbor: DiskHarbor; sock: string; side: string; http: Http | null; oauth: OAuth | null; exchange: Exchange | null; mcp: McpHttp | null; agents: Map<string, Agent>; quo: Quo; close(): Promise<void> };
81
- // The routes' public faces. From `<dir>/routes.json` on a device, or given.
82
- // `quo` is what a tab dials; without it the tab dials the daemon itself.
83
- export type Routes = { mcp: string; web: string; quo?: string };
84
77
  // The agents this daemon runs, by client identity. From `<dir>/agents.json`
85
78
  // on a device, or given.
86
79
  export type Agents = Record<string, { command: string; args?: string[]; dir: string }>;
@@ -117,21 +110,6 @@ const honour =
117
110
  };
118
111
  Desk.verifiers.local = honour('local');
119
112
  Desk.verifiers.web = honour('web');
120
- Desk.verifiers.tab = honour('tab'); // a tab that gave the owner password at the web route
121
-
122
- // What a request may carry, on the doors that read a body: enough for any ask
123
- // a ward seals, and not a body chosen by whoever is on the other end.
124
- const BODY = 1 << 20;
125
- async function readAll(req: AsyncIterable<Buffer>): Promise<Buffer | undefined> {
126
- const chunks: Buffer[] = [];
127
- let size = 0;
128
- for await (const c of req) {
129
- size += c.length;
130
- if (size > BODY) return undefined;
131
- chunks.push(c);
132
- }
133
- return Buffer.concat(chunks);
134
- }
135
113
 
136
114
  // Admit a client identity into a ward: find or boot her avatar, and enter
137
115
  // her with a nonce the desk honours once. On a reconnect she already holds
@@ -193,12 +171,12 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
193
171
  const { server, ...door } = await listenHttp(harbor, options.http.host ?? '127.0.0.1', options.http.port, quo);
194
172
  servers.push(server);
195
173
  http = door;
196
- mountQuo(harbor, http, server, quo);
174
+ http.mount('/quo', quoRoute(harbor, server as HttpServer, quo));
197
175
  const routes = options.routes ?? (await readRoutes(harbor.dir));
198
176
  const password = options.password ?? (() => process.env.QUO_OWNER_PASSWORD);
199
177
  const here = `http://${http.host}:${http.port}`;
200
178
  // the worlds' pages: always, on the daemon's own door when no route names a public one
201
- const tab = worldPages(harbor, { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` }, password);
179
+ const web = webRoute(harbor, { at: { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` } });
202
180
  if (routes) {
203
181
  // the worlds a client may be allowed into: every ward with a public being, main first
204
182
  const worlds = () =>
@@ -216,7 +194,9 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
216
194
  for (const h of harbor.wards.values()) await h.save();
217
195
  },
218
196
  );
219
- oauth = await mountOAuth(harbor.dir, http, routes, mcp);
197
+ const route = await mcpRoute(harbor.dir, routes, mcp);
198
+ oauth = route.oauth;
199
+ http.mount('/mcp', route.handler);
220
200
  mcp.gone = (identity, ward) => oauth!.revoke(identity, ward); // removal at the ward ends the grant at the route
221
201
  exchange = new Exchange({
222
202
  oauth,
@@ -232,7 +212,7 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
232
212
  }
233
213
  const ex = exchange;
234
214
  http.mount('/web', async (req, res, rest) => {
235
- if (await tab(req, res, rest)) return;
215
+ if (await web(req, res, rest)) return;
236
216
  if (ex && (await ex.handle(req, res, rest))) return;
237
217
  res.writeHead(404, { 'content-type': 'text/plain' });
238
218
  res.end('no such page');
@@ -277,50 +257,6 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
277
257
  };
278
258
  }
279
259
 
280
- // The HTTP door. Loopback only; the proxy faces the world. A route mounts
281
- // under a prefix and gets the rest of the path. `/health` is the daemon's
282
- // own: the wards it hosts, by name and pk, and nothing a stranger can use.
283
- async function listenHttp(harbor: DiskHarbor, host: string, port: number, quo: Quo): Promise<Http & { server: HttpServer }> {
284
- const mounts = new Map<string, Handler>();
285
- const server = createHttp((req, res) => {
286
- const url = new URL(req.url ?? '/', 'http://localhost');
287
- if (url.pathname === '/health') {
288
- // the wards it hosts, and the directory: pks and whether each is a
289
- // socket held here, which is what an operator needs to see a dialer.
290
- // Open to any origin: a tab reads it, and it holds nothing a stranger can use.
291
- res.writeHead(200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
292
- res.end(
293
- JSON.stringify({
294
- ok: true,
295
- wards: Object.fromEntries([...harbor.wards].map(([n, h]) => [n, h.pk])),
296
- reaches: Object.fromEntries([...harbor.reaches].map(([pk, b]) => [pk, b.held ? 'held' : 'reach'])),
297
- sockets: quo.sockets.size,
298
- dialers: quo.dialers.map((d) => ({ url: d.url, open: d.socket !== null })),
299
- }),
300
- );
301
- return;
302
- }
303
- for (const [prefix, handler] of mounts) {
304
- if (url.pathname === prefix || url.pathname.startsWith(prefix + '/')) {
305
- void Promise.resolve(handler(req, res, url.pathname.slice(prefix.length))).catch(() => {
306
- if (!res.headersSent) res.writeHead(500);
307
- res.end();
308
- });
309
- return;
310
- }
311
- }
312
- res.writeHead(404, { 'content-type': 'application/json' });
313
- res.end(JSON.stringify({ error: 'no such route' }));
314
- });
315
- await new Promise<void>((ok, no) => {
316
- server.once('error', no);
317
- server.listen(port, host, () => ok());
318
- });
319
- const address = server.address();
320
- const bound = typeof address === 'object' && address !== null ? address.port : port;
321
- return { server, host, port: bound, mount: (prefix, handler) => mounts.set(prefix.replace(/\/$/, ''), handler) };
322
- }
323
-
324
260
  // A side connection: hello, then MCP over the same lines, or, with `run`,
325
261
  // the human's lines in and the model's final texts out.
326
262
  async function sideConnection(harbor: DiskHarbor, c: Socket): Promise<void> {
@@ -422,7 +358,7 @@ async function handle(harbor: DiskHarbor, line: string): Promise<string> {
422
358
  }
423
359
 
424
360
  // `<dir>/routes.json`: { mcp, web, quo? }, the public origins the proxy serves.
425
- async function readRoutes(dir: string): Promise<Routes | null> {
361
+ export async function readRoutes(dir: string): Promise<Routes | null> {
426
362
  const p = join(dir, 'routes.json');
427
363
  if (!existsSync(p)) return null;
428
364
  const r = JSON.parse(await readFile(p, 'utf8')) as Partial<Routes>;
@@ -432,163 +368,6 @@ async function readRoutes(dir: string): Promise<Routes | null> {
432
368
  return out;
433
369
  }
434
370
 
435
- // The worlds on the web route. A world is a ward with a public being, and
436
- // its address is `/web/<ward>`: the tab page, `human/tab.ts`, told which
437
- // ward and which pk, so that its guest is that ward's public being rendered
438
- // by the screen, whatever class she is. `/web/` lists the worlds. The bundle
439
- // is built once from the source beside this file, `.ts` in the tree, `.js`
440
- // once emitted into the package's dist. `/web/<ward>/login` is the one call
441
- // of the dock's own way in a tab makes: the owner password for a nonce the
442
- // desk honours under `tab`. The avatar that knocks with it lives in the tab,
443
- // not here, so nothing is admitted on this side.
444
- //
445
- // The page carries a content security policy: scripts from this origin
446
- // only and never inline, connections to this origin and the quo. route the
447
- // tab dials, images from data URIs and this origin, and nothing else. So
448
- // even a bug in a renderer cannot become a script, and no look can reach a
449
- // server. The config crosses in a JSON script, which the policy allows.
450
- const RESERVED_PATHS = new Set(['login', 'allow', 'tab.js']);
451
- function worldPages(harbor: DiskHarbor, at: { quo: string; web: string }, password: () => string | undefined): (req: IncomingMessage, res: ServerResponse, rest: string) => Promise<boolean> {
452
- const tabEntry = () => {
453
- const js = fileURLToPath(new URL('../human/tab.js', import.meta.url));
454
- return existsSync(js) ? js : fileURLToPath(new URL('../human/tab.ts', import.meta.url));
455
- };
456
- let bundle: Promise<string> | undefined;
457
- const built = () =>
458
- (bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((o) => o.outputFiles[0]!.text));
459
- const quoOrigin = (() => {
460
- try {
461
- const u = new URL(at.quo);
462
- return `${u.origin} ${u.origin.replace(/^http/, 'ws')}`;
463
- } catch {
464
- return '';
465
- }
466
- })();
467
- 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'`;
468
- const shell = (body: string) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
469
- <style>${CSS}</style>
470
- </head><body>${body}</body></html>`;
471
- const esc = (v: string) => v.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
472
- const publicOf = (h: Hosted) => (h.partition as { public?: string | null }).public ?? null;
473
- const html = (status: number, res: ServerResponse, body: string) => {
474
- res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'no-referrer' });
475
- res.end(shell(body));
476
- return true;
477
- };
478
- return async (req, res, rest) => {
479
- const parts = rest.split('/').filter(Boolean);
480
- if (req.method === 'GET' && (rest === '' || rest === '/')) {
481
- const list = [...harbor.wards]
482
- .filter(([, h]) => publicOf(h) !== null)
483
- .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>`)
484
- .join('');
485
- return html(200, res, `<h1>worlds</h1>${list ? `<ul>${list}</ul>` : '<p>no ward here has a public being</p>'}`);
486
- }
487
- if (rest === '/tab.js' && req.method === 'GET') {
488
- res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
489
- res.end(await built());
490
- return true;
491
- }
492
- const wardName = parts[0] ?? '';
493
- if (!wardName || RESERVED_PATHS.has(wardName)) return false;
494
- const hosted = harbor.wards.get(wardName);
495
- if (!hosted) return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
496
- if (parts.length === 1 && req.method === 'GET') {
497
- if (publicOf(hosted) === null) 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 as AsyncIterable<Buffer>);
503
- let body: { password?: unknown; identity?: unknown } = {};
504
- try {
505
- body = JSON.parse(raw?.toString() || '{}') as typeof body;
506
- } catch {
507
- /* nothing to read */
508
- }
509
- const json = (status: number, v: unknown) => {
510
- res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
511
- res.end(JSON.stringify(v));
512
- return true;
513
- };
514
- const want = password();
515
- if (want === undefined) return json(503, { error: 'closed: no owner password is set' });
516
- const got = typeof body.password === 'string' ? body.password : '';
517
- if (got.length !== want.length || !timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
518
- await new Promise((ok) => setTimeout(ok, 300));
519
- return json(401, { error: 'that is not the password' });
520
- }
521
- const identity = typeof body.identity === 'string' ? body.identity : '';
522
- if (!/^[\w.-]{1,40}$/.test(identity) || identity === hosted.record.user || identity === 'desk') return json(400, { error: 'an identity is one word, and not the user or the desk' });
523
- const nonce = randomBytes(16).toString('hex');
524
- NONCES.set(nonce, { kind: 'tab', user: hosted.record.user, client: identity, reach: true });
525
- return json(200, { nonce, ward: hosted.pk });
526
- }
527
- return false;
528
- };
529
- }
530
-
531
- // The tab's stylesheet: one, light and dark, honouring the variables a look
532
- // sets on a section. The page owns layout; a far being paints inside her
533
- // section and nowhere else.
534
- 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}";
535
-
536
- // The quo. route. A request carries one ask to a pk; an upgrade is a held
537
- // socket. Bytes from here go to an own door or a held socket, never onward.
538
- function mountQuo(harbor: DiskHarbor, http: Http, server: HttpServer, quo: Quo): void {
539
- const pks = () => [...harbor.doors.keys()];
540
- // The route is public and carries sealed bytes, so any origin may POST
541
- // to it: a tab on a world's web. reaching another world's quo. is the
542
- // ordinary case, and a binary POST needs the preflight answered.
543
- const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, quo-suite' };
544
- http.mount('/quo', async (req, res, rest) => {
545
- if (req.method === 'OPTIONS') {
546
- res.writeHead(204, open);
547
- return void res.end();
548
- }
549
- // The wire suite the caller speaks. Absent is this one, because a caller
550
- // older than the header is this one. Anything else this door cannot open
551
- // and says so as nothing delivered, rather than taking bytes that will
552
- // never open and answering a silence that names no reason.
553
- const suite = req.headers['quo-suite'];
554
- if (suite !== undefined && suite !== String(SUITE)) {
555
- res.writeHead(404, { 'content-type': 'application/json', ...open });
556
- return void res.end(JSON.stringify({ error: `this door speaks wire suite ${SUITE}` }));
557
- }
558
- const pk = rest.slice(1);
559
- if (req.method !== 'POST' || !/^[0-9a-f]{128}$/.test(pk)) {
560
- res.writeHead(404, { 'content-type': 'application/json', ...open });
561
- return void res.end(JSON.stringify({ error: 'POST /quo/<pk>' }));
562
- }
563
- const raw = await readAll(req as AsyncIterable<Buffer>);
564
- const back = raw === undefined ? undefined : await harbor.deliver(pk, new Uint8Array(raw));
565
- if (back === undefined) {
566
- res.writeHead(404, { 'content-type': 'application/json', ...open });
567
- return void res.end(JSON.stringify({ error: 'no reach for that pk' }));
568
- }
569
- res.writeHead(200, { 'content-type': 'application/octet-stream', ...open });
570
- res.end(Buffer.from(back));
571
- });
572
- const wss = new WebSocketServer({ noServer: true });
573
- server.on('upgrade', (req, socket, head) => {
574
- const url = new URL(req.url ?? '/', 'http://localhost');
575
- if (url.pathname.replace(/\/$/, '') !== '/quo') return void socket.destroy(); // the proxy rewrites the route root to /quo/
576
- wss.handleUpgrade(req, socket, head, (ws) => {
577
- const s: Held = new Held(
578
- ws as unknown as Line,
579
- (pk, bytes) => harbor.deliver(pk, bytes),
580
- (far) => void harbor.bind(far, s, true), // a dialer's claims, each proven at its door: held here, reachable through this socket
581
- () => {
582
- harbor.unbind(s);
583
- quo.sockets.delete(s);
584
- },
585
- );
586
- quo.sockets.add(s);
587
- s.announce(pks());
588
- });
589
- });
590
- }
591
-
592
371
  // `<dir>/dial.json`: [url, ...], the quo. routes this daemon holds a socket to.
593
372
  async function readDial(dir: string): Promise<string[]> {
594
373
  const p = join(dir, 'dial.json');
@@ -608,29 +387,3 @@ async function readAgents(dir: string): Promise<Agents> {
608
387
  }
609
388
  return out;
610
389
  }
611
-
612
- // The mcp route: OAuth today, the MCP endpoint at step 9. Its store is
613
- // `<dir>/oauth.json`, the route's own and never a ward's: clients, pending
614
- // requests, codes and tokens, each mapping to a client identity at most.
615
- async function mountOAuth(dir: string, http: Http, routes: Routes, mcp: McpHttp | null): Promise<OAuth> {
616
- const file = join(dir, 'oauth.json');
617
- const store: Store = existsSync(file) ? { ...emptyStore(), ...(JSON.parse(await readFile(file, 'utf8')) as Partial<Store>) } : emptyStore();
618
- let queue = Promise.resolve();
619
- const persist = (s: Store) =>
620
- (queue = queue.then(async () => {
621
- await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
622
- await rename(file + '.tmp', file);
623
- }));
624
- const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/login?request=${id}`, store, persist });
625
- http.mount('/mcp', async (req, res, rest) => {
626
- if (await oauth.handle(req, res, rest)) return;
627
- if (rest === '/mcp') {
628
- const grant = oauth.bearer(req);
629
- if (grant === null || !mcp) return oauth.challenge(res);
630
- return mcp.handle(req, res, grant.identity, grant.ward);
631
- }
632
- res.writeHead(404, { 'content-type': 'application/json' });
633
- res.end(JSON.stringify({ error: 'no such route' }));
634
- });
635
- return oauth;
636
- }
package/cli/http.ts ADDED
@@ -0,0 +1,70 @@
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, type IncomingMessage, type ServerResponse, type Server as HttpServer } from 'node:http';
8
+ import type { Socket as Held, Dialer } from '@quo-systems/quo/harbor';
9
+ import type { DiskHarbor } from '../harbor/disk.ts';
10
+
11
+ export type Handler = (req: IncomingMessage, res: ServerResponse, rest: string) => void | Promise<void>;
12
+ export type Http = { port: number; host: string; mount(prefix: string, handler: Handler): void };
13
+ // What the quo. route holds: the sockets held here and the dialers this
14
+ // daemon runs. `/health` reports them.
15
+ export type Quo = { sockets: Set<Held>; dialers: Dialer[] };
16
+
17
+ // What a request may carry, on the doors that read a body: enough for any ask
18
+ // a ward seals, and not a body chosen by whoever is on the other end.
19
+ const BODY = 1 << 20;
20
+ export async function readAll(req: AsyncIterable<Buffer>): Promise<Buffer | undefined> {
21
+ const chunks: Buffer[] = [];
22
+ let size = 0;
23
+ for await (const c of req) {
24
+ size += c.length;
25
+ if (size > BODY) return undefined;
26
+ chunks.push(c);
27
+ }
28
+ return Buffer.concat(chunks);
29
+ }
30
+
31
+ export async function listenHttp(harbor: DiskHarbor, host: string, port: number, quo: Quo): Promise<Http & { server: HttpServer }> {
32
+ const mounts = new Map<string, Handler>();
33
+ const server = createHttp((req, res) => {
34
+ const url = new URL(req.url ?? '/', 'http://localhost');
35
+ if (url.pathname === '/health') {
36
+ // the wards it hosts, and the directory: pks and whether each is a
37
+ // socket held here, which is what an operator needs to see a dialer.
38
+ // Open to any origin: a tab reads it, and it holds nothing a stranger can use.
39
+ res.writeHead(200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
40
+ res.end(
41
+ JSON.stringify({
42
+ ok: true,
43
+ wards: Object.fromEntries([...harbor.wards].map(([n, h]) => [n, h.pk])),
44
+ reaches: Object.fromEntries([...harbor.reaches].map(([pk, b]) => [pk, b.held ? 'held' : 'reach'])),
45
+ sockets: quo.sockets.size,
46
+ dialers: quo.dialers.map((d) => ({ url: d.url, open: d.socket !== null })),
47
+ }),
48
+ );
49
+ return;
50
+ }
51
+ for (const [prefix, handler] of mounts) {
52
+ if (url.pathname === prefix || url.pathname.startsWith(prefix + '/')) {
53
+ void Promise.resolve(handler(req, res, url.pathname.slice(prefix.length))).catch(() => {
54
+ if (!res.headersSent) res.writeHead(500);
55
+ res.end();
56
+ });
57
+ return;
58
+ }
59
+ }
60
+ res.writeHead(404, { 'content-type': 'application/json' });
61
+ res.end(JSON.stringify({ error: 'no such route' }));
62
+ });
63
+ await new Promise<void>((ok, no) => {
64
+ server.once('error', no);
65
+ server.listen(port, host, () => ok());
66
+ });
67
+ const address = server.address();
68
+ const bound = typeof address === 'object' && address !== null ? address.port : port;
69
+ return { server, host, port: bound, mount: (prefix, handler) => mounts.set(prefix.replace(/\/$/, ''), handler) };
70
+ }
package/cli/quo.ts 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.ts';
33
- import { serve } from './daemon.ts';
33
+ import { serve, readRoutes } from './daemon.ts';
34
34
  import { ask, side, reach, type Run } from './client.ts';
35
+ import { link, isInvitation } from '../beings/link.ts';
35
36
  import { pilotSide } from '../mcp/pilot.ts';
36
37
  import { estate } from './estate.ts';
37
38
 
@@ -116,7 +117,13 @@ async function main(argv: string[]): Promise<number> {
116
117
  if (cmd === 'census' || (cmd !== undefined && OWNER_ASKS.has(cmd))) {
117
118
  const args = json ? (JSON.parse(json) as Record<string, unknown>) : {};
118
119
  const out = await ask(dir, cmd === 'census' ? undefined : cmd, args, ward, via);
119
- print('error' in out ? { error: out.error } : out.result);
120
+ // an invitation minted here is handed on as a link when the harbor's
121
+ // routes name a web. origin: the world's page with the invitation in
122
+ // the fragment, one string to send to the person it is for
123
+ const minted = cmd === 'invite' && !via && 'result' in out && isInvitation(out.result) ? out.result : null;
124
+ const routes = minted ? await readRoutes(dir) : null;
125
+ if (minted && routes) print({ ...minted, link: link(`${routes.web}/${encodeURIComponent(ward)}`, minted) });
126
+ else print('error' in out ? { error: out.error } : out.result);
120
127
  // an error object the ward answered is an ordinary answer, and still a
121
128
  // non-zero exit, so a script can tell a boot that happened from one that did not.
122
129
  const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
@@ -4,7 +4,9 @@
4
4
  // exactly one standing, `user`, and everything her side asks goes through
5
5
  // it; the user being reaches her only to push, and she hands the push to
6
6
  // every side she has. A side is in-process with her, holds the object, and
7
- // calls the three methods below. Nothing of a side is in her cells.
7
+ // calls the four methods below: one of the two ways in, `enter` behind a
8
+ // route's proof or `join` with an invitation, then `tools` and `call`.
9
+ // Nothing of a side is in her cells.
8
10
  import { Being, isSilence, isWord, wordOf } from '@quo-systems/quo';
9
11
  // Her one standing, and the id under which the user being pushes to her.
10
12
  // Two ids for one far being, because standings and occupants share one
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The beings every side shares. See quo-estate.md, the trunk.
2
+ // The beings every side shares. See quo-dock.md, the trunk.
3
3
  export { User, DESK } from './user.js';
4
4
  export { Desk } from './desk.js';
5
5
  export { Avatar, USER, PUSHER } from './avatar.js';
@@ -1,4 +1,4 @@
1
- import type { Asker, JsonObject, OccupantRecord } from '@quo-systems/quo';
1
+ import type { Asker, Invitation, JsonObject, OccupantRecord } from '@quo-systems/quo';
2
2
  import { Carrier } from './carry.ts';
3
3
  export declare const DESK = "desk";
4
4
  declare const isDesk: (occ: OccupantRecord | undefined) => boolean;
@@ -9,6 +9,7 @@ export declare class User extends Carrier {
9
9
  static cells: {
10
10
  name: string;
11
11
  reports: JsonObject[];
12
+ ways: Record<string, Invitation>;
12
13
  };
13
14
  static asks: {
14
15
  hello: {
@@ -103,6 +104,7 @@ export declare class User extends Carrier {
103
104
  welcome: string | null;
104
105
  name: import("@quo-systems/quo").Json;
105
106
  }>;
107
+ wayBack(id: string, inv: Invitation): Promise<boolean>;
106
108
  whoami(_args: JsonObject, asker: Asker): {
107
109
  id: string | null;
108
110
  client: string | null;
@@ -10,18 +10,28 @@ import { Carrier } from './carry.js';
10
10
  export const DESK = 'desk';
11
11
  const isDesk = (occ) => occ?.id === DESK;
12
12
  const isDevice = (occ) => occ !== undefined && occ.id !== DESK;
13
- const client = (occ) => (typeof occ?.notes.client === 'string' ? occ.notes.client : null);
14
- // A device the human allowed to wake her other devices: the note says so.
15
- const mayWake = (occ) => isDesk(occ) || occ?.notes.wake === true;
13
+ // A device is known by its client identity. The desk writes it as a note
14
+ // when it asks her to mint; the root, minting on her directly, writes
15
+ // nothing, and then the id the root chose is the identity.
16
+ const rootMinted = (occ) => isDevice(occ) && occ?.notes.client === undefined;
17
+ const client = (occ) => (typeof occ?.notes.client === 'string' ? occ.notes.client : rootMinted(occ) ? occ.id : null);
18
+ // A device the human allowed to wake her other devices: the note says so,
19
+ // or the root minted it, and the root's word needs no note.
20
+ const mayWake = (occ) => isDesk(occ) || occ?.notes.wake === true || rootMinted(occ);
16
21
  // She carries her standings, acme, the calendar, the house, for a device the
17
22
  // human allowed to reach them at the exchange: the note says so. A device
18
23
  // without the note sees her own asks alone, and a model sees acme only
19
- // because the human said it may.
24
+ // because the human said it may. A device the root minted reaches: the root
25
+ // is the owner of everything she holds and chose to mint it.
20
26
  export class User extends Carrier {
21
27
  static carries(occ) {
22
- return isDevice(occ) && occ?.notes.reach === true;
28
+ return isDevice(occ) && (occ?.notes.reach === true || rootMinted(occ));
23
29
  }
24
- static cells = { name: '', reports: [] };
30
+ // `ways` holds a device's way back that could not be taken when it was
31
+ // handed over: the knock was unreached, a tab whose pk the far harbor had
32
+ // not bound yet, a phone in a tunnel. Nothing delivered spends nothing,
33
+ // so the invitation is tried again at the next push.
34
+ static cells = { name: '', reports: [], ways: {} };
25
35
  static asks = {
26
36
  hello: { description: 'say hello, and hand back an invitation so she can reach you', input: { type: 'object', properties: { invitation: { type: 'object' } } } },
27
37
  whoami: { description: 'who she thinks you are', input: { type: 'object' }, for: isDevice },
@@ -32,16 +42,30 @@ export class User extends Carrier {
32
42
  report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
33
43
  };
34
44
  // Anyone may say hello. A device that hands her an invitation in the args
35
- // is taken as a standing under its own id, so she can push to it later.
45
+ // is taken as a standing under its own id, so she can push to it later;
46
+ // if it cannot be taken now, it is kept and tried at the next push.
36
47
  async hello(args, asker) {
37
48
  if (asker.id !== undefined && args.invitation && typeof args.invitation === 'object' && !Array.isArray(args.invitation)) {
38
- const inv = args.invitation;
39
- const back = await this.knock(inv);
40
- if (!isSilence(back) && !isWord(back))
41
- await this.take(`to:${asker.id}`, inv);
49
+ await this.wayBack(asker.id, args.invitation);
42
50
  }
43
51
  return { welcome: asker.id ?? null, name: this.cells.name };
44
52
  }
53
+ // Take a device's way back, or keep it for later when the knock did not
54
+ // reach. A refusal spends the heir and is final; the way is dropped.
55
+ async wayBack(id, inv) {
56
+ const ways = this.cells.ways;
57
+ const back = await this.knock(inv);
58
+ if (!isSilence(back) && !isWord(back)) {
59
+ await this.take(`to:${id}`, inv);
60
+ delete ways[id];
61
+ return true;
62
+ }
63
+ if (isWord(back) && wordOf(back) !== 'unreached')
64
+ delete ways[id];
65
+ else
66
+ ways[id] = inv;
67
+ return false;
68
+ }
45
69
  whoami(_args, asker) {
46
70
  return { id: asker.id ?? null, client: client(this.occupant(asker)) };
47
71
  }
@@ -72,7 +96,12 @@ export class User extends Carrier {
72
96
  // stops being able to ask her and keeps receiving everything she pushes.
73
97
  async push(args) {
74
98
  const c = typeof args.client === 'string' ? args.client : null;
75
- const st = c === null || !this.cells.occupants[c] ? undefined : this.standings[`to:${c}`];
99
+ if (c === null || !this.cells.occupants[c])
100
+ return { error: 'no such device, or it gave no way back' };
101
+ const kept = this.cells.ways[c];
102
+ if (!this.standings[`to:${c}`] && kept)
103
+ await this.wayBack(c, kept);
104
+ const st = this.standings[`to:${c}`];
76
105
  if (!st)
77
106
  return { error: 'no such device, or it gave no way back' };
78
107
  const out = await st.ask('notify', args.object ?? {});
@@ -87,6 +116,7 @@ export class User extends Carrier {
87
116
  return { error: 'client is a string' };
88
117
  this.occupants.remove(c);
89
118
  this.standings.remove(`to:${c}`);
119
+ delete this.cells.ways[c];
90
120
  return { forgot: c };
91
121
  }
92
122
  chores() {