@quo-systems/dock 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/beings/avatar.ts +9 -3
  2. package/beings/carry.ts +101 -0
  3. package/beings/desk.ts +2 -2
  4. package/beings/index.ts +3 -1
  5. package/beings/link.ts +54 -0
  6. package/beings/look.ts +96 -0
  7. package/beings/quo-dock.md +163 -277
  8. package/beings/side.ts +10 -1
  9. package/beings/user.ts +67 -13
  10. package/cli/daemon.ts +58 -251
  11. package/cli/http.ts +70 -0
  12. package/cli/quo.ts +13 -5
  13. package/dist/beings/avatar.js +10 -3
  14. package/dist/beings/carry.d.ts +10 -0
  15. package/dist/beings/carry.js +106 -0
  16. package/dist/beings/desk.d.ts +1 -0
  17. package/dist/beings/desk.js +1 -1
  18. package/dist/beings/index.d.ts +2 -0
  19. package/dist/beings/index.js +3 -1
  20. package/dist/beings/link.d.ts +7 -0
  21. package/dist/beings/link.js +42 -0
  22. package/dist/beings/look.d.ts +27 -0
  23. package/dist/beings/look.js +71 -0
  24. package/dist/beings/side.d.ts +8 -1
  25. package/dist/beings/user.d.ts +29 -3
  26. package/dist/beings/user.js +71 -14
  27. package/dist/cli/daemon.d.ts +7 -19
  28. package/dist/cli/daemon.js +51 -240
  29. package/dist/cli/http.d.ts +17 -0
  30. package/dist/cli/http.js +60 -0
  31. package/dist/cli/quo.js +15 -5
  32. package/dist/harbor/quo.d.ts +4 -0
  33. package/dist/harbor/quo.js +53 -0
  34. package/dist/human/door.d.ts +5 -0
  35. package/dist/human/door.js +19 -0
  36. package/dist/human/guest.d.ts +3 -0
  37. package/dist/human/guest.js +25 -0
  38. package/dist/human/html.d.ts +10 -2
  39. package/dist/human/html.js +61 -10
  40. package/dist/human/screen.d.ts +8 -3
  41. package/dist/human/screen.js +26 -6
  42. package/dist/human/tab.d.ts +5 -0
  43. package/dist/human/tab.js +161 -43
  44. package/dist/human/web.d.ts +9 -0
  45. package/dist/human/web.js +95 -0
  46. package/dist/human/worlds.d.ts +10 -0
  47. package/dist/human/worlds.js +38 -0
  48. package/dist/mcp/http.d.ts +4 -3
  49. package/dist/mcp/http.js +6 -6
  50. package/dist/mcp/oauth.d.ts +9 -4
  51. package/dist/mcp/oauth.js +15 -14
  52. package/dist/mcp/pilot.js +1 -1
  53. package/dist/mcp/route.d.ts +12 -0
  54. package/dist/mcp/route.js +34 -0
  55. package/dist/mcp/server.d.ts +5 -10
  56. package/dist/mcp/server.js +35 -4
  57. package/dist/mcp/web/exchange.d.ts +5 -2
  58. package/dist/mcp/web/exchange.js +21 -7
  59. package/harbor/quo-harbor.md +67 -30
  60. package/harbor/quo.ts +67 -0
  61. package/human/door.ts +26 -0
  62. package/human/guest.ts +26 -0
  63. package/human/html.ts +58 -10
  64. package/human/quo-human.md +158 -67
  65. package/human/screen.ts +34 -8
  66. package/human/tab.ts +193 -53
  67. package/human/web.ts +123 -0
  68. package/human/worlds.ts +52 -0
  69. package/mcp/http.ts +10 -9
  70. package/mcp/oauth.ts +20 -17
  71. package/mcp/pilot.ts +1 -1
  72. package/mcp/quo-mcp.md +20 -2
  73. package/mcp/route.ts +39 -0
  74. package/mcp/server.ts +37 -18
  75. package/mcp/web/exchange.ts +23 -9
  76. package/package.json +2 -2
@@ -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[];
@@ -50,8 +37,9 @@ export type Options = {
50
37
  };
51
38
  export declare const sockPath: (dir: string) => string;
52
39
  export declare const sidePath: (dir: string) => string;
53
- export declare function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake?: boolean): Promise<{
40
+ export declare function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake?: boolean, reach?: boolean): Promise<{
54
41
  avatar?: Avatar;
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,27 +96,12 @@ 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
122
102
  // device may wake her others, is read the first time only; to change it,
123
103
  // remove the occupant and allow again. The one path for every side.
124
- export async function admit(hosted, identity, kind, wake = false) {
104
+ export async function admit(hosted, identity, kind, wake = false, reach = false) {
125
105
  if (!/^[\w.-]+$/.test(identity) || identity === hosted.record.user || identity === 'desk')
126
106
  return { error: 'an identity is a word, and not a being of the ward' };
127
107
  const key = `avatar:${identity}`;
@@ -133,7 +113,7 @@ export async function admit(hosted, identity, kind, wake = false) {
133
113
  avatar = hosted.being(key);
134
114
  }
135
115
  const nonce = randomBytes(16).toString('hex');
136
- NONCES.set(nonce, wake ? { kind, user: hosted.record.user, client: identity, wake: true } : { kind, user: hosted.record.user, client: identity });
116
+ NONCES.set(nonce, { kind, user: hosted.record.user, client: identity, ...(wake ? { wake: true } : {}), ...(reach ? { reach: true } : {}) });
137
117
  const entered = await avatar.enter({ ward: hosted.pk }, { kind, nonce });
138
118
  NONCES.delete(nonce);
139
119
  await hosted.save(); // the knock went through the ward's own door, which the harbor never sees
@@ -175,34 +155,46 @@ 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
- const main = harbor.wards.get('main');
182
161
  const here = `http://${http.host}:${http.port}`;
183
- // the tab page: always, on the daemon's own door when no route names a public one
184
- const tab = main ? tabPages(main, { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` }, password) : null;
162
+ // the worlds' pages: always, on the daemon's own door when no route names a public one
163
+ const web = webRoute(harbor, { at: { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` } });
185
164
  if (routes) {
186
- // the MCP endpoint: a bearer names an identity, the identity names her avatar, the side runs beside her
187
- mcp = main ? new McpHttp((identity) => admit(main, identity, 'web'), () => main.save()) : null;
188
- oauth = await mountOAuth(harbor.dir, http, routes, mcp);
189
- if (mcp)
190
- mcp.gone = (identity) => oauth.revoke(identity); // removal at the ward ends the grant at the route
191
- if (main) {
192
- exchange = new Exchange({
193
- oauth,
194
- password,
195
- user: main.record.user,
196
- admit: async (identity, wake) => {
197
- const r = await admit(main, identity, 'web', wake);
198
- return r.error ? { error: r.error } : {};
199
- },
200
- });
201
- }
165
+ // the worlds a client may be allowed into: every ward with a public being, main first
166
+ const worlds = () => [...harbor.wards]
167
+ .filter(([, h]) => h.partition.public !== null)
168
+ .sort(([a], [b]) => (a === 'main' ? -1 : b === 'main' ? 1 : a.localeCompare(b)))
169
+ .map(([ward, h]) => ({ ward, user: h.record.user }));
170
+ // the MCP endpoint: a bearer names an identity in a world, the identity names her avatar there, the side runs beside her
171
+ mcp = new McpHttp(async (identity, ward) => {
172
+ const hosted = harbor.wards.get(ward);
173
+ return hosted ? admit(hosted, identity, 'web') : { error: 'no such world' };
174
+ }, async () => {
175
+ for (const h of harbor.wards.values())
176
+ await h.save();
177
+ });
178
+ const route = await mcpRoute(harbor.dir, routes, mcp);
179
+ oauth = route.oauth;
180
+ http.mount('/mcp', route.handler);
181
+ mcp.gone = (identity, ward) => oauth.revoke(identity, ward); // removal at the ward ends the grant at the route
182
+ exchange = new Exchange({
183
+ oauth,
184
+ password,
185
+ worlds,
186
+ admit: async (identity, wake, reach, ward) => {
187
+ const hosted = harbor.wards.get(ward);
188
+ if (!hosted)
189
+ return { error: 'no such world' };
190
+ const r = await admit(hosted, identity, 'web', wake, reach);
191
+ return r.error ? { error: r.error } : {};
192
+ },
193
+ });
202
194
  }
203
195
  const ex = exchange;
204
196
  http.mount('/web', async (req, res, rest) => {
205
- if (tab && (await tab(req, res, rest)))
197
+ if (await web(req, res, rest))
206
198
  return;
207
199
  if (ex && (await ex.handle(req, res, rest)))
208
200
  return;
@@ -258,48 +250,6 @@ export async function serve(dir, options = {}) {
258
250
  },
259
251
  };
260
252
  }
261
- // The HTTP door. Loopback only; the proxy faces the world. A route mounts
262
- // under a prefix and gets the rest of the path. `/health` is the daemon's
263
- // own: the wards it hosts, by name and pk, and nothing a stranger can use.
264
- async function listenHttp(harbor, host, port, quo) {
265
- const mounts = new Map();
266
- const server = createHttp((req, res) => {
267
- const url = new URL(req.url ?? '/', 'http://localhost');
268
- if (url.pathname === '/health') {
269
- // the wards it hosts, and the directory: pks and whether each is a
270
- // socket held here, which is what an operator needs to see a dialer.
271
- // Open to any origin: a tab reads it, and it holds nothing a stranger can use.
272
- res.writeHead(200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
273
- res.end(JSON.stringify({
274
- ok: true,
275
- wards: Object.fromEntries([...harbor.wards].map(([n, h]) => [n, h.pk])),
276
- reaches: Object.fromEntries([...harbor.reaches].map(([pk, b]) => [pk, b.held ? 'held' : 'reach'])),
277
- sockets: quo.sockets.size,
278
- dialers: quo.dialers.map((d) => ({ url: d.url, open: d.socket !== null })),
279
- }));
280
- return;
281
- }
282
- for (const [prefix, handler] of mounts) {
283
- if (url.pathname === prefix || url.pathname.startsWith(prefix + '/')) {
284
- void Promise.resolve(handler(req, res, url.pathname.slice(prefix.length))).catch(() => {
285
- if (!res.headersSent)
286
- res.writeHead(500);
287
- res.end();
288
- });
289
- return;
290
- }
291
- }
292
- res.writeHead(404, { 'content-type': 'application/json' });
293
- res.end(JSON.stringify({ error: 'no such route' }));
294
- });
295
- await new Promise((ok, no) => {
296
- server.once('error', no);
297
- server.listen(port, host, () => ok());
298
- });
299
- const address = server.address();
300
- const bound = typeof address === 'object' && address !== null ? address.port : port;
301
- return { server, host, port: bound, mount: (prefix, handler) => mounts.set(prefix.replace(/\/$/, ''), handler) };
302
- }
303
253
  // A side connection: hello, then MCP over the same lines, or, with `run`,
304
254
  // the human's lines in and the model's final texts out.
305
255
  async function sideConnection(harbor, c) {
@@ -407,7 +357,7 @@ async function handle(harbor, line) {
407
357
  }
408
358
  }
409
359
  // `<dir>/routes.json`: { mcp, web, quo? }, the public origins the proxy serves.
410
- async function readRoutes(dir) {
360
+ export async function readRoutes(dir) {
411
361
  const p = join(dir, 'routes.json');
412
362
  if (!existsSync(p))
413
363
  return null;
@@ -419,119 +369,6 @@ async function readRoutes(dir) {
419
369
  out.quo = r.quo.replace(/\/$/, '');
420
370
  return out;
421
371
  }
422
- // The tab page, `estate/human/tab.ts`, on the web route: the page, its
423
- // bundle built once from the source, and the one call of the exchange a tab
424
- // makes: the owner password for a nonce the desk honours under `tab`. The
425
- // avatar that knocks with it lives in the tab, not here, so nothing is
426
- // admitted on this side; the tab's harbor announces its ward and the user
427
- // being knocks back down that socket.
428
- function tabPages(main, at, password) {
429
- // The tab's source sits beside this file: `.ts` in the tree, `.js` once
430
- // emitted into the package's dist. The bundler takes whichever is there.
431
- const tabEntry = () => {
432
- const js = fileURLToPath(new URL('../human/tab.js', import.meta.url));
433
- return existsSync(js) ? js : fileURLToPath(new URL('../human/tab.ts', import.meta.url));
434
- };
435
- let bundle;
436
- const built = () => (bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((o) => o.outputFiles[0].text));
437
- const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
438
- <style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem;color:#222}input,button{font:inherit;padding:.4rem;margin:.2rem}pre{background:#f4f4f4;padding:.75rem;overflow:auto}</style>
439
- </head><body><script type="module">import { start } from './tab.js'; start(${JSON.stringify(at)});</script></body></html>`;
440
- return async (req, res, rest) => {
441
- if (rest === '/tab' && req.method === 'GET') {
442
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
443
- res.end(html);
444
- return true;
445
- }
446
- if (rest === '/tab.js' && req.method === 'GET') {
447
- res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
448
- res.end(await built());
449
- return true;
450
- }
451
- if (rest === '/tab/login' && req.method === 'POST') {
452
- const raw = await readAll(req);
453
- let body = {};
454
- try {
455
- body = JSON.parse(raw?.toString() || '{}');
456
- }
457
- catch {
458
- /* nothing to read */
459
- }
460
- const json = (status, v) => {
461
- res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
462
- res.end(JSON.stringify(v));
463
- return true;
464
- };
465
- const want = password();
466
- if (want === undefined)
467
- return json(503, { error: 'closed: no owner password is set' });
468
- const got = typeof body.password === 'string' ? body.password : '';
469
- if (got.length !== want.length || !timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
470
- await new Promise((ok) => setTimeout(ok, 300));
471
- return json(401, { error: 'that is not the password' });
472
- }
473
- const identity = typeof body.identity === 'string' ? body.identity : '';
474
- if (!/^[\w.-]{1,40}$/.test(identity) || identity === main.record.user || identity === 'desk')
475
- return json(400, { error: 'an identity is one word, and not the user or the desk' });
476
- const nonce = randomBytes(16).toString('hex');
477
- NONCES.set(nonce, { kind: 'tab', user: main.record.user, client: identity });
478
- return json(200, { nonce, ward: main.pk });
479
- }
480
- return false;
481
- };
482
- }
483
- // The quo. route. A request carries one ask to a pk; an upgrade is a held
484
- // socket. Bytes from here go to an own door or a held socket, never onward.
485
- function mountQuo(harbor, http, server, quo) {
486
- const pks = () => [...harbor.doors.keys()];
487
- // The route is public and carries sealed bytes, so any origin may POST
488
- // to it: a tab on a world's web. reaching another world's quo. is the
489
- // ordinary case, and a binary POST needs the preflight answered.
490
- const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, quo-suite' };
491
- http.mount('/quo', async (req, res, rest) => {
492
- if (req.method === 'OPTIONS') {
493
- res.writeHead(204, open);
494
- return void res.end();
495
- }
496
- // The wire suite the caller speaks. Absent is this one, because a caller
497
- // older than the header is this one. Anything else this door cannot open
498
- // and says so as nothing delivered, rather than taking bytes that will
499
- // never open and answering a silence that names no reason.
500
- const suite = req.headers['quo-suite'];
501
- if (suite !== undefined && suite !== String(SUITE)) {
502
- res.writeHead(404, { 'content-type': 'application/json', ...open });
503
- return void res.end(JSON.stringify({ error: `this door speaks wire suite ${SUITE}` }));
504
- }
505
- const pk = rest.slice(1);
506
- if (req.method !== 'POST' || !/^[0-9a-f]{128}$/.test(pk)) {
507
- res.writeHead(404, { 'content-type': 'application/json', ...open });
508
- return void res.end(JSON.stringify({ error: 'POST /quo/<pk>' }));
509
- }
510
- const raw = await readAll(req);
511
- const back = raw === undefined ? undefined : await harbor.deliver(pk, new Uint8Array(raw));
512
- if (back === undefined) {
513
- res.writeHead(404, { 'content-type': 'application/json', ...open });
514
- return void res.end(JSON.stringify({ error: 'no reach for that pk' }));
515
- }
516
- res.writeHead(200, { 'content-type': 'application/octet-stream', ...open });
517
- res.end(Buffer.from(back));
518
- });
519
- const wss = new WebSocketServer({ noServer: true });
520
- server.on('upgrade', (req, socket, head) => {
521
- const url = new URL(req.url ?? '/', 'http://localhost');
522
- if (url.pathname.replace(/\/$/, '') !== '/quo')
523
- return void socket.destroy(); // the proxy rewrites the route root to /quo/
524
- wss.handleUpgrade(req, socket, head, (ws) => {
525
- 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
526
- () => {
527
- harbor.unbind(s);
528
- quo.sockets.delete(s);
529
- });
530
- quo.sockets.add(s);
531
- s.announce(pks());
532
- });
533
- });
534
- }
535
372
  // `<dir>/dial.json`: [url, ...], the quo. routes this daemon holds a socket to.
536
373
  async function readDial(dir) {
537
374
  const p = join(dir, 'dial.json');
@@ -553,29 +390,3 @@ async function readAgents(dir) {
553
390
  }
554
391
  return out;
555
392
  }
556
- // The mcp route: OAuth today, the MCP endpoint at step 9. Its store is
557
- // `<dir>/oauth.json`, the route's own and never a ward's: clients, pending
558
- // requests, codes and tokens, each mapping to a client identity at most.
559
- async function mountOAuth(dir, http, routes, mcp) {
560
- const file = join(dir, 'oauth.json');
561
- const store = existsSync(file) ? { ...emptyStore(), ...JSON.parse(await readFile(file, 'utf8')) } : emptyStore();
562
- let queue = Promise.resolve();
563
- const persist = (s) => (queue = queue.then(async () => {
564
- await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
565
- await rename(file + '.tmp', file);
566
- }));
567
- const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/login?request=${id}`, store, persist });
568
- http.mount('/mcp', async (req, res, rest) => {
569
- if (await oauth.handle(req, res, rest))
570
- return;
571
- if (rest === '/mcp') {
572
- const identity = oauth.bearer(req);
573
- if (identity === null || !mcp)
574
- return oauth.challenge(res);
575
- return mcp.handle(req, res, identity);
576
- }
577
- res.writeHead(404, { 'content-type': 'application/json' });
578
- res.end(JSON.stringify({ error: 'no such route' }));
579
- });
580
- return oauth;
581
- }
@@ -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,9 +8,10 @@
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
+ // quo unboot [--dir D] [--ward W] [--via S] <json> { being }: a being out of the ward, with every relation she holds
14
15
  // quo side [--dir D] [--ward W] --as NAME an avatar over stdio for a local MCP client; NAME is the client identity
15
16
  // quo run [--dir D] [--ward W] --as NAME --url URL --model NAME [--turns N] a human at a terminal talking to a model through an avatar: a line in, the model's final text out
16
17
  // quo pilot [--dir D] [--ward W] [--via S] the owner pilot over stdio: census, boot, invite, knock, remove
@@ -29,11 +30,12 @@ import { homedir, userInfo } from 'node:os';
29
30
  import { join } from 'node:path';
30
31
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
31
32
  import { DiskHarbor } from '../harbor/disk.js';
32
- import { serve } from './daemon.js';
33
+ import { serve, readRoutes } from './daemon.js';
33
34
  import { ask, side, reach } from './client.js';
35
+ import { link, isInvitation } from '../beings/link.js';
34
36
  import { pilotSide } from '../mcp/pilot.js';
35
37
  import { estate } from './estate.js';
36
- const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove']);
38
+ const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove', 'unboot']);
37
39
  function parse(argv) {
38
40
  const flags = {};
39
41
  const rest = [];
@@ -113,13 +115,21 @@ async function main(argv) {
113
115
  if (cmd === 'census' || (cmd !== undefined && OWNER_ASKS.has(cmd))) {
114
116
  const args = json ? JSON.parse(json) : {};
115
117
  const out = await ask(dir, cmd === 'census' ? undefined : cmd, args, ward, via);
116
- 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);
117
127
  // an error object the ward answered is an ordinary answer, and still a
118
128
  // non-zero exit, so a script can tell a boot that happened from one that did not.
119
129
  const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
120
130
  return failed ? 1 : 0;
121
131
  }
122
- console.error('usage: quo init|serve|census|boot|public|invite|knock|remove|side|run|pilot|reach|estate [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
132
+ console.error('usage: quo init|serve|census|boot|public|invite|knock|remove|unboot|side|run|pilot|reach|estate [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
123
133
  return 2;
124
134
  }
125
135
  main(process.argv.slice(2)).then((code) => {
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import type { Avatar } from '../beings/avatar.ts';
2
+ import type { Subject } from '../beings/side.ts';
3
+ export declare function guest(avatar: Avatar, ward: string): Subject;