@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
package/dist/cli/quo.js CHANGED
@@ -11,10 +11,12 @@
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
17
18
  // quo reach [--dir D] <pk> <url> a hint for the harbor's directory: that ward pk lives at that quo. route
19
+ // quo estate <dir> --domain D an estate folder of the shape every estate has: a droplet's quo directory, unit and Caddyfile, on this dock
18
20
  //
19
21
  // D defaults to $QUO_DIR, then ~/.quo. W defaults to main. init takes
20
22
  // --user NAME for the user being, defaulting to the device's user. run
@@ -31,7 +33,8 @@ import { DiskHarbor } from '../harbor/disk.js';
31
33
  import { serve } from './daemon.js';
32
34
  import { ask, side, reach } from './client.js';
33
35
  import { pilotSide } from '../mcp/pilot.js';
34
- const OWNER_ASKS = new Set(['boot', 'invite', 'knock', 'remove']);
36
+ import { estate } from './estate.js';
37
+ const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove', 'unboot']);
35
38
  function parse(argv) {
36
39
  const flags = {};
37
40
  const rest = [];
@@ -78,7 +81,7 @@ async function main(argv) {
78
81
  }
79
82
  if (cmd === 'pilot') {
80
83
  await ask(dir, undefined, {}, ward); // no daemon is a clear word now, not at the first tool call
81
- const serving = await pilotSide(dir, ward, new StdioServerTransport(), (line) => console.error(`pilot ${line}`), via);
84
+ const serving = await pilotSide(dir, ward, new StdioServerTransport(), via);
82
85
  process.stdin.once('end', () => void serving.close().then(() => process.exit(0)));
83
86
  return -1;
84
87
  }
@@ -91,6 +94,14 @@ async function main(argv) {
91
94
  process.once('SIGTERM', stop);
92
95
  return -1; // stays up
93
96
  }
97
+ if (cmd === 'estate') {
98
+ if (!json || !flags.domain) {
99
+ console.error('quo estate needs <dir> --domain D');
100
+ return 2;
101
+ }
102
+ print(await estate(json, flags.domain));
103
+ return 0;
104
+ }
94
105
  if (cmd === 'reach') {
95
106
  if (!json || !more) {
96
107
  console.error('quo reach needs <pk> <url>');
@@ -109,7 +120,7 @@ async function main(argv) {
109
120
  const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
110
121
  return failed ? 1 : 0;
111
122
  }
112
- console.error('usage: quo init|serve|census|boot|invite|knock|remove|side|run|pilot|reach [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
123
+ 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]');
113
124
  return 2;
114
125
  }
115
126
  main(process.argv.slice(2)).then((code) => {
@@ -63,7 +63,9 @@ function probe(base, state, env) {
63
63
  learn(i, w);
64
64
  }
65
65
  wardOf.set(key, name);
66
- assert.deepEqual(await w.ask('boot', { key, class: Class.name, public: isPublic }), { booted: key });
66
+ assert.deepEqual(await w.ask('boot', { key, class: Class.name }), { booted: key });
67
+ if (isPublic)
68
+ assert.deepEqual(await w.ask('public', { key }), { public: key });
67
69
  if (Object.keys(cells).length)
68
70
  Object.assign(at(name).p.beings[key], cells);
69
71
  return {
@@ -0,0 +1,28 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The edge platform, as much of it as the harbor touches and no more. The
3
+ // vendor's own type package declares every global a worker has, and it
4
+ // collides with the DOM library this tree compiles against; these few
5
+ // lines are what the edge harbor and worker actually call.
6
+ export type Storage = {
7
+ get<T = unknown>(key: string): Promise<T | undefined>;
8
+ get<T = unknown>(keys: string[]): Promise<Map<string, T>>;
9
+ put(key: string, value: unknown): Promise<void>;
10
+ delete(key: string): Promise<boolean>;
11
+ list<T = unknown>(options?: { prefix?: string }): Promise<Map<string, T>>;
12
+ };
13
+ export type State = { id: { toString(): string; name?: string }; storage: Storage };
14
+ export type Stub = { fetch(input: string | Request, init?: RequestInit): Promise<Response> };
15
+ export type Namespace = { idFromName(name: string): unknown; get(id: unknown): Stub };
16
+ export type Env = { HARBOR: Namespace; QUO_SEED_KEY?: string; QUO_ROOT?: string; QUO_EXERCISE?: string };
17
+ // The server end of a WebSocketPair: the standard surface plus accept.
18
+ export type ServerSocket = { accept(): void; readyState: number; binaryType: string; send(data: string | Uint8Array): void; close(): void; addEventListener(type: string, fn: (e: never) => void): void };
19
+ declare global {
20
+ // eslint-disable-next-line no-var
21
+ var WebSocketPair: new () => { 0: ServerSocket; 1: ServerSocket };
22
+ interface ResponseInit {
23
+ webSocket?: unknown;
24
+ }
25
+ interface Response {
26
+ readonly webSocket?: unknown;
27
+ }
28
+ }
@@ -0,0 +1,3 @@
1
+ import type { Avatar } from '../beings/avatar.ts';
2
+ import type { Subject } from '../beings/side.ts';
3
+ export declare function guest(avatar: Avatar, ward: string): Subject;
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A guest at a world's door: the world's public being, whatever class she
3
+ // is, as a subject the screen can render. Her describe for a stranger is the
4
+ // guest page; a form on it is a knock on the public invitation; and an
5
+ // answer that is an invitation is the way in, which the tab takes by
6
+ // joining. Nothing here knows the desk: the desk's `device` is one ask
7
+ // that answers an invitation, and any being may write another.
8
+ import { isSilence, isWord, wordOf } from '@quo-systems/quo';
9
+ export function guest(avatar, ward) {
10
+ const at = { ward };
11
+ return {
12
+ tools: async () => {
13
+ const bp = await avatar.knock(at);
14
+ if (isSilence(bp))
15
+ return { error: 'silence' };
16
+ if (isWord(bp))
17
+ return { error: wordOf(bp) };
18
+ if (bp === null || typeof bp !== 'object' || Array.isArray(bp) || !Array.isArray(bp.asks))
19
+ return { error: 'nobody is home' };
20
+ return bp;
21
+ },
22
+ call: (name, args = {}, wanted) => avatar.knock(at, name, args, wanted),
23
+ ears: new Set(),
24
+ };
25
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Ask, Blueprint, Json, JsonObject } from '@quo-systems/quo';
2
2
  import { type Word } from '../beings/side.ts';
3
+ import { hintFor, type Look, type Hint } from '../beings/look.ts';
3
4
  export type Property = {
4
5
  type?: string;
5
6
  description?: string;
@@ -13,7 +14,7 @@ export type Schema = {
13
14
  };
14
15
  export declare const escape: (s: string) => string;
15
16
  export declare function field(name: string, p: Property, must: boolean): string;
16
- export declare function form(ask: Ask): string;
17
+ export declare function form(ask: Ask, h?: Hint): string;
17
18
  export type Raw = Record<string, string>;
18
19
  export declare function values(ask: Ask, raw: Raw): {
19
20
  args: JsonObject;
@@ -24,9 +25,16 @@ export declare function view(value: Json, schema?: JsonObject): string;
24
25
  export declare function face(w: Word, schema?: JsonObject): string;
25
26
  export type Model = {
26
27
  blueprint: Blueprint | null;
28
+ look: Look;
27
29
  notice: string;
28
30
  answers: Record<string, Word>;
29
31
  pushes: JsonObject[];
30
32
  };
31
- export declare function title(bp: Blueprint | null): string;
33
+ export declare function title(bp: Blueprint | null, l?: Look): string;
34
+ export declare function look(l: Look | undefined): {
35
+ style: string;
36
+ head: string;
37
+ };
38
+ export declare function ordered(asks: Ask[], l: Look | undefined): Ask[];
39
+ export { hintFor };
32
40
  export declare function page(m: Model): string;
@@ -1,4 +1,5 @@
1
1
  import { SILENCE_TEXT, UNREACHED_TEXT, wordText } from '../beings/side.js';
2
+ import { hint, hintFor, sanitise, groups as grouped } from '../beings/look.js';
2
3
  export const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
3
4
  const props = (schema) => Object.entries((schema?.properties ?? {}));
4
5
  const required = (schema) => new Set((schema?.required ?? []));
@@ -28,12 +29,14 @@ export function field(name, p, must) {
28
29
  }
29
30
  // The ask as a form: its name is the button, its description the legend's
30
31
  // small print, and `data-ask` is how the surface says which ask was sent.
31
- export function form(ask) {
32
+ export function form(ask, h = {}) {
32
33
  const must = required(ask.input);
33
34
  const fields = props(ask.input)
34
35
  .map(([name, p]) => field(name, p, must.has(name)))
35
36
  .join('');
36
- return `<form data-ask="${escape(ask.name)}"><fieldset><legend>${escape(ask.name)}${ask.description ? ` <small>${escape(ask.description)}</small>` : ''}</legend>${fields}<p><button>${escape(ask.name)}</button></p></fieldset></form>`;
37
+ const label = `${h.icon ? `${escape(h.icon)} ` : ''}${escape(h.title ?? ask.name)}`;
38
+ const care = h.destructive ? ' data-confirm="true"' : '';
39
+ return `<form data-ask="${escape(ask.name)}"${care}><fieldset><legend>${label}${ask.description ? ` <small>${escape(ask.description)}</small>` : ''}</legend>${fields}<p><button>${label}</button></p></fieldset></form>`;
37
40
  }
38
41
  // The strings typed by the schema. An empty field that is not required is
39
42
  // left out, so the being sees what the human said and nothing else; an
@@ -116,21 +119,69 @@ export function face(w, schema) {
116
119
  }
117
120
  // The title is the one hint the notes may carry: a string named `name`.
118
121
  // The rest of the notes is shown as a view and read as nothing else.
119
- export function title(bp) {
122
+ export function title(bp, l = {}) {
123
+ if (l.name)
124
+ return l.name;
120
125
  const n = bp?.notes;
121
126
  return n !== null && typeof n === 'object' && !Array.isArray(n) && typeof n.name === 'string' && n.name ? n.name : 'quo';
122
127
  }
128
+ // ---- a look
129
+ // A look as the page paints it: the tokens become CSS variables on one
130
+ // section, and the name and the logo its heading. The shape of every token
131
+ // is the dock's, `beings/look.ts`; this only writes what survived it.
132
+ export function look(l) {
133
+ if (!l)
134
+ return { style: '', head: '' };
135
+ const vars = [];
136
+ if (l.accent)
137
+ vars.push(`--accent:${l.accent}`);
138
+ if (l.background)
139
+ vars.push(`--bg:${l.background}`);
140
+ if (l.foreground)
141
+ vars.push(`--fg:${l.foreground}`);
142
+ if (l.font)
143
+ vars.push(`--font:${l.font}`);
144
+ if (l.radius !== undefined)
145
+ vars.push(`--radius:${l.radius}px`);
146
+ const logo = l.logo ? `<img class="logo" alt="" src="${l.logo}">` : '';
147
+ const name = l.name ? escape(l.name) : '';
148
+ return { style: vars.length ? ` style="${vars.join(';')}"` : '', head: logo || name ? `<h2>${logo}${name}</h2>` : '' };
149
+ }
150
+ // Her asks in the order her look asks for, the rest after in her own order.
151
+ export function ordered(asks, l) {
152
+ const want = l?.order ?? [];
153
+ return [...want.map((n) => asks.find((a) => a.name === n)).filter((a) => a !== undefined), ...asks.filter((a) => !want.includes(a.name))];
154
+ }
155
+ export { hintFor };
123
156
  export function page(m) {
124
157
  const bp = m.blueprint;
125
- const asks = bp
126
- ? bp.asks
127
- .map((a) => {
128
- const w = m.answers[a.name];
129
- return `<section>${form(a)}${w ? face(w, a.output) : ''}</section>`;
158
+ const one = (l, prefix = '') => (a) => {
159
+ const w = m.answers[a.name];
160
+ const h = hint(l, a.name.startsWith(prefix) ? a.name.slice(prefix.length) : a.name);
161
+ return `<section>${form(a, h)}${w ? face(w, a.output) : ''}</section>`;
162
+ };
163
+ const groups = grouped(bp);
164
+ const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
165
+ const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && a.name !== 'look'), m.look).map(one(m.look)).join('') : '';
166
+ const far = bp
167
+ ? Object.entries(groups)
168
+ .map(([id, g]) => {
169
+ const asks = bp.asks.filter((a) => g.asks?.includes(a.name));
170
+ if (!asks.length)
171
+ return '';
172
+ const kept = sanitise(g.look);
173
+ const l = look(kept);
174
+ const prefix = `${id}-`;
175
+ const want = (kept.order ?? []).map((n) => prefix + n);
176
+ const inOrder = ordered(asks, { order: want });
177
+ return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
130
178
  })
131
179
  .join('')
132
180
  : '';
133
- const notes = bp && bp.notes !== null && !(typeof bp.notes === 'object' && !Array.isArray(bp.notes) && !Object.keys(bp.notes).length) ? `<aside class="notes">${view(bp.notes)}</aside>` : '';
181
+ const asks = own + far;
182
+ const shown = bp && bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? Object.fromEntries(Object.entries(bp.notes).filter(([k]) => k !== 'standings' && k !== 'name')) : bp?.notes;
183
+ const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown)}</aside>` : '';
134
184
  const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
135
- return `<header><h1>${escape(title(bp))}</h1><p class="notice">${escape(m.notice)}</p></header>${notes}<main>${asks}</main>${pushes}`;
185
+ const mine = look(m.look);
186
+ return `<header${mine.style}>${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1><p class="notice">${escape(m.notice)}</p></header>${notes}<main${mine.style}>${asks}</main>${pushes}`;
136
187
  }
@@ -1,11 +1,16 @@
1
- import type { Avatar } from '../beings/avatar.ts';
2
- import { type Serving } from '../beings/side.ts';
1
+ import { type Serving, type Subject } from '../beings/side.ts';
2
+ import type { Invitation } from '@quo-systems/quo';
3
3
  import { type Model, type Raw } from './html.ts';
4
4
  export type Surface = {
5
5
  show(html: string): void;
6
6
  onSubmit: ((ask: string, raw: Raw) => void) | undefined;
7
7
  };
8
- export declare function screenSide(avatar: Avatar, surface: Surface, after?: () => Promise<void>, notice?: string): Promise<Serving & {
8
+ export type Options = {
9
+ after?: () => Promise<void>;
10
+ notice?: string;
11
+ admit?: (invitation: Invitation) => Promise<void>;
12
+ };
13
+ export declare function screenSide(avatar: Subject, surface: Surface, options?: Options): Promise<Serving & {
9
14
  model: Model;
10
15
  refresh(): Promise<void>;
11
16
  }>;
@@ -8,11 +8,14 @@
8
8
  // moved. A push is appended and shown the moment it lands.
9
9
  import { digest } from '@quo-systems/quo';
10
10
  import { word } from '../beings/side.js';
11
- import { page, values } from './html.js';
12
- // `after` runs when a call is done, as it does for the model side: a harbor
13
- // that must write what the ward changed hooks it.
14
- export async function screenSide(avatar, surface, after = async () => { }, notice = '') {
15
- const model = { blueprint: null, notice, answers: {}, pushes: [] };
11
+ import { isInvitation } from '../beings/link.js';
12
+ import { page, values, hintFor } from './html.js';
13
+ import { sanitise } from '../beings/look.js';
14
+ import { isSilence, isWord } from '@quo-systems/quo';
15
+ export async function screenSide(avatar, surface, options = {}) {
16
+ const after = options.after ?? (async () => { });
17
+ const notice = options.notice ?? '';
18
+ const model = { blueprint: null, look: {}, notice, answers: {}, pushes: [] };
16
19
  let seen = null;
17
20
  const show = () => surface.show(page(model));
18
21
  // Her describe, again: the page follows the digest.
@@ -31,6 +34,19 @@ export async function screenSide(avatar, surface, after = async () => { }, notic
31
34
  for (const k of Object.keys(model.answers))
32
35
  if (!bp.asks.some((a) => a.name === k))
33
36
  delete model.answers[k];
37
+ // her look, once per digest: how she wants her page painted, and what each ask is
38
+ if (bp.asks.some((a) => a.name === 'look')) {
39
+ const l = await avatar.call('look');
40
+ model.look = isSilence(l) || isWord(l) ? {} : sanitise(l);
41
+ }
42
+ else
43
+ model.look = {};
44
+ // a read-only ask that needs nothing typed is run on her behalf, so the page opens with what it shows
45
+ for (const a of bp.asks) {
46
+ const needs = (a.input.required ?? []).length > 0;
47
+ if (hintFor(bp, model.look, a.name).readOnly && !needs && !(a.name in model.answers))
48
+ model.answers[a.name] = word(await avatar.call(a.name, {}));
49
+ }
34
50
  }
35
51
  }
36
52
  show();
@@ -45,8 +61,11 @@ export async function screenSide(avatar, surface, after = async () => { }, notic
45
61
  model.answers[name] = { word: 'error', value: { error: v.error } };
46
62
  return show();
47
63
  }
48
- model.answers[name] = word(await avatar.call(name, v.args));
64
+ const out = await avatar.call(name, v.args);
49
65
  await after();
66
+ if (options.admit && isInvitation(out))
67
+ return options.admit(out);
68
+ model.answers[name] = word(out);
50
69
  await refresh();
51
70
  };
52
71
  const ear = (object) => {
@@ -2,6 +2,8 @@ import { USER } from '../beings/avatar.ts';
2
2
  export type Config = {
3
3
  quo: string;
4
4
  web: string;
5
+ ward: string;
6
+ pk: string;
5
7
  };
6
8
  export declare function start(cfg: Config, root?: HTMLElement): Promise<void>;
7
9
  export { USER };
package/dist/human/tab.js CHANGED
@@ -1,7 +1,27 @@
1
1
  import { BrowserHarbor } from '../harbor/browser.js';
2
2
  import { Avatar, USER } from '../beings/avatar.js';
3
+ import { parse, strip, isInvitation } from '../beings/link.js';
3
4
  import { screenSide } from './screen.js';
4
5
  import { domSurface } from './dom.js';
6
+ import { guest } from './guest.js';
7
+ import { escape } from './html.js';
8
+ const WORLDS = 'quo-worlds';
9
+ const worlds = () => {
10
+ try {
11
+ return JSON.parse(localStorage.getItem(WORLDS) ?? '{}');
12
+ }
13
+ catch {
14
+ return {};
15
+ }
16
+ };
17
+ const remember = (pk, url, name) => {
18
+ try {
19
+ localStorage.setItem(WORLDS, JSON.stringify({ ...worlds(), [pk]: { url, name } }));
20
+ }
21
+ catch {
22
+ /* a tab with no storage still works, and forgets */
23
+ }
24
+ };
5
25
  const el = (tag, text = '', attrs = {}) => {
6
26
  const e = document.createElement(tag);
7
27
  if (text)
@@ -11,58 +31,123 @@ const el = (tag, text = '', attrs = {}) => {
11
31
  return e;
12
32
  };
13
33
  export async function start(cfg, root = document.body) {
34
+ // The link, first and once: the invitation leaves the address bar before
35
+ // any other code sees it.
36
+ const linked = parse(location.hash);
37
+ if (linked)
38
+ history.replaceState(null, '', location.pathname + location.search + strip(location.hash));
39
+ const nav = el('nav', '', { class: 'worlds' });
14
40
  const status = el('p', 'booting');
15
41
  const screen = el('div');
16
- root.append(status, screen);
42
+ root.append(nav, status, screen);
17
43
  const say = (s) => (status.textContent = s);
18
- // The harbor in the tab: one database, one ward, one avatar, for good.
44
+ // The harbor in the tab: one database per origin, one ward per world.
19
45
  const harbor = new BrowserHarbor('quo');
20
46
  await harbor.boot();
21
- const main = harbor.wards.get('main') ?? (await harbor.create('main', 'me'));
22
- if (!main.being('me'))
23
- await main.ask('boot', { key: 'me', class: 'Avatar' });
24
- const me = main.being('me');
47
+ const name = `w-${cfg.pk.slice(0, 16)}`;
48
+ const ward = harbor.wards.get(name) ?? (await harbor.create(name, 'me'));
49
+ if (!ward.being('me'))
50
+ await ward.ask('boot', { key: 'me', class: 'Avatar' });
51
+ const me = ward.being('me');
25
52
  harbor.dial(cfg.quo);
26
- // The screen: every call rotates her keys and a same-ward ask never
53
+ const switcher = () => {
54
+ nav.replaceChildren();
55
+ const known = worlds();
56
+ for (const [pk, w] of Object.entries(known)) {
57
+ const a = el('a', w.name || pk.slice(0, 8), { href: w.url });
58
+ if (pk === cfg.pk)
59
+ a.setAttribute('aria-current', 'page');
60
+ nav.append(a);
61
+ }
62
+ const here = worlds()[cfg.pk];
63
+ if (!here)
64
+ nav.append(el('span', `${cfg.ward} at ${new URL(cfg.web).host}`));
65
+ };
66
+ switcher();
67
+ // Joined: her page. Every call rotates her keys and a same-ward ask never
27
68
  // crosses the harbor, so the side saves after each one.
28
- const show = async (notice) => {
69
+ const inside = async (notice) => {
29
70
  status.remove();
30
- await screenSide(me, domSurface(screen), () => main.save(), notice);
71
+ screen.replaceChildren();
72
+ const side = await screenSide(me, domSurface(screen), { after: () => ward.save(), notice });
73
+ remember(cfg.pk, location.origin + location.pathname, side.model.look.name || (typeof side.model.blueprint?.notes?.name === 'string' ? side.model.blueprint.notes.name : cfg.ward));
74
+ switcher();
31
75
  };
32
- // Already in: reconnect, nothing minted.
33
- const had = await me.tools();
34
- if ('asks' in had)
35
- return show('in, as before');
36
- // The exchange, once: the owner password for a nonce, the nonce for an
37
- // invitation the user being mints, the invitation for a standing.
38
- const form = el('form');
39
- const identity = el('input', '', { name: 'identity', value: 'tab', placeholder: 'identity' });
40
- const password = el('input', '', { name: 'password', type: 'password', placeholder: 'owner password' });
41
- const go = el('button', 'enter');
42
- form.append(identity, password, go);
43
- root.insertBefore(form, screen);
44
- say(`not in (${had.error}): the owner password opens the world`);
45
- form.onsubmit = async (ev) => {
46
- ev.preventDefault();
47
- say('asking the world');
48
- let res;
49
- try {
50
- res = await fetch(`${cfg.web}/tab/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: password.value, identity: identity.value }) });
51
- }
52
- catch (e) {
53
- return say(`the world did not answer: ${e.message}`);
54
- }
55
- if (!res.ok)
56
- return say(`refused: ${(await res.json().catch(() => ({ error: res.status }))).error ?? res.status}`);
57
- const { nonce, ward } = (await res.json());
58
- password.value = '';
76
+ // The way in, from any of the three: join, save, and be inside.
77
+ const admit = async (inv, notice) => {
59
78
  say('knocking');
60
- const got = await me.enter({ ward }, { kind: 'tab', nonce });
61
- await main.save(); // the knock went through her own door, which the harbor never sees
62
- if (!('asks' in got))
63
- return say(`not in: ${got.error}`);
64
- form.remove();
65
- await show(`in, as ${identity.value}`);
79
+ const got = await me.join(inv);
80
+ await ward.save(); // the knock went through her own door, which the harbor never sees
81
+ if (!('asks' in got)) {
82
+ say(`not in: ${got.error}`);
83
+ return;
84
+ }
85
+ await inside(notice);
66
86
  };
87
+ // Already in: reconnect, nothing minted.
88
+ if (me.standings[USER]) {
89
+ const had = await me.tools();
90
+ if ('asks' in had)
91
+ return inside('in, as before');
92
+ say(`not in (${had.error})`);
93
+ }
94
+ if (linked)
95
+ return admit(linked, 'in, by the link');
96
+ // A guest: the public being's describe as a page; a form whose answer is
97
+ // an invitation lets the guest in.
98
+ say(`at the door of ${cfg.ward}`);
99
+ const door = guest(me, cfg.pk);
100
+ const side = await screenSide(door, domSurface(screen), {
101
+ after: () => ward.save(),
102
+ notice: `a guest at ${cfg.ward}: what she shows strangers`,
103
+ admit: async (inv) => {
104
+ await side.close();
105
+ await admit(inv, `in, as a guest of ${cfg.ward}`);
106
+ },
107
+ });
108
+ // The dock's own way in, when the desk is at the door: the owner password
109
+ // for a nonce, and the desk's `device` form submitted with it.
110
+ if (side.model.blueprint?.asks.some((a) => a.name === 'device')) {
111
+ const form = el('form', '', { class: 'password' });
112
+ const identity = el('input', '', { name: 'identity', value: 'tab', placeholder: 'identity' });
113
+ const password = el('input', '', { name: 'password', type: 'password', placeholder: 'owner password' });
114
+ const go = el('button', 'enter as the owner');
115
+ form.append(identity, password, go);
116
+ root.insertBefore(form, screen);
117
+ form.onsubmit = (ev) => {
118
+ ev.preventDefault();
119
+ void (async () => {
120
+ say('asking the world');
121
+ let res;
122
+ try {
123
+ res = await fetch(`${cfg.web}/${encodeURIComponent(cfg.ward)}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: password.value, identity: identity.value }) });
124
+ }
125
+ catch (e) {
126
+ say(`the world did not answer: ${e.message}`);
127
+ return;
128
+ }
129
+ password.value = '';
130
+ if (!res.ok) {
131
+ say(`refused: ${(await res.json().catch(() => ({ error: res.status }))).error ?? res.status}`);
132
+ return;
133
+ }
134
+ const { nonce } = (await res.json());
135
+ const inv = await door.call('device', { proof: { kind: 'tab', nonce } });
136
+ await ward.save();
137
+ if (!isInvitation(inv)) {
138
+ say(`not in: ${escape(JSON.stringify(inv))}`);
139
+ return;
140
+ }
141
+ form.remove();
142
+ await side.close();
143
+ await admit(inv, `in, as ${identity.value}`);
144
+ })();
145
+ };
146
+ }
67
147
  }
148
+ // The page hands the config in a JSON script, which a content security
149
+ // policy allows where an inline script is not.
150
+ const config = document.getElementById('quo');
151
+ if (config?.textContent)
152
+ void start(JSON.parse(config.textContent));
68
153
  export { USER };
@@ -4,12 +4,13 @@ import type { Avatar } from '../beings/avatar.ts';
4
4
  import type { Serving } from '../beings/side.ts';
5
5
  export type Session = {
6
6
  identity: string;
7
+ ward: string;
7
8
  transport: StreamableHTTPServerTransport;
8
9
  serving: Serving;
9
10
  touched: number;
10
11
  };
11
12
  export declare const SESSION_IDLE: number;
12
- export type Resolve = (identity: string) => Promise<{
13
+ export type Resolve = (identity: string, ward: string) => Promise<{
13
14
  avatar?: Avatar;
14
15
  error?: string;
15
16
  }>;
@@ -18,9 +19,9 @@ export declare class McpHttp {
18
19
  readonly resolve: Resolve;
19
20
  readonly after: () => Promise<void>;
20
21
  now: () => number;
21
- gone: (identity: string) => Promise<void>;
22
+ gone: (identity: string, ward: string) => Promise<void>;
22
23
  constructor(resolve: Resolve, after?: () => Promise<void>);
23
- handle(req: IncomingMessage, res: ServerResponse, identity: string): Promise<void>;
24
+ handle(req: IncomingMessage, res: ServerResponse, identity: string, ward?: string): Promise<void>;
24
25
  sweep(): void;
25
26
  drop(sid: string): void;
26
27
  close(): Promise<void>;
package/dist/mcp/http.js CHANGED
@@ -28,7 +28,7 @@ export class McpHttp {
28
28
  // `identity` is what the bearer named; the caller has already turned a
29
29
  // stranger away. A request on a known session goes to it. A request with
30
30
  // no session opens one, if it is an initialize; anything else is 400.
31
- async handle(req, res, identity) {
31
+ async handle(req, res, identity, ward = 'main') {
32
32
  const id = req.headers['mcp-session-id'];
33
33
  const sid = Array.isArray(id) ? id[0] : id;
34
34
  const json = (status, body) => {
@@ -40,7 +40,7 @@ export class McpHttp {
40
40
  const s = this.sessions.get(sid);
41
41
  if (!s)
42
42
  return json(404, { jsonrpc: '2.0', error: { code: -32001, message: 'no such session' }, id: null });
43
- if (s.identity !== identity)
43
+ if (s.identity !== identity || s.ward !== ward)
44
44
  return json(403, { jsonrpc: '2.0', error: { code: -32003, message: 'not your session' }, id: null });
45
45
  s.touched = this.now();
46
46
  await s.transport.handleRequest(req, res);
@@ -50,16 +50,16 @@ export class McpHttp {
50
50
  }
51
51
  if (req.method !== 'POST')
52
52
  return json(400, { jsonrpc: '2.0', error: { code: -32000, message: 'no session' }, id: null });
53
- const found = await this.resolve(identity);
53
+ const found = await this.resolve(identity, ward);
54
54
  // A session opens on her describe, and admit already asked it. The one
55
55
  // word the door says for an identity the user being removed is
56
56
  // `removed`, under the key it bound for her avatar: in MCP's vocabulary
57
57
  // that is 401, the client drops its token and starts the exchange again,
58
58
  // and the route forgets the grant.
59
59
  if (found.error === 'removed') {
60
- await this.gone(identity);
60
+ await this.gone(identity, ward);
61
61
  for (const [sid, s] of this.sessions)
62
- if (s.identity === identity)
62
+ if (s.identity === identity && s.ward === ward)
63
63
  this.drop(sid);
64
64
  res.writeHead(401, { 'content-type': 'application/json' });
65
65
  return void res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'removed: the user being removed this identity' }, id: null }));
@@ -69,7 +69,7 @@ export class McpHttp {
69
69
  const transport = new StreamableHTTPServerTransport({
70
70
  sessionIdGenerator: () => randomUUID(),
71
71
  onsessioninitialized: (s) => {
72
- this.sessions.set(s, { identity, transport, serving, touched: this.now() });
72
+ this.sessions.set(s, { identity, ward, transport, serving, touched: this.now() });
73
73
  },
74
74
  onsessionclosed: (s) => this.drop(s),
75
75
  });