@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
@@ -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
  });
@@ -18,6 +18,7 @@ export type Pending = {
18
18
  };
19
19
  export type Grant = {
20
20
  identity: string;
21
+ ward: string;
21
22
  client_id: string;
22
23
  exp: number;
23
24
  };
@@ -26,6 +27,7 @@ export type Store = {
26
27
  pending: Record<string, Pending>;
27
28
  codes: Record<string, Pending & {
28
29
  identity: string;
30
+ ward: string;
29
31
  }>;
30
32
  access: Record<string, Grant>;
31
33
  refresh: Record<string, Grant>;
@@ -72,7 +74,7 @@ export declare class OAuth {
72
74
  pending(id: string): (Pending & {
73
75
  client: Client;
74
76
  }) | null;
75
- complete(id: string, identity: string): Promise<{
77
+ complete(id: string, identity: string, ward?: string): Promise<{
76
78
  redirect: string;
77
79
  } | {
78
80
  error: string;
@@ -83,15 +85,18 @@ export declare class OAuth {
83
85
  error: string;
84
86
  }>;
85
87
  token(body: URLSearchParams): Promise<Record<string, unknown>>;
86
- issue(identity: string, client_id: string): Promise<{
88
+ issue(identity: string, ward: string, client_id: string): Promise<{
87
89
  access_token: string;
88
90
  token_type: string;
89
91
  expires_in: number;
90
92
  refresh_token: string;
91
93
  scope: string;
92
94
  }>;
93
- bearer(req: IncomingMessage): string | null;
94
- revoke(identity: string): Promise<void>;
95
+ bearer(req: IncomingMessage): {
96
+ identity: string;
97
+ ward: string;
98
+ } | null;
99
+ revoke(identity: string, ward?: string): Promise<void>;
95
100
  sweep(): void;
96
101
  handle(req: IncomingMessage, res: ServerResponse, rest: string): Promise<boolean>;
97
102
  challenge(res: ServerResponse): void;
package/dist/mcp/oauth.js CHANGED
@@ -99,14 +99,15 @@ export class OAuth {
99
99
  const client = this.client(p.client_id);
100
100
  return client ? { ...p, client } : null;
101
101
  }
102
- // The web route's last step: the human allowed this client as this identity.
103
- async complete(id, identity) {
102
+ // The web route's last step: the human allowed this client as this
103
+ // identity, into this world.
104
+ async complete(id, identity, ward = 'main') {
104
105
  const p = this.pending(id);
105
106
  if (!p)
106
107
  return { error: 'expired' };
107
108
  delete this.store.pending[id];
108
109
  const code = token();
109
- this.store.codes[code] = { ...p, identity, exp: this.now() + CODE_TTL };
110
+ this.store.codes[code] = { ...p, identity, ward, exp: this.now() + CODE_TTL };
110
111
  await this.o.persist(this.store);
111
112
  return { redirect: withQuery(p.redirect_uri, { code, state: p.state }) };
112
113
  }
@@ -131,7 +132,7 @@ export class OAuth {
131
132
  await this.o.persist(this.store);
132
133
  return { error: 'invalid_grant' };
133
134
  }
134
- return this.issue(c.identity, c.client_id);
135
+ return this.issue(c.identity, c.ward ?? 'main', c.client_id);
135
136
  }
136
137
  if (grant === 'refresh_token') {
137
138
  const rt = body.get('refresh_token') ?? '';
@@ -139,14 +140,14 @@ export class OAuth {
139
140
  if (!r || r.exp < this.now())
140
141
  return { error: 'invalid_grant' };
141
142
  delete this.store.refresh[rt]; // rotated: the old one is gone with the new one's birth
142
- return this.issue(r.identity, r.client_id);
143
+ return this.issue(r.identity, r.ward ?? 'main', r.client_id);
143
144
  }
144
145
  return { error: 'unsupported_grant_type' };
145
146
  }
146
- async issue(identity, client_id) {
147
+ async issue(identity, ward, client_id) {
147
148
  const access_token = token(), refresh_token = token();
148
- this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
149
- this.store.refresh[refresh_token] = { identity, client_id, exp: this.now() + REFRESH_TTL };
149
+ this.store.access[access_token] = { identity, ward, client_id, exp: this.now() + ACCESS_TTL };
150
+ this.store.refresh[refresh_token] = { identity, ward, client_id, exp: this.now() + REFRESH_TTL };
150
151
  const c = own(this.store.clients, client_id);
151
152
  if (c)
152
153
  c.exp = this.now() + REFRESH_TTL; // allowed: the client lives as long as what it was granted
@@ -154,19 +155,19 @@ export class OAuth {
154
155
  await this.o.persist(this.store);
155
156
  return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
156
157
  }
157
- // The bearer on an MCP request, to a client identity. Null is 401.
158
+ // The bearer on an MCP request, to a client identity in a world. Null is 401.
158
159
  bearer(req) {
159
160
  const h = req.headers.authorization ?? '';
160
161
  const t = h.startsWith('Bearer ') ? h.slice(7) : '';
161
162
  const g = own(this.store.access, t);
162
- return g && g.exp >= this.now() && typeof g.identity === 'string' ? g.identity : null;
163
+ return g && g.exp >= this.now() && typeof g.identity === 'string' ? { identity: g.identity, ward: g.ward ?? 'main' } : null;
163
164
  }
164
- // Every grant an identity holds, gone: the route's half of revocation.
165
- // The other half is the user being removing the occupant.
166
- async revoke(identity) {
165
+ // Every grant an identity holds in a world, gone: the route's half of
166
+ // revocation. The other half is the user being removing the occupant.
167
+ async revoke(identity, ward = 'main') {
167
168
  for (const k of ['access', 'refresh'])
168
169
  for (const [t, g] of Object.entries(this.store[k]))
169
- if (g.identity === identity)
170
+ if (g.identity === identity && (g.ward ?? 'main') === ward)
170
171
  delete this.store[k][t];
171
172
  await this.o.persist(this.store);
172
173
  }
package/dist/mcp/pilot.js CHANGED
@@ -15,7 +15,7 @@ export function owner(dir, ward, via) {
15
15
  return { error: r.word };
16
16
  return r;
17
17
  };
18
- return { tools: () => one(undefined, {}), call: (name, args) => one(name, args), ears: new Set() };
18
+ return { tools: () => one(undefined, {}), call: (name, args) => one(name, args ?? {}), ears: new Set() };
19
19
  }
20
20
  export function pilotSide(dir, ward, transport, via) {
21
21
  return mcpSide(owner(dir, ward, via), transport);
@@ -1,16 +1,11 @@
1
1
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
2
  import { type Tool } from '@modelcontextprotocol/sdk/types.js';
3
- import type { Answer, Blueprint, JsonObject } from '@quo-systems/quo';
4
- import { type Serving } from '../beings/side.ts';
3
+ import type { Blueprint } from '@quo-systems/quo';
4
+ import { type Serving, type Subject } from '../beings/side.ts';
5
+ import { type Look } from '../beings/look.ts';
5
6
  export declare const NAME = "quo";
6
7
  export declare const VERSION = "0.0.0";
7
- export type Subject = {
8
- tools(): Promise<Blueprint | {
9
- error: string;
10
- }>;
11
- call(name: string, args: JsonObject): Promise<Answer>;
12
- ears: Set<(object: JsonObject) => void>;
13
- };
8
+ export type { Subject } from '../beings/side.ts';
14
9
  export declare const DESCRIBE: Tool;
15
- export declare function tools(bp: Blueprint): Tool[];
10
+ export declare function tools(bp: Blueprint, look?: Look): Tool[];
16
11
  export declare function mcpSide(avatar: Subject, transport: Transport, after?: () => Promise<void>): Promise<Serving>;