@quo-systems/dock 0.2.0 → 0.2.1

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 (58) hide show
  1. package/beings/avatar.ts +6 -2
  2. package/beings/carry.ts +101 -0
  3. package/beings/desk.ts +2 -2
  4. package/beings/index.ts +2 -0
  5. package/beings/link.ts +54 -0
  6. package/beings/look.ts +96 -0
  7. package/beings/quo-dock.md +129 -19
  8. package/beings/side.ts +10 -1
  9. package/beings/user.ts +31 -5
  10. package/cli/daemon.ts +98 -44
  11. package/cli/quo.ts +3 -2
  12. package/dist/beings/avatar.js +7 -2
  13. package/dist/beings/carry.d.ts +10 -0
  14. package/dist/beings/carry.js +106 -0
  15. package/dist/beings/desk.d.ts +1 -0
  16. package/dist/beings/desk.js +1 -1
  17. package/dist/beings/index.d.ts +2 -0
  18. package/dist/beings/index.js +2 -0
  19. package/dist/beings/link.d.ts +7 -0
  20. package/dist/beings/link.js +42 -0
  21. package/dist/beings/look.d.ts +27 -0
  22. package/dist/beings/look.js +71 -0
  23. package/dist/beings/side.d.ts +8 -1
  24. package/dist/beings/user.d.ts +26 -2
  25. package/dist/beings/user.js +32 -5
  26. package/dist/cli/daemon.d.ts +1 -1
  27. package/dist/cli/daemon.js +98 -44
  28. package/dist/cli/quo.js +3 -2
  29. package/dist/human/guest.d.ts +3 -0
  30. package/dist/human/guest.js +25 -0
  31. package/dist/human/html.d.ts +10 -2
  32. package/dist/human/html.js +61 -10
  33. package/dist/human/screen.d.ts +8 -3
  34. package/dist/human/screen.js +25 -6
  35. package/dist/human/tab.d.ts +2 -0
  36. package/dist/human/tab.js +127 -42
  37. package/dist/mcp/http.d.ts +4 -3
  38. package/dist/mcp/http.js +6 -6
  39. package/dist/mcp/oauth.d.ts +9 -4
  40. package/dist/mcp/oauth.js +15 -14
  41. package/dist/mcp/pilot.js +1 -1
  42. package/dist/mcp/server.d.ts +5 -10
  43. package/dist/mcp/server.js +35 -4
  44. package/dist/mcp/web/exchange.d.ts +5 -2
  45. package/dist/mcp/web/exchange.js +21 -7
  46. package/harbor/quo-harbor.md +37 -0
  47. package/human/guest.ts +26 -0
  48. package/human/html.ts +58 -10
  49. package/human/quo-human.md +123 -66
  50. package/human/screen.ts +28 -7
  51. package/human/tab.ts +153 -51
  52. package/mcp/http.ts +10 -9
  53. package/mcp/oauth.ts +20 -17
  54. package/mcp/pilot.ts +1 -1
  55. package/mcp/quo-mcp.md +18 -2
  56. package/mcp/server.ts +37 -18
  57. package/mcp/web/exchange.ts +23 -9
  58. package/package.json +2 -2
package/beings/user.ts CHANGED
@@ -5,8 +5,9 @@
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;
@@ -15,14 +16,22 @@ const client = (occ: OccupantRecord | undefined) => (typeof occ?.notes.client ==
15
16
  // A device the human allowed to wake her other devices: the note says so.
16
17
  const mayWake = (occ: OccupantRecord | undefined) => isDesk(occ) || occ?.notes.wake === true;
17
18
 
18
- export class User extends Being {
19
+ // She carries her standings, acme, the calendar, the house, for a device the
20
+ // human allowed to reach them at the exchange: the note says so. A device
21
+ // without the note sees her own asks alone, and a model sees acme only
22
+ // because the human said it may.
23
+ export class User extends Carrier {
24
+ static override carries(occ: OccupantRecord | undefined): boolean {
25
+ return isDevice(occ) && occ?.notes.reach === true;
26
+ }
19
27
  static override cells = { name: '', reports: [] as JsonObject[] };
20
28
  static override asks = {
21
29
  hello: { description: 'say hello, and hand back an invitation so she can reach you', input: { type: 'object', properties: { invitation: { type: 'object' } } } },
22
30
  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 },
31
+ 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
32
  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
33
  chores: { description: 'what the agent may run', input: { type: 'object' }, for: (occ: OccupantRecord | undefined) => client(occ) === 'agent' },
34
+ 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
35
  report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
27
36
  };
28
37
 
@@ -43,7 +52,8 @@ export class User extends Being {
43
52
 
44
53
  // The front desk asks; she mints. The client identity goes into the
45
54
  // 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.
55
+ // human's word at the exchange that this device may wake her others, and
56
+ // `reach` that it may see and ask what she holds: her standings, carried.
47
57
  async device(args: JsonObject) {
48
58
  const c = typeof args.client === 'string' ? args.client : null;
49
59
  if (c === null) return { error: 'client is a string' };
@@ -53,18 +63,34 @@ export class User extends Being {
53
63
  if (rec) {
54
64
  rec.notes.client = c;
55
65
  if (args.wake === true) rec.notes.wake = true;
66
+ if (args.reach === true) rec.notes.reach = true;
56
67
  }
57
68
  return inv as unknown as JsonObject;
58
69
  }
59
70
 
71
+ // Her way out and the device's way in are two relations, and Quo keeps
72
+ // them apart. She joins them herself, here, as her own rule: she does
73
+ // not reach a device she no longer admits. Without it a revoked device
74
+ // stops being able to ask her and keeps receiving everything she pushes.
60
75
  async push(args: JsonObject) {
61
76
  const c = typeof args.client === 'string' ? args.client : null;
62
- const st = c === null ? undefined : this.standings[`to:${c}`];
77
+ const st = c === null || !this.cells.occupants[c] ? undefined : this.standings[`to:${c}`];
63
78
  if (!st) return { error: 'no such device, or it gave no way back' };
64
79
  const out = await st.ask('notify', (args.object as JsonObject) ?? {});
65
80
  return isSilence(out) ? { error: 'silence' } : isWord(out) ? { error: wordOf(out) } : { pushed: out };
66
81
  }
67
82
 
83
+ // Revocation is one act at her, because only she knows both ids. A side
84
+ // that had to remove two relations by hand could leave half of one
85
+ // standing, and every side would have to remember which half.
86
+ forget(args: JsonObject) {
87
+ const c = typeof args.client === 'string' ? args.client : null;
88
+ if (c === null) return { error: 'client is a string' };
89
+ this.occupants.remove(c);
90
+ this.standings.remove(`to:${c}`);
91
+ return { forgot: c };
92
+ }
93
+
68
94
  chores() {
69
95
  return { chores: ['census', 'report'] };
70
96
  }
package/cli/daemon.ts CHANGED
@@ -102,7 +102,7 @@ export const sidePath = (dir: string) => socketPath(dir, 'side');
102
102
  // them: `local`, for a side that reached side.sock, and `web`, for a human
103
103
  // who passed the exchange pages. Both are the daemon vouching for someone it
104
104
  // 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 }>();
105
+ const NONCES = new Map<string, { kind: string; user: string; client: string; wake?: boolean; reach?: boolean }>();
106
106
  // A nonce is minted for one kind and honoured under that kind alone: a tab's
107
107
  // nonce offered as a `local` proof names no nonce, so a proof of a kind still
108
108
  // arrives only the one way that kind is made.
@@ -138,7 +138,7 @@ async function readAll(req: AsyncIterable<Buffer>): Promise<Buffer | undefined>
138
138
  // `user` and nothing is minted, so `wake`, the human's word that this
139
139
  // device may wake her others, is read the first time only; to change it,
140
140
  // 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 }> {
141
+ export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake = false, reach = false): Promise<{ avatar?: Avatar; error?: string }> {
142
142
  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
143
  const key = `avatar:${identity}`;
144
144
  let avatar = hosted.being(key) as Avatar | undefined;
@@ -148,7 +148,7 @@ export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'w
148
148
  avatar = hosted.being(key) as Avatar;
149
149
  }
150
150
  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 });
151
+ NONCES.set(nonce, { kind, user: hosted.record.user, client: identity, ...(wake ? { wake: true } : {}), ...(reach ? { reach: true } : {}) });
152
152
  const entered = await avatar.enter({ ward: hosted.pk }, { kind, nonce });
153
153
  NONCES.delete(nonce);
154
154
  await hosted.save(); // the knock went through the ward's own door, which the harbor never sees
@@ -196,30 +196,43 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
196
196
  mountQuo(harbor, http, server, quo);
197
197
  const routes = options.routes ?? (await readRoutes(harbor.dir));
198
198
  const password = options.password ?? (() => process.env.QUO_OWNER_PASSWORD);
199
- const main = harbor.wards.get('main');
200
199
  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;
200
+ // 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);
203
202
  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;
203
+ // the worlds a client may be allowed into: every ward with a public being, main first
204
+ const worlds = () =>
205
+ [...harbor.wards]
206
+ .filter(([, h]) => (h.partition as { public?: string | null }).public !== null)
207
+ .sort(([a], [b]) => (a === 'main' ? -1 : b === 'main' ? 1 : a.localeCompare(b)))
208
+ .map(([ward, h]) => ({ ward, user: h.record.user }));
209
+ // the MCP endpoint: a bearer names an identity in a world, the identity names her avatar there, the side runs beside her
210
+ mcp = new McpHttp(
211
+ async (identity, ward) => {
212
+ const hosted = harbor.wards.get(ward);
213
+ return hosted ? admit(hosted, identity, 'web') : { error: 'no such world' };
214
+ },
215
+ async () => {
216
+ for (const h of harbor.wards.values()) await h.save();
217
+ },
218
+ );
206
219
  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
- }
220
+ mcp.gone = (identity, ward) => oauth!.revoke(identity, ward); // removal at the ward ends the grant at the route
221
+ exchange = new Exchange({
222
+ oauth,
223
+ password,
224
+ worlds,
225
+ admit: async (identity, wake, reach, ward) => {
226
+ const hosted = harbor.wards.get(ward);
227
+ if (!hosted) return { error: 'no such world' };
228
+ const r = await admit(hosted, identity, 'web', wake, reach);
229
+ return r.error ? { error: r.error } : {};
230
+ },
231
+ });
219
232
  }
220
233
  const ex = exchange;
221
234
  http.mount('/web', async (req, res, rest) => {
222
- if (tab && (await tab(req, res, rest))) return;
235
+ if (await tab(req, res, rest)) return;
223
236
  if (ex && (await ex.handle(req, res, rest))) return;
224
237
  res.writeHead(404, { 'content-type': 'text/plain' });
225
238
  res.end('no such page');
@@ -419,15 +432,23 @@ async function readRoutes(dir: string): Promise<Routes | null> {
419
432
  return out;
420
433
  }
421
434
 
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.
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> {
431
452
  const tabEntry = () => {
432
453
  const js = fileURLToPath(new URL('../human/tab.js', import.meta.url));
433
454
  return existsSync(js) ? js : fileURLToPath(new URL('../human/tab.ts', import.meta.url));
@@ -435,21 +456,49 @@ function tabPages(main: Hosted, at: { quo: string; web: string }, password: () =
435
456
  let bundle: Promise<string> | undefined;
436
457
  const built = () =>
437
458
  (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>`;
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
+ };
441
478
  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;
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>'}`);
446
486
  }
447
487
  if (rest === '/tab.js' && req.method === 'GET') {
448
488
  res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
449
489
  res.end(await built());
450
490
  return true;
451
491
  }
452
- if (rest === '/tab/login' && req.method === 'POST') {
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') {
453
502
  const raw = await readAll(req as AsyncIterable<Buffer>);
454
503
  let body: { password?: unknown; identity?: unknown } = {};
455
504
  try {
@@ -470,15 +519,20 @@ function tabPages(main: Hosted, at: { quo: string; web: string }, password: () =
470
519
  return json(401, { error: 'that is not the password' });
471
520
  }
472
521
  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' });
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' });
474
523
  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 });
524
+ NONCES.set(nonce, { kind: 'tab', user: hosted.record.user, client: identity, reach: true });
525
+ return json(200, { nonce, ward: hosted.pk });
477
526
  }
478
527
  return false;
479
528
  };
480
529
  }
481
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
+
482
536
  // The quo. route. A request carries one ask to a pk; an upgrade is a held
483
537
  // socket. Bytes from here go to an own door or a held socket, never onward.
484
538
  function mountQuo(harbor: DiskHarbor, http: Http, server: HttpServer, quo: Quo): void {
@@ -571,9 +625,9 @@ async function mountOAuth(dir: string, http: Http, routes: Routes, mcp: McpHttp
571
625
  http.mount('/mcp', async (req, res, rest) => {
572
626
  if (await oauth.handle(req, res, rest)) return;
573
627
  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);
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);
577
631
  }
578
632
  res.writeHead(404, { 'content-type': 'application/json' });
579
633
  res.end(JSON.stringify({ error: 'no such route' }));
package/cli/quo.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // quo invite [--dir D] [--ward W] [--via S] <json> { being, id }
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
@@ -34,7 +35,7 @@ import { ask, side, reach, type Run } from './client.ts';
34
35
  import { pilotSide } from '../mcp/pilot.ts';
35
36
  import { estate } from './estate.ts';
36
37
 
37
- const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove']);
38
+ const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove', 'unboot']);
38
39
 
39
40
  function parse(argv: string[]) {
40
41
  const flags: Record<string, string> = {};
@@ -121,7 +122,7 @@ async function main(argv: string[]): Promise<number> {
121
122
  const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
122
123
  return failed ? 1 : 0;
123
124
  }
124
- 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]');
125
+ 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]');
125
126
  return 2;
126
127
  }
127
128
 
@@ -47,8 +47,11 @@ export class Avatar extends Being {
47
47
  return inv ?? { error: 'no invitation' };
48
48
  return this.join(inv);
49
49
  }
50
- // Knock with an invitation, hand the user being one back so she can push,
51
- // and take her as `user`. The only moment a standing is born here.
50
+ // Knock with an invitation, hand the far being one back so she can push,
51
+ // and take her as `user`. The only moment a standing is born here. A being
52
+ // who answers the hello with an error object is a being who answered: she
53
+ // is taken all the same, and the way back she did not take is dropped, so
54
+ // any world's public being may admit a guest, hello or not.
52
55
  async join(invitation) {
53
56
  if (this.standings[USER])
54
57
  return this.tools();
@@ -60,6 +63,8 @@ export class Avatar extends Being {
60
63
  this.occupants.remove(PUSHER);
61
64
  return { error: isSilence(out) ? 'silence' : wordOf(out) };
62
65
  }
66
+ if (out !== null && typeof out === 'object' && !Array.isArray(out) && 'error' in out)
67
+ this.occupants.remove(PUSHER);
63
68
  await this.take(USER, invitation);
64
69
  return this.tools();
65
70
  }
@@ -0,0 +1,10 @@
1
+ import { Being } from '@quo-systems/quo';
2
+ import type { Asker, Blueprint, JsonObject, OccupantRecord, Reply } from '@quo-systems/quo';
3
+ export declare class Carrier extends Being {
4
+ static carries(_occupant: OccupantRecord | undefined, _asker: Asker): boolean;
5
+ static hidden(id: string): boolean;
6
+ private get carried();
7
+ private get looks();
8
+ blueprint(asker: Asker): Promise<Blueprint>;
9
+ answer(asker: Asker, method?: string, args?: JsonObject): Promise<Reply>;
10
+ }
@@ -0,0 +1,106 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A carrier: a being who shows the asks of the standings she holds as asks
3
+ // of her own, and forwards. Whoever she carries for sees, in one describe,
4
+ // what she can be asked and what every being she holds can be asked, each
5
+ // under that standing's name; asking one is an ask on that standing, in her
6
+ // name, so the far being sees her and never who is behind her. A user being
7
+ // carries acme for the human's devices; a house being could carry its
8
+ // rooms; nothing here knows what she is.
9
+ //
10
+ // A carried ask is `<id>-<name>`, and the mapping back is kept in her cells
11
+ // and never parsed from the name, so an id with a dash in it is no trouble.
12
+ // The notes say which asks are whose, `standings: { id: { asks, look? } }`,
13
+ // so a screen draws one section per standing and a model side keeps the
14
+ // flat list it needs. Her own asks come first and are never shadowed: a
15
+ // carried name that collides with one of hers is dropped.
16
+ //
17
+ // She asks a standing's describe again when its digest moved, and its
18
+ // `look` once per digest, and keeps both in her cells. So a describe of
19
+ // hers may cost one ask per stale standing, which is the price of a page
20
+ // that is never more than one ask behind.
21
+ import { Being, isSilence, isWord, wordOf } from '@quo-systems/quo';
22
+ import { sanitise } from './look.js';
23
+ export class Carrier extends Being {
24
+ // Who sees what she carries. Nobody, until a subclass says who.
25
+ static carries(_occupant, _asker) {
26
+ return false;
27
+ }
28
+ // Standings she never carries: a way back to a device is one, by the
29
+ // dock's own convention, and a subclass may name more.
30
+ static hidden(id) {
31
+ return id.startsWith('to:');
32
+ }
33
+ get carried() {
34
+ return (this.cells.carried ??= {});
35
+ }
36
+ get looks() {
37
+ return (this.cells.looks ??= {});
38
+ }
39
+ // Her describe with her standings carried into it, for an asker who may
40
+ // see them. Every other asker gets her describe alone.
41
+ async blueprint(asker) {
42
+ const C = this.constructor;
43
+ const bp = this.describe(asker);
44
+ if (!C.carries(this.occupant(asker), asker))
45
+ return bp;
46
+ const own = new Set(bp.asks.map((a) => a.name));
47
+ const carried = {};
48
+ const groups = {};
49
+ for (const [id, rec] of Object.entries(this.cells.standings)) {
50
+ if (C.hidden(id))
51
+ continue;
52
+ const st = this.standings[id];
53
+ if (!st)
54
+ continue;
55
+ if (!rec.blueprint || rec.seen !== rec.digest)
56
+ await st.ask(); // her ward writes the record
57
+ if (!rec.blueprint)
58
+ continue;
59
+ const names = [];
60
+ for (const a of rec.blueprint.asks) {
61
+ if (a.name === 'look')
62
+ continue;
63
+ const name = `${id}-${a.name}`;
64
+ if (own.has(name))
65
+ continue;
66
+ carried[name] = { id, ask: a.name };
67
+ names.push(name);
68
+ bp.asks.push({ ...a, name });
69
+ }
70
+ const group = { asks: names };
71
+ if (rec.blueprint.asks.some((a) => a.name === 'look')) {
72
+ const kept = this.looks[id];
73
+ if (!kept || kept.digest !== rec.digest) {
74
+ const l = await st.ask('look');
75
+ this.looks[id] = { digest: rec.digest, look: isSilence(l) || isWord(l) ? {} : sanitise(l) };
76
+ }
77
+ group.look = this.looks[id].look;
78
+ }
79
+ groups[id] = group;
80
+ }
81
+ for (const id of Object.keys(this.looks))
82
+ if (!(id in groups))
83
+ delete this.looks[id];
84
+ this.cells.carried = carried;
85
+ const notes = bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? bp.notes : {};
86
+ return { asks: bp.asks, notes: { ...notes, standings: groups } };
87
+ }
88
+ async answer(asker, method, args = {}) {
89
+ if (method === undefined)
90
+ return this.blueprint(asker);
91
+ const C = this.constructor;
92
+ const to = Object.hasOwn(this.carried, method) ? this.carried[method] : undefined;
93
+ if (to && C.carries(this.occupant(asker), asker)) {
94
+ const st = this.standings[to.id];
95
+ if (!st)
96
+ return { error: 'unknown ask' };
97
+ const out = await st.ask(to.ask, args);
98
+ if (isSilence(out))
99
+ return out;
100
+ if (isWord(out))
101
+ return { error: wordOf(out) };
102
+ return out;
103
+ }
104
+ return super.answer(asker, method, args);
105
+ }
106
+ }
@@ -8,6 +8,7 @@ export type Verified = {
8
8
  user: string;
9
9
  client: string;
10
10
  wake?: boolean;
11
+ reach?: boolean;
11
12
  };
12
13
  export type Verifier = (proof: Proof) => Promise<Verified | null> | Verified | null;
13
14
  export declare class Desk extends Being {
@@ -33,7 +33,7 @@ export class Desk extends Being {
33
33
  const user = this.standings[`user:${who.user}`];
34
34
  if (!user)
35
35
  return { error: 'no such user' };
36
- const inv = await user.ask('device', who.wake === true ? { client: who.client, wake: true } : { client: who.client });
36
+ const inv = await user.ask('device', { client: who.client, ...(who.wake === true ? { wake: true } : {}), ...(who.reach === true ? { reach: true } : {}) });
37
37
  if (isSilence(inv))
38
38
  return { error: 'silence' };
39
39
  if (isWord(inv))
@@ -1,3 +1,5 @@
1
1
  export { User, DESK } from './user.ts';
2
2
  export { Desk, type Proof, type Verified, type Verifier } from './desk.ts';
3
3
  export { Avatar, USER, PUSHER } from './avatar.ts';
4
+ export { Carrier } from './carry.ts';
5
+ export { sanitise, hint, hintFor, groups, type Look, type Hint } from './look.ts';
@@ -3,3 +3,5 @@
3
3
  export { User, DESK } from './user.js';
4
4
  export { Desk } from './desk.js';
5
5
  export { Avatar, USER, PUSHER } from './avatar.js';
6
+ export { Carrier } from './carry.js';
7
+ export { sanitise, hint, hintFor, groups } from './look.js';
@@ -0,0 +1,7 @@
1
+ import type { Invitation } from '@quo-systems/quo';
2
+ export declare const KEY = "quo";
3
+ export declare function format(inv: Invitation): string;
4
+ export declare function parse(hash: string): Invitation | null;
5
+ export declare function strip(hash: string): string;
6
+ export declare function link(page: string, inv: Invitation): string;
7
+ export declare function isInvitation(v: unknown): v is Invitation;
@@ -0,0 +1,42 @@
1
+ export const KEY = 'quo';
2
+ const HEX = (n) => `[0-9a-f]{${n}}`;
3
+ const FULL = new RegExp(`^(${HEX(128)})\\.(${HEX(64)})\\.(${HEX(64)})$`);
4
+ const PUBLIC = new RegExp(`^${HEX(128)}$`);
5
+ export function format(inv) {
6
+ return inv.heir && inv.secret ? `${inv.ward}.${inv.heir}.${inv.secret}` : inv.ward;
7
+ }
8
+ // The invitation in a fragment, or null. Anything else in the fragment is
9
+ // the page's own and is left alone.
10
+ export function parse(hash) {
11
+ const m = /(?:^#|&)quo=([^&]+)/.exec(hash.startsWith('#') ? hash : `#${hash}`);
12
+ if (!m)
13
+ return null;
14
+ const v = decodeURIComponent(m[1]);
15
+ const full = FULL.exec(v);
16
+ if (full)
17
+ return { ward: full[1], heir: full[2], secret: full[3] };
18
+ return PUBLIC.test(v) ? { ward: v } : null;
19
+ }
20
+ // The fragment without the invitation, so the page keeps whatever else it
21
+ // put there and the capability is gone from the address bar.
22
+ export function strip(hash) {
23
+ const rest = (hash.startsWith('#') ? hash.slice(1) : hash)
24
+ .split('&')
25
+ .filter((p) => !p.startsWith(`${KEY}=`))
26
+ .join('&');
27
+ return rest ? `#${rest}` : '';
28
+ }
29
+ export function link(page, inv) {
30
+ return `${page}#${KEY}=${format(inv)}`;
31
+ }
32
+ // Whether an answer is an invitation: a ward pk, and a heir with its secret
33
+ // or neither. What a guest's form answers with to be let in.
34
+ export function isInvitation(v) {
35
+ if (v === null || typeof v !== 'object' || Array.isArray(v))
36
+ return false;
37
+ const o = v;
38
+ if (typeof o.ward !== 'string' || !PUBLIC.test(o.ward))
39
+ return false;
40
+ const heir = typeof o.heir === 'string', secret = typeof o.secret === 'string';
41
+ return (heir && secret) || (!heir && !secret && !('heir' in o) && !('secret' in o));
42
+ }
@@ -0,0 +1,27 @@
1
+ import type { Blueprint, Json } from '@quo-systems/quo';
2
+ export type Hint = {
3
+ title?: string;
4
+ readOnly?: boolean;
5
+ destructive?: boolean;
6
+ idempotent?: boolean;
7
+ icon?: string;
8
+ };
9
+ export type Look = {
10
+ name?: string;
11
+ logo?: string;
12
+ accent?: string;
13
+ background?: string;
14
+ foreground?: string;
15
+ font?: string;
16
+ radius?: number;
17
+ order?: string[];
18
+ asks?: Record<string, Hint>;
19
+ };
20
+ export declare function sanitise(v: Json | undefined): Look;
21
+ export declare const hint: (l: Look | undefined, name: string) => Hint;
22
+ export type Group = {
23
+ asks?: string[];
24
+ look?: Json;
25
+ };
26
+ export declare function groups(bp: Blueprint | null): Record<string, Group>;
27
+ export declare function hintFor(bp: Blueprint | null, mine: Look, name: string): Hint;