@quo-systems/dock 0.1.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 (72) hide show
  1. package/README.md +9 -2
  2. package/beings/avatar.ts +6 -2
  3. package/beings/carry.ts +101 -0
  4. package/beings/desk.ts +2 -2
  5. package/beings/index.ts +2 -0
  6. package/beings/link.ts +54 -0
  7. package/beings/look.ts +96 -0
  8. package/beings/quo-dock.md +185 -38
  9. package/beings/setup.ts +3 -1
  10. package/beings/side.ts +10 -1
  11. package/beings/user.ts +31 -5
  12. package/cli/daemon.ts +98 -44
  13. package/cli/estate/Caddyfile +25 -0
  14. package/cli/estate/quo.service +36 -0
  15. package/cli/estate.ts +44 -0
  16. package/cli/quo.ts +14 -3
  17. package/dist/beings/avatar.js +7 -2
  18. package/dist/beings/carry.d.ts +10 -0
  19. package/dist/beings/carry.js +106 -0
  20. package/dist/beings/desk.d.ts +1 -0
  21. package/dist/beings/desk.js +1 -1
  22. package/dist/beings/index.d.ts +2 -0
  23. package/dist/beings/index.js +2 -0
  24. package/dist/beings/link.d.ts +7 -0
  25. package/dist/beings/link.js +42 -0
  26. package/dist/beings/look.d.ts +27 -0
  27. package/dist/beings/look.js +71 -0
  28. package/dist/beings/setup.js +4 -1
  29. package/dist/beings/side.d.ts +8 -1
  30. package/dist/beings/user.d.ts +26 -2
  31. package/dist/beings/user.js +32 -5
  32. package/dist/cli/daemon.d.ts +1 -1
  33. package/dist/cli/daemon.js +98 -44
  34. package/dist/cli/estate/Caddyfile +25 -0
  35. package/dist/cli/estate/quo.service +36 -0
  36. package/dist/cli/estate.d.ts +5 -0
  37. package/dist/cli/estate.js +46 -0
  38. package/dist/cli/quo.js +14 -3
  39. package/dist/harbor/edge/exercise.js +3 -1
  40. package/dist/harbor/edge/platform.d.ts +28 -0
  41. package/dist/human/guest.d.ts +3 -0
  42. package/dist/human/guest.js +25 -0
  43. package/dist/human/html.d.ts +10 -2
  44. package/dist/human/html.js +61 -10
  45. package/dist/human/screen.d.ts +8 -3
  46. package/dist/human/screen.js +25 -6
  47. package/dist/human/tab.d.ts +2 -0
  48. package/dist/human/tab.js +127 -42
  49. package/dist/mcp/http.d.ts +4 -3
  50. package/dist/mcp/http.js +6 -6
  51. package/dist/mcp/oauth.d.ts +11 -4
  52. package/dist/mcp/oauth.js +32 -21
  53. package/dist/mcp/pilot.d.ts +3 -4
  54. package/dist/mcp/pilot.js +19 -65
  55. package/dist/mcp/server.d.ts +6 -4
  56. package/dist/mcp/server.js +48 -11
  57. package/dist/mcp/web/exchange.d.ts +5 -2
  58. package/dist/mcp/web/exchange.js +21 -7
  59. package/harbor/edge/exercise.ts +2 -1
  60. package/harbor/quo-harbor.md +37 -0
  61. package/human/guest.ts +26 -0
  62. package/human/html.ts +58 -10
  63. package/human/quo-human.md +123 -66
  64. package/human/screen.ts +28 -7
  65. package/human/tab.ts +153 -51
  66. package/mcp/http.ts +10 -9
  67. package/mcp/oauth.ts +39 -23
  68. package/mcp/pilot.ts +26 -65
  69. package/mcp/quo-mcp.md +34 -19
  70. package/mcp/server.ts +52 -19
  71. package/mcp/web/exchange.ts +23 -9
  72. package/package.json +7 -3
@@ -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;
@@ -0,0 +1,71 @@
1
+ const COLOUR = /^#[0-9a-fA-F]{3,8}$/;
2
+ const FONT = /^[\w\s,'"-]{1,80}$/;
3
+ const LOGO = /^data:image\/(svg\+xml|png|jpeg|webp);base64,[A-Za-z0-9+/=]{1,40000}$/;
4
+ const NAME = /^[^<>&]{1,60}$/;
5
+ const ICON = /^[^<>&"']{1,8}$/;
6
+ const str = (v, re) => (typeof v === 'string' && re.test(v) ? v : undefined);
7
+ // The look as a side may use it: every token held to its shape, everything
8
+ // else gone. A value that is not an object is no look at all.
9
+ export function sanitise(v) {
10
+ if (v === null || v === undefined || typeof v !== 'object' || Array.isArray(v))
11
+ return {};
12
+ const l = {};
13
+ const name = str(v.name, NAME);
14
+ if (name !== undefined)
15
+ l.name = name;
16
+ const logo = str(v.logo, LOGO);
17
+ if (logo !== undefined)
18
+ l.logo = logo;
19
+ for (const k of ['accent', 'background', 'foreground']) {
20
+ const c = str(v[k], COLOUR);
21
+ if (c !== undefined)
22
+ l[k] = c;
23
+ }
24
+ const font = str(v.font, FONT);
25
+ if (font !== undefined)
26
+ l.font = font;
27
+ if (typeof v.radius === 'number' && Number.isFinite(v.radius) && v.radius >= 0 && v.radius <= 40)
28
+ l.radius = Math.round(v.radius);
29
+ if (Array.isArray(v.order))
30
+ l.order = v.order.filter((x) => typeof x === 'string');
31
+ if (v.asks !== null && typeof v.asks === 'object' && !Array.isArray(v.asks)) {
32
+ const asks = {};
33
+ for (const [ask, h] of Object.entries(v.asks)) {
34
+ if (h === null || typeof h !== 'object' || Array.isArray(h))
35
+ continue;
36
+ const hint = {};
37
+ const title = str(h.title, NAME);
38
+ if (title !== undefined)
39
+ hint.title = title;
40
+ const icon = str(h.icon, ICON);
41
+ if (icon !== undefined)
42
+ hint.icon = icon;
43
+ for (const k of ['readOnly', 'destructive', 'idempotent'])
44
+ if (typeof h[k] === 'boolean')
45
+ hint[k] = h[k];
46
+ if (Object.keys(hint).length)
47
+ asks[ask] = hint;
48
+ }
49
+ if (Object.keys(asks).length)
50
+ l.asks = asks;
51
+ }
52
+ return l;
53
+ }
54
+ // The one hint a side reads first: whether this ask is in the look at all.
55
+ export const hint = (l, name) => l?.asks?.[name] ?? {};
56
+ export function groups(bp) {
57
+ const n = bp?.notes;
58
+ const g = n !== null && n !== undefined && typeof n === 'object' && !Array.isArray(n) ? n.standings : undefined;
59
+ return g !== null && g !== undefined && typeof g === 'object' && !Array.isArray(g) ? g : {};
60
+ }
61
+ // The hint for one ask, hers or a standing's: a carried ask is looked up
62
+ // under its bare name in that standing's look.
63
+ export function hintFor(bp, mine, name) {
64
+ for (const [id, g] of Object.entries(groups(bp))) {
65
+ if (!g.asks?.includes(name))
66
+ continue;
67
+ const prefix = `${id}-`;
68
+ return hint(sanitise(g.look), name.startsWith(prefix) ? name.slice(prefix.length) : name);
69
+ }
70
+ return hint(mine, name);
71
+ }
@@ -7,7 +7,10 @@ export async function setup(hosted, user) {
7
7
  if (out.error)
8
8
  throw new Error(`init: ${out.error}`);
9
9
  };
10
- await boot({ key: 'desk', class: 'Desk', public: true });
10
+ await boot({ key: 'desk', class: 'Desk' });
11
+ const pub = (await hosted.ask('public', { key: 'desk' }));
12
+ if (pub.error)
13
+ throw new Error(`init: ${pub.error}`);
11
14
  await boot({ key: user, class: 'User' });
12
15
  const inv = (await hosted.ask('invite', { being: user, id: DESK }));
13
16
  const placed = (await hosted.ask('knock', { being: 'desk', id: `user:${user}`, invitation: inv, method: 'hello' }));
@@ -1,9 +1,16 @@
1
- import type { Answer, Json, JsonObject, WordName } from '@quo-systems/quo';
1
+ import type { Answer, Blueprint, Json, JsonObject, Wanted, WordName } from '@quo-systems/quo';
2
2
  import type { Avatar } from './avatar.ts';
3
3
  export type Serving = {
4
4
  close(): Promise<void>;
5
5
  };
6
6
  export type Side = (avatar: Avatar) => Promise<Serving>;
7
+ export type Subject = {
8
+ tools(): Promise<Blueprint | {
9
+ error: string;
10
+ }>;
11
+ call(name: string, args?: JsonObject, wanted?: Wanted): Promise<Answer>;
12
+ ears: Set<(object: JsonObject) => void>;
13
+ };
7
14
  export type Word = {
8
15
  word: 'object';
9
16
  value: Json;
@@ -1,10 +1,11 @@
1
- import { Being } from '@quo-systems/quo';
2
1
  import type { Asker, JsonObject, OccupantRecord } from '@quo-systems/quo';
2
+ import { Carrier } from './carry.ts';
3
3
  export declare const DESK = "desk";
4
4
  declare const isDesk: (occ: OccupantRecord | undefined) => boolean;
5
5
  declare const isDevice: (occ: OccupantRecord | undefined) => boolean;
6
6
  declare const mayWake: (occ: OccupantRecord | undefined) => boolean;
7
- export declare class User extends Being {
7
+ export declare class User extends Carrier {
8
+ static carries(occ: OccupantRecord | undefined): boolean;
8
9
  static cells: {
9
10
  name: string;
10
11
  reports: JsonObject[];
@@ -39,6 +40,9 @@ export declare class User extends Being {
39
40
  wake: {
40
41
  type: string;
41
42
  };
43
+ reach: {
44
+ type: string;
45
+ };
42
46
  };
43
47
  required: string[];
44
48
  };
@@ -67,6 +71,19 @@ export declare class User extends Being {
67
71
  };
68
72
  for: (occ: OccupantRecord | undefined) => boolean;
69
73
  };
74
+ forget: {
75
+ description: string;
76
+ input: {
77
+ type: string;
78
+ properties: {
79
+ client: {
80
+ type: string;
81
+ };
82
+ };
83
+ required: string[];
84
+ };
85
+ for: typeof isDesk;
86
+ };
70
87
  report: {
71
88
  description: string;
72
89
  input: {
@@ -98,6 +115,13 @@ export declare class User extends Being {
98
115
  error?: undefined;
99
116
  pushed: import("@quo-systems/quo").Json;
100
117
  }>;
118
+ forget(args: JsonObject): {
119
+ error: string;
120
+ forgot?: undefined;
121
+ } | {
122
+ error?: undefined;
123
+ forgot: string;
124
+ };
101
125
  chores(): {
102
126
  chores: string[];
103
127
  };
@@ -5,21 +5,30 @@
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
+ import { Carrier } from './carry.js';
9
10
  export const DESK = 'desk';
10
11
  const isDesk = (occ) => occ?.id === DESK;
11
12
  const isDevice = (occ) => occ !== undefined && occ.id !== DESK;
12
13
  const client = (occ) => (typeof occ?.notes.client === 'string' ? occ.notes.client : null);
13
14
  // A device the human allowed to wake her other devices: the note says so.
14
15
  const mayWake = (occ) => isDesk(occ) || occ?.notes.wake === true;
15
- export class User extends Being {
16
+ // She carries her standings, acme, the calendar, the house, for a device the
17
+ // human allowed to reach them at the exchange: the note says so. A device
18
+ // without the note sees her own asks alone, and a model sees acme only
19
+ // because the human said it may.
20
+ export class User extends Carrier {
21
+ static carries(occ) {
22
+ return isDevice(occ) && occ?.notes.reach === true;
23
+ }
16
24
  static cells = { name: '', reports: [] };
17
25
  static asks = {
18
26
  hello: { description: 'say hello, and hand back an invitation so she can reach you', input: { type: 'object', properties: { invitation: { type: 'object' } } } },
19
27
  whoami: { description: 'who she thinks you are', input: { type: 'object' }, for: isDevice },
20
- device: { description: 'mint an invitation for a device', input: { type: 'object', properties: { client: { type: 'string' }, wake: { type: 'boolean' } }, required: ['client'] }, for: isDesk },
28
+ 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 },
21
29
  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 },
22
30
  chores: { description: 'what the agent may run', input: { type: 'object' }, for: (occ) => client(occ) === 'agent' },
31
+ 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 },
23
32
  report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
24
33
  };
25
34
  // Anyone may say hello. A device that hands her an invitation in the args
@@ -38,7 +47,8 @@ export class User extends Being {
38
47
  }
39
48
  // The front desk asks; she mints. The client identity goes into the
40
49
  // occupant's notes, and that is what every gate reads. `wake` is the
41
- // human's word at the exchange that this device may wake her others.
50
+ // human's word at the exchange that this device may wake her others, and
51
+ // `reach` that it may see and ask what she holds: her standings, carried.
42
52
  async device(args) {
43
53
  const c = typeof args.client === 'string' ? args.client : null;
44
54
  if (c === null)
@@ -51,17 +61,34 @@ export class User extends Being {
51
61
  rec.notes.client = c;
52
62
  if (args.wake === true)
53
63
  rec.notes.wake = true;
64
+ if (args.reach === true)
65
+ rec.notes.reach = true;
54
66
  }
55
67
  return inv;
56
68
  }
69
+ // Her way out and the device's way in are two relations, and Quo keeps
70
+ // them apart. She joins them herself, here, as her own rule: she does
71
+ // not reach a device she no longer admits. Without it a revoked device
72
+ // stops being able to ask her and keeps receiving everything she pushes.
57
73
  async push(args) {
58
74
  const c = typeof args.client === 'string' ? args.client : null;
59
- const st = c === null ? undefined : this.standings[`to:${c}`];
75
+ const st = c === null || !this.cells.occupants[c] ? undefined : this.standings[`to:${c}`];
60
76
  if (!st)
61
77
  return { error: 'no such device, or it gave no way back' };
62
78
  const out = await st.ask('notify', args.object ?? {});
63
79
  return isSilence(out) ? { error: 'silence' } : isWord(out) ? { error: wordOf(out) } : { pushed: out };
64
80
  }
81
+ // Revocation is one act at her, because only she knows both ids. A side
82
+ // that had to remove two relations by hand could leave half of one
83
+ // standing, and every side would have to remember which half.
84
+ forget(args) {
85
+ const c = typeof args.client === 'string' ? args.client : null;
86
+ if (c === null)
87
+ return { error: 'client is a string' };
88
+ this.occupants.remove(c);
89
+ this.standings.remove(`to:${c}`);
90
+ return { forgot: c };
91
+ }
65
92
  chores() {
66
93
  return { chores: ['census', 'report'] };
67
94
  }
@@ -50,7 +50,7 @@ export type Options = {
50
50
  };
51
51
  export declare const sockPath: (dir: string) => string;
52
52
  export declare const sidePath: (dir: string) => string;
53
- export declare function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake?: boolean): Promise<{
53
+ export declare function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake?: boolean, reach?: boolean): Promise<{
54
54
  avatar?: Avatar;
55
55
  error?: string;
56
56
  }>;
@@ -121,7 +121,7 @@ async function readAll(req) {
121
121
  // `user` and nothing is minted, so `wake`, the human's word that this
122
122
  // device may wake her others, is read the first time only; to change it,
123
123
  // remove the occupant and allow again. The one path for every side.
124
- export async function admit(hosted, identity, kind, wake = false) {
124
+ export async function admit(hosted, identity, kind, wake = false, reach = false) {
125
125
  if (!/^[\w.-]+$/.test(identity) || identity === hosted.record.user || identity === 'desk')
126
126
  return { error: 'an identity is a word, and not a being of the ward' };
127
127
  const key = `avatar:${identity}`;
@@ -133,7 +133,7 @@ export async function admit(hosted, identity, kind, wake = false) {
133
133
  avatar = hosted.being(key);
134
134
  }
135
135
  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 });
136
+ NONCES.set(nonce, { kind, user: hosted.record.user, client: identity, ...(wake ? { wake: true } : {}), ...(reach ? { reach: true } : {}) });
137
137
  const entered = await avatar.enter({ ward: hosted.pk }, { kind, nonce });
138
138
  NONCES.delete(nonce);
139
139
  await hosted.save(); // the knock went through the ward's own door, which the harbor never sees
@@ -178,31 +178,41 @@ export async function serve(dir, options = {}) {
178
178
  mountQuo(harbor, http, server, quo);
179
179
  const routes = options.routes ?? (await readRoutes(harbor.dir));
180
180
  const password = options.password ?? (() => process.env.QUO_OWNER_PASSWORD);
181
- const main = harbor.wards.get('main');
182
181
  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;
182
+ // the worlds' pages: always, on the daemon's own door when no route names a public one
183
+ const tab = worldPages(harbor, { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` }, password);
185
184
  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;
185
+ // the worlds a client may be allowed into: every ward with a public being, main first
186
+ const worlds = () => [...harbor.wards]
187
+ .filter(([, h]) => h.partition.public !== null)
188
+ .sort(([a], [b]) => (a === 'main' ? -1 : b === 'main' ? 1 : a.localeCompare(b)))
189
+ .map(([ward, h]) => ({ ward, user: h.record.user }));
190
+ // the MCP endpoint: a bearer names an identity in a world, the identity names her avatar there, the side runs beside her
191
+ mcp = new McpHttp(async (identity, ward) => {
192
+ const hosted = harbor.wards.get(ward);
193
+ return hosted ? admit(hosted, identity, 'web') : { error: 'no such world' };
194
+ }, async () => {
195
+ for (const h of harbor.wards.values())
196
+ await h.save();
197
+ });
188
198
  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
- }
199
+ mcp.gone = (identity, ward) => oauth.revoke(identity, ward); // removal at the ward ends the grant at the route
200
+ exchange = new Exchange({
201
+ oauth,
202
+ password,
203
+ worlds,
204
+ admit: async (identity, wake, reach, ward) => {
205
+ const hosted = harbor.wards.get(ward);
206
+ if (!hosted)
207
+ return { error: 'no such world' };
208
+ const r = await admit(hosted, identity, 'web', wake, reach);
209
+ return r.error ? { error: r.error } : {};
210
+ },
211
+ });
202
212
  }
203
213
  const ex = exchange;
204
214
  http.mount('/web', async (req, res, rest) => {
205
- if (tab && (await tab(req, res, rest)))
215
+ if (await tab(req, res, rest))
206
216
  return;
207
217
  if (ex && (await ex.handle(req, res, rest)))
208
218
  return;
@@ -419,36 +429,76 @@ async function readRoutes(dir) {
419
429
  out.quo = r.quo.replace(/\/$/, '');
420
430
  return out;
421
431
  }
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.
432
+ // The worlds on the web route. A world is a ward with a public being, and
433
+ // its address is `/web/<ward>`: the tab page, `human/tab.ts`, told which
434
+ // ward and which pk, so that its guest is that ward's public being rendered
435
+ // by the screen, whatever class she is. `/web/` lists the worlds. The bundle
436
+ // is built once from the source beside this file, `.ts` in the tree, `.js`
437
+ // once emitted into the package's dist. `/web/<ward>/login` is the one call
438
+ // of the dock's own way in a tab makes: the owner password for a nonce the
439
+ // desk honours under `tab`. The avatar that knocks with it lives in the tab,
440
+ // not here, so nothing is admitted on this side.
441
+ //
442
+ // The page carries a content security policy: scripts from this origin
443
+ // only and never inline, connections to this origin and the quo. route the
444
+ // tab dials, images from data URIs and this origin, and nothing else. So
445
+ // even a bug in a renderer cannot become a script, and no look can reach a
446
+ // server. The config crosses in a JSON script, which the policy allows.
447
+ const RESERVED_PATHS = new Set(['login', 'allow', 'tab.js']);
448
+ function worldPages(harbor, at, password) {
431
449
  const tabEntry = () => {
432
450
  const js = fileURLToPath(new URL('../human/tab.js', import.meta.url));
433
451
  return existsSync(js) ? js : fileURLToPath(new URL('../human/tab.ts', import.meta.url));
434
452
  };
435
453
  let bundle;
436
454
  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>`;
455
+ const quoOrigin = (() => {
456
+ try {
457
+ const u = new URL(at.quo);
458
+ return `${u.origin} ${u.origin.replace(/^http/, 'ws')}`;
459
+ }
460
+ catch {
461
+ return '';
462
+ }
463
+ })();
464
+ const policy = `default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ${quoOrigin}; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`;
465
+ const shell = (body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
466
+ <style>${CSS}</style>
467
+ </head><body>${body}</body></html>`;
468
+ const esc = (v) => v.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
469
+ const publicOf = (h) => h.partition.public ?? null;
470
+ const html = (status, res, body) => {
471
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'no-referrer' });
472
+ res.end(shell(body));
473
+ return true;
474
+ };
440
475
  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;
476
+ const parts = rest.split('/').filter(Boolean);
477
+ if (req.method === 'GET' && (rest === '' || rest === '/')) {
478
+ const list = [...harbor.wards]
479
+ .filter(([, h]) => publicOf(h) !== null)
480
+ .map(([n, h]) => `<li><a href="${at.web}/${encodeURIComponent(n)}">${esc(n)}</a> <small>${esc(publicOf(h) ?? '')} at the door, <code>${h.pk.slice(0, 16)}…</code></small></li>`)
481
+ .join('');
482
+ return html(200, res, `<h1>worlds</h1>${list ? `<ul>${list}</ul>` : '<p>no ward here has a public being</p>'}`);
445
483
  }
446
484
  if (rest === '/tab.js' && req.method === 'GET') {
447
485
  res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
448
486
  res.end(await built());
449
487
  return true;
450
488
  }
451
- if (rest === '/tab/login' && req.method === 'POST') {
489
+ const wardName = parts[0] ?? '';
490
+ if (!wardName || RESERVED_PATHS.has(wardName))
491
+ return false;
492
+ const hosted = harbor.wards.get(wardName);
493
+ if (!hosted)
494
+ return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
495
+ if (parts.length === 1 && req.method === 'GET') {
496
+ if (publicOf(hosted) === null)
497
+ return html(404, res, `<h1>not a world</h1><p>ward ${esc(wardName)} has no public being, so nobody is at its door.</p>`);
498
+ const cfg = { quo: at.quo, web: at.web, ward: wardName, pk: hosted.pk };
499
+ return html(200, res, `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${at.web}/tab.js"></script>`);
500
+ }
501
+ if (parts.length === 2 && parts[1] === 'login' && req.method === 'POST') {
452
502
  const raw = await readAll(req);
453
503
  let body = {};
454
504
  try {
@@ -471,15 +521,19 @@ function tabPages(main, at, password) {
471
521
  return json(401, { error: 'that is not the password' });
472
522
  }
473
523
  const identity = typeof body.identity === 'string' ? body.identity : '';
474
- if (!/^[\w.-]{1,40}$/.test(identity) || identity === main.record.user || identity === 'desk')
524
+ if (!/^[\w.-]{1,40}$/.test(identity) || identity === hosted.record.user || identity === 'desk')
475
525
  return json(400, { error: 'an identity is one word, and not the user or the desk' });
476
526
  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 });
527
+ NONCES.set(nonce, { kind: 'tab', user: hosted.record.user, client: identity, reach: true });
528
+ return json(200, { nonce, ward: hosted.pk });
479
529
  }
480
530
  return false;
481
531
  };
482
532
  }
533
+ // The tab's stylesheet: one, light and dark, honouring the variables a look
534
+ // sets on a section. The page owns layout; a far being paints inside her
535
+ // section and nowhere else.
536
+ const CSS = ":root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px}body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}header,main{background:var(--bg);color:var(--fg);font-family:var(--font)}header{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}header .notice{width:100%;margin:0}input,select,textarea,button{font:inherit;padding:.4rem;margin:.2rem;border-radius:var(--radius)}button{background:var(--accent);color:#fff;border:0;padding:.4rem .9rem}fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}section.standing{background:var(--bg);color:var(--fg);font-family:var(--font);border-left:4px solid var(--accent);border-radius:var(--radius);padding:.25rem 1rem;margin:1.5rem 0}section.standing h2{display:flex;align-items:center;gap:.5rem;font-size:1.1rem}img.logo{height:1.6rem}table{border-collapse:collapse}td,th{padding:.15rem .5rem;text-align:left}.answer{margin:.5rem 0 1rem;padding:.5rem;border-left:3px solid var(--accent)}.answer.error{border-color:#c33}.answer.silence,.answer.word,.answer.unreached{border-color:#c93}pre{padding:.75rem;overflow:auto}";
483
537
  // The quo. route. A request carries one ask to a pk; an upgrade is a held
484
538
  // socket. Bytes from here go to an own door or a held socket, never onward.
485
539
  function mountQuo(harbor, http, server, quo) {
@@ -569,10 +623,10 @@ async function mountOAuth(dir, http, routes, mcp) {
569
623
  if (await oauth.handle(req, res, rest))
570
624
  return;
571
625
  if (rest === '/mcp') {
572
- const identity = oauth.bearer(req);
573
- if (identity === null || !mcp)
626
+ const grant = oauth.bearer(req);
627
+ if (grant === null || !mcp)
574
628
  return oauth.challenge(res);
575
- return mcp.handle(req, res, identity);
629
+ return mcp.handle(req, res, grant.identity, grant.ward);
576
630
  }
577
631
  res.writeHead(404, { 'content-type': 'application/json' });
578
632
  res.end(JSON.stringify({ error: 'no such route' }));
@@ -0,0 +1,25 @@
1
+ # Three hostnames, one daemon, one loopback port. Caddy faces the world and
2
+ # terminates TLS; the daemon never does. Each hostname is a route, and a
3
+ # route is a path on the daemon's HTTP door. Replace DOMAIN.
4
+ #
5
+ # mcp. the model side: streamable HTTP MCP, with the credential exchange in front
6
+ # web. the bundle for plain tabs, and the exchange pages
7
+ # quo. the socket door for other harbors: requests in, sockets held, the rendezvous
8
+ #
9
+ # There is no cli. route. The root owner is the unix socket, reached on the
10
+ # device or over SSH, and never through here.
11
+
12
+ mcp.DOMAIN {
13
+ reverse_proxy 127.0.0.1:8787
14
+ rewrite * /mcp{uri}
15
+ }
16
+
17
+ web.DOMAIN {
18
+ reverse_proxy 127.0.0.1:8787
19
+ rewrite * /web{uri}
20
+ }
21
+
22
+ quo.DOMAIN {
23
+ reverse_proxy 127.0.0.1:8787
24
+ rewrite * /quo{uri}
25
+ }
@@ -0,0 +1,36 @@
1
+ # The daemon on a droplet, as one user, forever. Install with:
2
+ # sudo cp droplet/quo.service /etc/systemd/system/quo.service
3
+ # sudo systemctl enable --now quo
4
+ # The unit runs as the `quo` user, whose home holds the estate folder and
5
+ # the harbor folder, so the seed, the lease and the sockets are that user's
6
+ # alone. The HTTP door is loopback only; Caddy fronts it. See Caddyfile
7
+ # beside this file.
8
+ [Unit]
9
+ Description=quo: one harbor on this device
10
+ After=network.target
11
+
12
+ [Service]
13
+ User=quo
14
+ Group=quo
15
+ Environment=QUO_DIR=/home/quo/.quo
16
+ Environment=QUO_HTTP=8787
17
+ # QUO_OWNER_PASSWORD, root-only. Without it the exchange pages are closed.
18
+ # CLAUDE_CODE_OAUTH_TOKEN, when an agent folder runs `claude -p`: the agent
19
+ # reads its credential from the device, never from cells.
20
+ EnvironmentFile=-/etc/quo/env
21
+ WorkingDirectory=/home/quo/ESTATE
22
+ ExecStart=/usr/bin/node /home/quo/ESTATE/node_modules/@quo-systems/dock/dist/cli/quo.js serve
23
+ Restart=always
24
+ RestartSec=2
25
+ KillSignal=SIGTERM
26
+ TimeoutStopSec=10
27
+ NoNewPrivileges=true
28
+ ProtectSystem=strict
29
+ # Agents run in their folders and Claude Code keeps its own state in the
30
+ # user's home; both must be writable for a run to happen, and a harbor
31
+ # with no agent has neither.
32
+ ReadWritePaths=/home/quo/.quo -/home/quo/agents -/home/quo/.claude -/home/quo/.claude.json /tmp
33
+ PrivateTmp=false
34
+
35
+ [Install]
36
+ WantedBy=multi-user.target
@@ -0,0 +1,5 @@
1
+ export declare function estate(dir: string, domain: string): Promise<{
2
+ dir: string;
3
+ name: string;
4
+ files: string[];
5
+ }>;
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // `quo estate DIR --domain D`: an estate folder, written once, of the shape
3
+ // every estate has. One folder per harbor, here the first, `droplet/`: the
4
+ // quo directory that device runs minus what it mints, its routes under the
5
+ // domain, no agents, no classes of its own, and that device's unit and
6
+ // Caddyfile. A package file that depends on this dock and nothing else, and
7
+ // one document to fill in. An estate needs nothing the dock does not give
8
+ // it, and this is the dock giving it.
9
+ import { existsSync } from 'node:fs';
10
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
11
+ import { basename, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ // This package's own version, read beside the emitted or the source tree.
14
+ async function version() {
15
+ for (const rel of ['../package.json', '../../package.json']) {
16
+ const at = fileURLToPath(new URL(rel, import.meta.url));
17
+ if (!existsSync(at))
18
+ continue;
19
+ const pkg = JSON.parse(await readFile(at, 'utf8'));
20
+ if (pkg.name === '@quo-systems/dock' && pkg.version)
21
+ return pkg.version;
22
+ }
23
+ return '0.1.0';
24
+ }
25
+ const template = (name) => readFile(fileURLToPath(new URL(`estate/${name}`, import.meta.url)), 'utf8');
26
+ export async function estate(dir, domain) {
27
+ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain))
28
+ throw new Error(`estate: ${domain} is not a domain`);
29
+ if (existsSync(join(dir, 'package.json')))
30
+ throw new Error(`estate: ${dir} already holds a package`);
31
+ const name = basename(dir);
32
+ const files = {
33
+ 'package.json': JSON.stringify({ name, private: true, description: `The ${name} estate: one owner's harbors, built on the dock and nothing else.`, type: 'module', dependencies: { '@quo-systems/dock': `^${await version()}` } }, null, 2) + '\n',
34
+ [`${name}.md`]: `# ${name}\n\nAn estate: all of one owner's harbors. One folder per harbor, each the quo\ndirectory that device runs minus what it mints, plus that device's unit.\n\`droplet/\` is the first: its routes under \`${domain}\`, its agents, its\nclasses, its systemd unit and its Caddyfile. This folder depends on the\ndock and nothing else.\n\n## The droplet\n\nWhere it is, how it is reached, and what was done to stand it up: yours\nto write.\n`,
35
+ 'droplet/routes.json': JSON.stringify({ mcp: `https://mcp.${domain}`, web: `https://web.${domain}`, quo: `https://quo.${domain}` }) + '\n',
36
+ 'droplet/agents.json': '{}\n',
37
+ 'droplet/classes/index.ts': '// The classes this harbor holds beside the dock\'s built-in ones, exported\n// by name.\nexport {};\n',
38
+ 'droplet/quo.service': (await template('quo.service')).replaceAll('ESTATE', name),
39
+ 'droplet/Caddyfile': (await template('Caddyfile')).replaceAll('DOMAIN', domain),
40
+ };
41
+ for (const [rel, text] of Object.entries(files)) {
42
+ await mkdir(join(dir, rel, '..'), { recursive: true });
43
+ await writeFile(join(dir, rel), text);
44
+ }
45
+ return { dir, name, files: Object.keys(files) };
46
+ }