@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
package/beings/user.ts CHANGED
@@ -5,45 +5,79 @@
5
5
  // special only by the id the root chose for them: `desk`, the front desk,
6
6
  // who may ask her to invite a device; and each device, whose id is the
7
7
  // client identity the desk established.
8
- import { Being, isSilence, isWord, wordOf } from '@quo-systems/quo';
8
+ import { isSilence, isWord, wordOf } from '@quo-systems/quo';
9
9
  import type { Asker, Invitation, JsonObject, OccupantRecord } from '@quo-systems/quo';
10
+ import { Carrier } from './carry.ts';
10
11
 
11
12
  export const DESK = 'desk';
12
13
  const isDesk = (occ: OccupantRecord | undefined) => occ?.id === DESK;
13
14
  const isDevice = (occ: OccupantRecord | undefined) => occ !== undefined && occ.id !== DESK;
14
- const client = (occ: OccupantRecord | undefined) => (typeof occ?.notes.client === 'string' ? occ.notes.client : null);
15
- // A device the human allowed to wake her other devices: the note says so.
16
- const mayWake = (occ: OccupantRecord | undefined) => isDesk(occ) || occ?.notes.wake === true;
15
+ // A device is known by its client identity. The desk writes it as a note
16
+ // when it asks her to mint; the root, minting on her directly, writes
17
+ // nothing, and then the id the root chose is the identity.
18
+ const rootMinted = (occ: OccupantRecord | undefined) => isDevice(occ) && occ?.notes.client === undefined;
19
+ const client = (occ: OccupantRecord | undefined) => (typeof occ?.notes.client === 'string' ? occ.notes.client : rootMinted(occ) ? occ!.id : null);
20
+ // A device the human allowed to wake her other devices: the note says so,
21
+ // or the root minted it, and the root's word needs no note.
22
+ const mayWake = (occ: OccupantRecord | undefined) => isDesk(occ) || occ?.notes.wake === true || rootMinted(occ);
17
23
 
18
- export class User extends Being {
19
- static override cells = { name: '', reports: [] as JsonObject[] };
24
+ // She carries her standings, acme, the calendar, the house, for a device the
25
+ // human allowed to reach them at the exchange: the note says so. A device
26
+ // without the note sees her own asks alone, and a model sees acme only
27
+ // because the human said it may. A device the root minted reaches: the root
28
+ // is the owner of everything she holds and chose to mint it.
29
+ export class User extends Carrier {
30
+ static override carries(occ: OccupantRecord | undefined): boolean {
31
+ return isDevice(occ) && (occ?.notes.reach === true || rootMinted(occ));
32
+ }
33
+ // `ways` holds a device's way back that could not be taken when it was
34
+ // handed over: the knock was unreached, a tab whose pk the far harbor had
35
+ // not bound yet, a phone in a tunnel. Nothing delivered spends nothing,
36
+ // so the invitation is tried again at the next push.
37
+ static override cells = { name: '', reports: [] as JsonObject[], ways: {} as Record<string, Invitation> };
20
38
  static override asks = {
21
39
  hello: { description: 'say hello, and hand back an invitation so she can reach you', input: { type: 'object', properties: { invitation: { type: 'object' } } } },
22
40
  whoami: { description: 'who she thinks you are', input: { type: 'object' }, for: isDevice },
23
- device: { description: 'mint an invitation for a device', input: { type: 'object', properties: { client: { type: 'string' }, wake: { type: 'boolean' } }, required: ['client'] }, for: isDesk },
41
+ device: { description: 'mint an invitation for a device', input: { type: 'object', properties: { client: { type: 'string' }, wake: { type: 'boolean' }, reach: { type: 'boolean' } }, required: ['client'] }, for: isDesk },
24
42
  push: { description: 'push an object to a device: wake it with an event', input: { type: 'object', properties: { client: { type: 'string' }, object: { type: 'object' } }, required: ['client', 'object'] }, for: mayWake },
25
43
  chores: { description: 'what the agent may run', input: { type: 'object' }, for: (occ: OccupantRecord | undefined) => client(occ) === 'agent' },
44
+ forget: { description: 'revoke a device: drop its way in and her way back to it, in one act', input: { type: 'object', properties: { client: { type: 'string' } }, required: ['client'] }, for: isDesk },
26
45
  report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
27
46
  };
28
47
 
29
48
  // Anyone may say hello. A device that hands her an invitation in the args
30
- // is taken as a standing under its own id, so she can push to it later.
49
+ // is taken as a standing under its own id, so she can push to it later;
50
+ // if it cannot be taken now, it is kept and tried at the next push.
31
51
  async hello(args: JsonObject, asker: Asker) {
32
52
  if (asker.id !== undefined && args.invitation && typeof args.invitation === 'object' && !Array.isArray(args.invitation)) {
33
- const inv = args.invitation as Invitation;
34
- const back = await this.knock(inv);
35
- if (!isSilence(back) && !isWord(back)) await this.take(`to:${asker.id}`, inv);
53
+ await this.wayBack(asker.id, args.invitation as Invitation);
36
54
  }
37
55
  return { welcome: asker.id ?? null, name: this.cells.name };
38
56
  }
39
57
 
58
+ // Take a device's way back, or keep it for later when the knock did not
59
+ // reach. A refusal spends the heir and is final; the way is dropped.
60
+ async wayBack(id: string, inv: Invitation): Promise<boolean> {
61
+ const ways = this.cells.ways as Record<string, Invitation>;
62
+ const back = await this.knock(inv);
63
+ if (!isSilence(back) && !isWord(back)) {
64
+ await this.take(`to:${id}`, inv);
65
+ delete ways[id];
66
+ return true;
67
+ }
68
+ if (isWord(back) && wordOf(back) !== 'unreached') delete ways[id];
69
+ else ways[id] = inv;
70
+ return false;
71
+ }
72
+
40
73
  whoami(_args: JsonObject, asker: Asker) {
41
74
  return { id: asker.id ?? null, client: client(this.occupant(asker)) };
42
75
  }
43
76
 
44
77
  // The front desk asks; she mints. The client identity goes into the
45
78
  // occupant's notes, and that is what every gate reads. `wake` is the
46
- // human's word at the exchange that this device may wake her others.
79
+ // human's word at the exchange that this device may wake her others, and
80
+ // `reach` that it may see and ask what she holds: her standings, carried.
47
81
  async device(args: JsonObject) {
48
82
  const c = typeof args.client === 'string' ? args.client : null;
49
83
  if (c === null) return { error: 'client is a string' };
@@ -53,18 +87,38 @@ export class User extends Being {
53
87
  if (rec) {
54
88
  rec.notes.client = c;
55
89
  if (args.wake === true) rec.notes.wake = true;
90
+ if (args.reach === true) rec.notes.reach = true;
56
91
  }
57
92
  return inv as unknown as JsonObject;
58
93
  }
59
94
 
95
+ // Her way out and the device's way in are two relations, and Quo keeps
96
+ // them apart. She joins them herself, here, as her own rule: she does
97
+ // not reach a device she no longer admits. Without it a revoked device
98
+ // stops being able to ask her and keeps receiving everything she pushes.
60
99
  async push(args: JsonObject) {
61
100
  const c = typeof args.client === 'string' ? args.client : null;
62
- const st = c === null ? undefined : this.standings[`to:${c}`];
101
+ if (c === null || !this.cells.occupants[c]) return { error: 'no such device, or it gave no way back' };
102
+ const kept = (this.cells.ways as Record<string, Invitation>)[c];
103
+ if (!this.standings[`to:${c}`] && kept) await this.wayBack(c, kept);
104
+ const st = this.standings[`to:${c}`];
63
105
  if (!st) return { error: 'no such device, or it gave no way back' };
64
106
  const out = await st.ask('notify', (args.object as JsonObject) ?? {});
65
107
  return isSilence(out) ? { error: 'silence' } : isWord(out) ? { error: wordOf(out) } : { pushed: out };
66
108
  }
67
109
 
110
+ // Revocation is one act at her, because only she knows both ids. A side
111
+ // that had to remove two relations by hand could leave half of one
112
+ // standing, and every side would have to remember which half.
113
+ forget(args: JsonObject) {
114
+ const c = typeof args.client === 'string' ? args.client : null;
115
+ if (c === null) return { error: 'client is a string' };
116
+ this.occupants.remove(c);
117
+ this.standings.remove(`to:${c}`);
118
+ delete (this.cells.ways as Record<string, Invitation>)[c];
119
+ return { forgot: c };
120
+ }
121
+
68
122
  chores() {
69
123
  return { chores: ['census', 'report'] };
70
124
  }
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 }>;
@@ -102,7 +95,7 @@ export const sidePath = (dir: string) => socketPath(dir, 'side');
102
95
  // them: `local`, for a side that reached side.sock, and `web`, for a human
103
96
  // who passed the exchange pages. Both are the daemon vouching for someone it
104
97
  // saw itself; a proof of either kind arriving any other way names no nonce.
105
- const NONCES = new Map<string, { kind: string; user: string; client: string; wake?: boolean }>();
98
+ const NONCES = new Map<string, { kind: string; user: string; client: string; wake?: boolean; reach?: boolean }>();
106
99
  // A nonce is minted for one kind and honoured under that kind alone: a tab's
107
100
  // nonce offered as a `local` proof names no nonce, so a proof of a kind still
108
101
  // arrives only the one way that kind is made.
@@ -117,28 +110,13 @@ 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
138
116
  // `user` and nothing is minted, so `wake`, the human's word that this
139
117
  // device may wake her others, is read the first time only; to change it,
140
118
  // remove the occupant and allow again. The one path for every side.
141
- export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake = false): Promise<{ avatar?: Avatar; error?: string }> {
119
+ export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake = false, reach = false): Promise<{ avatar?: Avatar; error?: string }> {
142
120
  if (!/^[\w.-]+$/.test(identity) || identity === hosted.record.user || identity === 'desk') return { error: 'an identity is a word, and not a being of the ward' };
143
121
  const key = `avatar:${identity}`;
144
122
  let avatar = hosted.being(key) as Avatar | undefined;
@@ -148,7 +126,7 @@ export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'w
148
126
  avatar = hosted.being(key) as Avatar;
149
127
  }
150
128
  const nonce = randomBytes(16).toString('hex');
151
- NONCES.set(nonce, wake ? { kind, user: hosted.record.user, client: identity, wake: true } : { kind, user: hosted.record.user, client: identity });
129
+ NONCES.set(nonce, { kind, user: hosted.record.user, client: identity, ...(wake ? { wake: true } : {}), ...(reach ? { reach: true } : {}) });
152
130
  const entered = await avatar.enter({ ward: hosted.pk }, { kind, nonce });
153
131
  NONCES.delete(nonce);
154
132
  await hosted.save(); // the knock went through the ward's own door, which the harbor never sees
@@ -193,33 +171,48 @@ 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
- const main = harbor.wards.get('main');
200
177
  const here = `http://${http.host}:${http.port}`;
201
- // the tab page: always, on the daemon's own door when no route names a public one
202
- const tab = main ? tabPages(main, { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` }, password) : null;
178
+ // the worlds' pages: always, on the daemon's own door when no route names a public one
179
+ const web = webRoute(harbor, { at: { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` } });
203
180
  if (routes) {
204
- // the MCP endpoint: a bearer names an identity, the identity names her avatar, the side runs beside her
205
- mcp = main ? new McpHttp((identity) => admit(main, identity, 'web'), () => main.save()) : null;
206
- oauth = await mountOAuth(harbor.dir, http, routes, mcp);
207
- if (mcp) mcp.gone = (identity) => oauth!.revoke(identity); // removal at the ward ends the grant at the route
208
- if (main) {
209
- exchange = new Exchange({
210
- oauth,
211
- password,
212
- user: main.record.user,
213
- admit: async (identity, wake) => {
214
- const r = await admit(main, identity, 'web', wake);
215
- return r.error ? { error: r.error } : {};
216
- },
217
- });
218
- }
181
+ // the worlds a client may be allowed into: every ward with a public being, main first
182
+ const worlds = () =>
183
+ [...harbor.wards]
184
+ .filter(([, h]) => (h.partition as { public?: string | null }).public !== null)
185
+ .sort(([a], [b]) => (a === 'main' ? -1 : b === 'main' ? 1 : a.localeCompare(b)))
186
+ .map(([ward, h]) => ({ ward, user: h.record.user }));
187
+ // the MCP endpoint: a bearer names an identity in a world, the identity names her avatar there, the side runs beside her
188
+ mcp = new McpHttp(
189
+ async (identity, ward) => {
190
+ const hosted = harbor.wards.get(ward);
191
+ return hosted ? admit(hosted, identity, 'web') : { error: 'no such world' };
192
+ },
193
+ async () => {
194
+ for (const h of harbor.wards.values()) await h.save();
195
+ },
196
+ );
197
+ const route = await mcpRoute(harbor.dir, routes, mcp);
198
+ oauth = route.oauth;
199
+ http.mount('/mcp', route.handler);
200
+ mcp.gone = (identity, ward) => oauth!.revoke(identity, ward); // removal at the ward ends the grant at the route
201
+ exchange = new Exchange({
202
+ oauth,
203
+ password,
204
+ worlds,
205
+ admit: async (identity, wake, reach, ward) => {
206
+ const hosted = harbor.wards.get(ward);
207
+ if (!hosted) return { error: 'no such world' };
208
+ const r = await admit(hosted, identity, 'web', wake, reach);
209
+ return r.error ? { error: r.error } : {};
210
+ },
211
+ });
219
212
  }
220
213
  const ex = exchange;
221
214
  http.mount('/web', async (req, res, rest) => {
222
- if (tab && (await tab(req, res, rest))) return;
215
+ if (await web(req, res, rest)) return;
223
216
  if (ex && (await ex.handle(req, res, rest))) return;
224
217
  res.writeHead(404, { 'content-type': 'text/plain' });
225
218
  res.end('no such page');
@@ -264,50 +257,6 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
264
257
  };
265
258
  }
266
259
 
267
- // The HTTP door. Loopback only; the proxy faces the world. A route mounts
268
- // under a prefix and gets the rest of the path. `/health` is the daemon's
269
- // own: the wards it hosts, by name and pk, and nothing a stranger can use.
270
- async function listenHttp(harbor: DiskHarbor, host: string, port: number, quo: Quo): Promise<Http & { server: HttpServer }> {
271
- const mounts = new Map<string, Handler>();
272
- const server = createHttp((req, res) => {
273
- const url = new URL(req.url ?? '/', 'http://localhost');
274
- if (url.pathname === '/health') {
275
- // the wards it hosts, and the directory: pks and whether each is a
276
- // socket held here, which is what an operator needs to see a dialer.
277
- // Open to any origin: a tab reads it, and it holds nothing a stranger can use.
278
- res.writeHead(200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
279
- res.end(
280
- JSON.stringify({
281
- ok: true,
282
- wards: Object.fromEntries([...harbor.wards].map(([n, h]) => [n, h.pk])),
283
- reaches: Object.fromEntries([...harbor.reaches].map(([pk, b]) => [pk, b.held ? 'held' : 'reach'])),
284
- sockets: quo.sockets.size,
285
- dialers: quo.dialers.map((d) => ({ url: d.url, open: d.socket !== null })),
286
- }),
287
- );
288
- return;
289
- }
290
- for (const [prefix, handler] of mounts) {
291
- if (url.pathname === prefix || url.pathname.startsWith(prefix + '/')) {
292
- void Promise.resolve(handler(req, res, url.pathname.slice(prefix.length))).catch(() => {
293
- if (!res.headersSent) res.writeHead(500);
294
- res.end();
295
- });
296
- return;
297
- }
298
- }
299
- res.writeHead(404, { 'content-type': 'application/json' });
300
- res.end(JSON.stringify({ error: 'no such route' }));
301
- });
302
- await new Promise<void>((ok, no) => {
303
- server.once('error', no);
304
- server.listen(port, host, () => ok());
305
- });
306
- const address = server.address();
307
- const bound = typeof address === 'object' && address !== null ? address.port : port;
308
- return { server, host, port: bound, mount: (prefix, handler) => mounts.set(prefix.replace(/\/$/, ''), handler) };
309
- }
310
-
311
260
  // A side connection: hello, then MCP over the same lines, or, with `run`,
312
261
  // the human's lines in and the model's final texts out.
313
262
  async function sideConnection(harbor: DiskHarbor, c: Socket): Promise<void> {
@@ -409,7 +358,7 @@ async function handle(harbor: DiskHarbor, line: string): Promise<string> {
409
358
  }
410
359
 
411
360
  // `<dir>/routes.json`: { mcp, web, quo? }, the public origins the proxy serves.
412
- async function readRoutes(dir: string): Promise<Routes | null> {
361
+ export async function readRoutes(dir: string): Promise<Routes | null> {
413
362
  const p = join(dir, 'routes.json');
414
363
  if (!existsSync(p)) return null;
415
364
  const r = JSON.parse(await readFile(p, 'utf8')) as Partial<Routes>;
@@ -419,122 +368,6 @@ async function readRoutes(dir: string): Promise<Routes | null> {
419
368
  return out;
420
369
  }
421
370
 
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: Hosted, at: { quo: string; web: string }, password: () => string | undefined): (req: IncomingMessage, res: ServerResponse, rest: string) => Promise<boolean> {
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: Promise<string> | undefined;
436
- const built = () =>
437
- (bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((o) => o.outputFiles[0]!.text));
438
- 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>
439
- <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>
440
- </head><body><script type="module">import { start } from './tab.js'; start(${JSON.stringify(at)});</script></body></html>`;
441
- return async (req, res, rest) => {
442
- if (rest === '/tab' && req.method === 'GET') {
443
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
444
- res.end(html);
445
- return true;
446
- }
447
- if (rest === '/tab.js' && req.method === 'GET') {
448
- res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
449
- res.end(await built());
450
- return true;
451
- }
452
- if (rest === '/tab/login' && req.method === 'POST') {
453
- const raw = await readAll(req as AsyncIterable<Buffer>);
454
- let body: { password?: unknown; identity?: unknown } = {};
455
- try {
456
- body = JSON.parse(raw?.toString() || '{}') as typeof body;
457
- } catch {
458
- /* nothing to read */
459
- }
460
- const json = (status: number, v: unknown) => {
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) return json(503, { error: 'closed: no owner password is set' });
467
- const got = typeof body.password === 'string' ? body.password : '';
468
- if (got.length !== want.length || !timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
469
- await new Promise((ok) => setTimeout(ok, 300));
470
- return json(401, { error: 'that is not the password' });
471
- }
472
- const identity = typeof body.identity === 'string' ? body.identity : '';
473
- if (!/^[\w.-]{1,40}$/.test(identity) || identity === main.record.user || identity === 'desk') return json(400, { error: 'an identity is one word, and not the user or the desk' });
474
- const nonce = randomBytes(16).toString('hex');
475
- NONCES.set(nonce, { kind: 'tab', user: main.record.user, client: identity });
476
- return json(200, { nonce, ward: main.pk });
477
- }
478
- return false;
479
- };
480
- }
481
-
482
- // The quo. route. A request carries one ask to a pk; an upgrade is a held
483
- // socket. Bytes from here go to an own door or a held socket, never onward.
484
- function mountQuo(harbor: DiskHarbor, http: Http, server: HttpServer, quo: Quo): void {
485
- const pks = () => [...harbor.doors.keys()];
486
- // The route is public and carries sealed bytes, so any origin may POST
487
- // to it: a tab on a world's web. reaching another world's quo. is the
488
- // ordinary case, and a binary POST needs the preflight answered.
489
- const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, quo-suite' };
490
- http.mount('/quo', async (req, res, rest) => {
491
- if (req.method === 'OPTIONS') {
492
- res.writeHead(204, open);
493
- return void res.end();
494
- }
495
- // The wire suite the caller speaks. Absent is this one, because a caller
496
- // older than the header is this one. Anything else this door cannot open
497
- // and says so as nothing delivered, rather than taking bytes that will
498
- // never open and answering a silence that names no reason.
499
- const suite = req.headers['quo-suite'];
500
- if (suite !== undefined && suite !== String(SUITE)) {
501
- res.writeHead(404, { 'content-type': 'application/json', ...open });
502
- return void res.end(JSON.stringify({ error: `this door speaks wire suite ${SUITE}` }));
503
- }
504
- const pk = rest.slice(1);
505
- if (req.method !== 'POST' || !/^[0-9a-f]{128}$/.test(pk)) {
506
- res.writeHead(404, { 'content-type': 'application/json', ...open });
507
- return void res.end(JSON.stringify({ error: 'POST /quo/<pk>' }));
508
- }
509
- const raw = await readAll(req as AsyncIterable<Buffer>);
510
- const back = raw === undefined ? undefined : await harbor.deliver(pk, new Uint8Array(raw));
511
- if (back === undefined) {
512
- res.writeHead(404, { 'content-type': 'application/json', ...open });
513
- return void res.end(JSON.stringify({ error: 'no reach for that pk' }));
514
- }
515
- res.writeHead(200, { 'content-type': 'application/octet-stream', ...open });
516
- res.end(Buffer.from(back));
517
- });
518
- const wss = new WebSocketServer({ noServer: true });
519
- server.on('upgrade', (req, socket, head) => {
520
- const url = new URL(req.url ?? '/', 'http://localhost');
521
- if (url.pathname.replace(/\/$/, '') !== '/quo') return void socket.destroy(); // the proxy rewrites the route root to /quo/
522
- wss.handleUpgrade(req, socket, head, (ws) => {
523
- const s: Held = new Held(
524
- ws as unknown as Line,
525
- (pk, bytes) => harbor.deliver(pk, bytes),
526
- (far) => void harbor.bind(far, s, true), // a dialer's claims, each proven at its door: held here, reachable through this socket
527
- () => {
528
- harbor.unbind(s);
529
- quo.sockets.delete(s);
530
- },
531
- );
532
- quo.sockets.add(s);
533
- s.announce(pks());
534
- });
535
- });
536
- }
537
-
538
371
  // `<dir>/dial.json`: [url, ...], the quo. routes this daemon holds a socket to.
539
372
  async function readDial(dir: string): Promise<string[]> {
540
373
  const p = join(dir, 'dial.json');
@@ -554,29 +387,3 @@ async function readAgents(dir: string): Promise<Agents> {
554
387
  }
555
388
  return out;
556
389
  }
557
-
558
- // The mcp route: OAuth today, the MCP endpoint at step 9. Its store is
559
- // `<dir>/oauth.json`, the route's own and never a ward's: clients, pending
560
- // requests, codes and tokens, each mapping to a client identity at most.
561
- async function mountOAuth(dir: string, http: Http, routes: Routes, mcp: McpHttp | null): Promise<OAuth> {
562
- const file = join(dir, 'oauth.json');
563
- const store: Store = existsSync(file) ? { ...emptyStore(), ...(JSON.parse(await readFile(file, 'utf8')) as Partial<Store>) } : emptyStore();
564
- let queue = Promise.resolve();
565
- const persist = (s: Store) =>
566
- (queue = queue.then(async () => {
567
- await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
568
- await rename(file + '.tmp', file);
569
- }));
570
- const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/login?request=${id}`, store, persist });
571
- http.mount('/mcp', async (req, res, rest) => {
572
- if (await oauth.handle(req, res, rest)) return;
573
- if (rest === '/mcp') {
574
- const identity = oauth.bearer(req);
575
- if (identity === null || !mcp) return oauth.challenge(res);
576
- return mcp.handle(req, res, identity);
577
- }
578
- res.writeHead(404, { 'content-type': 'application/json' });
579
- res.end(JSON.stringify({ error: 'no such route' }));
580
- });
581
- return oauth;
582
- }
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
+ }