@quo-systems/dock 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/human/tab.js CHANGED
@@ -1,27 +1,32 @@
1
1
  import { BrowserHarbor } from '../harbor/browser.js';
2
- import { Avatar, USER } from '../beings/avatar.js';
3
- import { parse, strip, isInvitation } from '../beings/link.js';
2
+ import { USER } from '../beings/avatar.js';
3
+ import { parse, strip } from '../beings/link.js';
4
4
  import { screenSide } from './screen.js';
5
5
  import { domSurface } from './dom.js';
6
6
  import { guest } from './guest.js';
7
- import { escape } from './html.js';
8
- const WORLDS = 'quo-worlds';
9
- const worlds = () => {
7
+ import { door } from './door.js';
8
+ import { world, relations, fresh } from './worlds.js';
9
+ const kept = (key, fallback) => {
10
10
  try {
11
- return JSON.parse(localStorage.getItem(WORLDS) ?? '{}');
11
+ return JSON.parse(localStorage.getItem(key) ?? 'null') ?? fallback;
12
12
  }
13
13
  catch {
14
- return {};
14
+ return fallback;
15
15
  }
16
16
  };
17
- const remember = (pk, url, name) => {
17
+ const keep = (key, value) => {
18
18
  try {
19
- localStorage.setItem(WORLDS, JSON.stringify({ ...worlds(), [pk]: { url, name } }));
19
+ localStorage.setItem(key, JSON.stringify(value));
20
20
  }
21
21
  catch {
22
- /* a tab with no storage still works, and forgets */
22
+ /* forgets */
23
23
  }
24
24
  };
25
+ const WORLDS = 'quo-worlds';
26
+ const worlds = () => kept(WORLDS, {});
27
+ const remember = (pk, url, name) => keep(WORLDS, { ...worlds(), [pk]: { url, name } });
28
+ const AT = (pk) => `quo-at:${pk}`;
29
+ const NAMES = (pk) => `quo-names:${pk}`;
25
30
  const el = (tag, text = '', attrs = {}) => {
26
31
  const e = document.createElement(tag);
27
32
  if (text)
@@ -37,113 +42,141 @@ export async function start(cfg, root = document.body) {
37
42
  if (linked)
38
43
  history.replaceState(null, '', location.pathname + location.search + strip(location.hash));
39
44
  const nav = el('nav', '', { class: 'worlds' });
45
+ const who = el('nav', '', { class: 'relations' });
40
46
  const status = el('p', 'booting');
41
47
  const screen = el('div');
42
- root.append(nav, status, screen);
48
+ root.append(nav, who, status, screen);
43
49
  const say = (s) => (status.textContent = s);
44
50
  // The harbor in the tab: one database per origin, one ward per world.
45
51
  const harbor = new BrowserHarbor('quo');
46
52
  await harbor.boot();
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');
52
53
  harbor.dial(cfg.quo);
54
+ // The world this page is about: the link's, since an invitation names
55
+ // its ward, or the page's own. A link for a world not on this harbor is
56
+ // a link to the wrong page, and says so.
57
+ const nameOf = (pk) => Object.keys(cfg.wards).find((n) => cfg.wards[n].pk === pk);
58
+ const pk = linked?.ward ?? (cfg.ward ? cfg.wards[cfg.ward]?.pk : undefined);
59
+ const wardName = pk ? nameOf(pk) : undefined;
60
+ const pageOf = (name) => `${new URL(cfg.web).pathname.replace(/\/$/, '')}/${encodeURIComponent(name)}`;
53
61
  const switcher = () => {
54
62
  nav.replaceChildren();
55
63
  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)
64
+ for (const [k, w] of Object.entries(known)) {
65
+ const a = el('a', w.name || k.slice(0, 8), { href: w.url });
66
+ if (k === pk)
59
67
  a.setAttribute('aria-current', 'page');
60
68
  nav.append(a);
61
69
  }
62
- const here = worlds()[cfg.pk];
63
- if (!here)
64
- nav.append(el('span', `${cfg.ward} at ${new URL(cfg.web).host}`));
70
+ if (pk && !known[pk] && wardName)
71
+ nav.append(el('span', `${wardName} at ${new URL(cfg.web).host}`));
65
72
  };
66
73
  switcher();
67
- // Joined: her page. Every call rotates her keys and a same-ward ask never
68
- // crosses the harbor, so the side saves after each one.
69
- const inside = async (notice) => {
74
+ if (!pk || !wardName) {
75
+ say(linked ? 'this link is for a world that does not live here' : 'choose a world');
76
+ return;
77
+ }
78
+ if (linked && location.pathname !== pageOf(wardName))
79
+ history.replaceState(null, '', pageOf(wardName) + location.search + location.hash);
80
+ const ward = await world(harbor, pk);
81
+ const at = { name: wardName, pk, public: cfg.wards[wardName].public };
82
+ // The relations in this world, and which one is on screen.
83
+ let side = null;
84
+ let current = null;
85
+ const names = () => kept(NAMES(pk), {});
86
+ const people = () => {
87
+ who.replaceChildren();
88
+ const rels = relations(ward);
89
+ const called = names();
90
+ for (const r of rels) {
91
+ const b = el('button', called[r.key] ?? r.key, { type: 'button' });
92
+ if (r.key === current?.key)
93
+ b.setAttribute('aria-current', 'true');
94
+ b.onclick = () => void inside(r, `in, as ${called[r.key] ?? r.key}`, called[r.key] ?? r.key);
95
+ who.append(b);
96
+ }
97
+ if (rels.length) {
98
+ const more = el('button', 'another way in', { type: 'button', class: 'another' });
99
+ more.onclick = () => void atDoor();
100
+ who.append(more);
101
+ }
102
+ };
103
+ // In, as one relation: her page. Every call rotates her keys and a
104
+ // same-ward ask never crosses the harbor, so the side saves after each.
105
+ const inside = async (rel, notice, called) => {
106
+ await side?.close();
70
107
  status.remove();
108
+ root.querySelector('form.password')?.remove();
71
109
  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));
110
+ current = rel;
111
+ keep(AT(pk), rel.key);
112
+ const s = await screenSide(rel.avatar, domSurface(screen), { after: () => ward.save(), notice });
113
+ side = s;
114
+ const bp = s.model.blueprint;
115
+ const notesName = typeof bp?.notes?.name === 'string' ? bp.notes.name : '';
116
+ remember(pk, location.origin + pageOf(at.name), s.model.look.name || notesName || at.name);
117
+ if (!names()[rel.key])
118
+ keep(NAMES(pk), { ...names(), [rel.key]: called });
74
119
  switcher();
120
+ people();
75
121
  };
76
- // The way in, from any of the three: join, save, and be inside.
77
- const admit = async (inv, notice) => {
122
+ // The way in, from any of the three: a fresh avatar joins, the ward is
123
+ // saved, and she is the one on screen.
124
+ const admit = async (inv, notice, called) => {
78
125
  say('knocking');
79
- const got = await me.join(inv);
126
+ const rel = await fresh(ward);
127
+ const got = await rel.avatar.join(inv);
80
128
  await ward.save(); // the knock went through her own door, which the harbor never sees
81
129
  if (!('asks' in got)) {
82
130
  say(`not in: ${got.error}`);
131
+ return false;
132
+ }
133
+ await inside(rel, notice, called);
134
+ return true;
135
+ };
136
+ // At the door. With a public being there, her describe as a page, and a
137
+ // form whose answer is an invitation lets the guest in. With nobody
138
+ // there, the door page: a link is the only way in, and nothing to type.
139
+ const atDoor = async () => {
140
+ await side?.close();
141
+ side = null;
142
+ current = null;
143
+ screen.replaceChildren();
144
+ if (!status.isConnected)
145
+ root.insertBefore(status, screen);
146
+ people();
147
+ if (!at.public) {
148
+ status.remove();
149
+ screen.innerHTML = door({ world: at.name, host: new URL(cfg.web).host });
83
150
  return;
84
151
  }
85
- await inside(notice);
152
+ say(`at the door of ${at.name}`);
153
+ const rel = await fresh(ward);
154
+ const gate = guest(rel.avatar, pk);
155
+ const s = await screenSide(gate, domSurface(screen), {
156
+ after: () => ward.save(),
157
+ notice: `a guest at ${at.name}: what she shows strangers`,
158
+ admit: async (inv) => {
159
+ await s.close();
160
+ await admit(inv, `in, as a guest of ${at.name}`, 'guest');
161
+ },
162
+ });
163
+ side = s;
86
164
  };
87
- // Already in: reconnect, nothing minted.
88
- if (me.standings[USER]) {
89
- const had = await me.tools();
165
+ // A link: one more relation, whoever else is in. Then whoever was on
166
+ // screen last, or the door.
167
+ if (linked && (await admit(linked, 'in, by the link', 'link')))
168
+ return;
169
+ const rels = relations(ward);
170
+ const last = kept(AT(pk), null);
171
+ const back = rels.find((r) => r.key === last) ?? rels[rels.length - 1];
172
+ if (back) {
173
+ const had = await back.avatar.tools();
174
+ await ward.save(); // her keys rotated on that ask, whatever it answered
90
175
  if ('asks' in had)
91
- return inside('in, as before');
176
+ return inside(back, linked ? `not in by the link; in, as before` : 'in, as before', names()[back.key] ?? back.key);
92
177
  say(`not in (${had.error})`);
93
178
  }
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
- }
179
+ await atDoor();
147
180
  }
148
181
  // The page hands the config in a JSON script, which a content security
149
182
  // policy allows where an inline script is not.
@@ -0,0 +1,9 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { DiskHarbor } from '../harbor/disk.ts';
3
+ export type WebOptions = {
4
+ at: {
5
+ quo: string;
6
+ web: string;
7
+ };
8
+ };
9
+ export declare function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessage, res: ServerResponse, rest: string) => Promise<boolean>;
@@ -0,0 +1,95 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { build } from 'esbuild';
4
+ import { door } from './door.js';
5
+ // The paths under `/web` that are the exchange's, never a ward's name.
6
+ const RESERVED_PATHS = new Set(['login', 'allow', 'tab.js']);
7
+ export function webRoute(harbor, o) {
8
+ const tabEntry = () => {
9
+ const js = fileURLToPath(new URL('./tab.js', import.meta.url));
10
+ return existsSync(js) ? js : fileURLToPath(new URL('./tab.ts', import.meta.url));
11
+ };
12
+ let bundle;
13
+ const built = () => (bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((out) => out.outputFiles[0].text));
14
+ const quoOrigin = (() => {
15
+ try {
16
+ const u = new URL(o.at.quo);
17
+ return `${u.origin} ${u.origin.replace(/^http/, 'ws')}`;
18
+ }
19
+ catch {
20
+ return '';
21
+ }
22
+ })();
23
+ const host = (() => {
24
+ try {
25
+ return new URL(o.at.web).host;
26
+ }
27
+ catch {
28
+ return o.at.web;
29
+ }
30
+ })();
31
+ 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'`;
32
+ 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>
33
+ <style>${CSS}</style>
34
+ </head><body>${body}</body></html>`;
35
+ const esc = (v) => v.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
36
+ const publicOf = (h) => h.partition.public ?? null;
37
+ const html = (status, res, body) => {
38
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'no-referrer' });
39
+ res.end(shell(body));
40
+ return true;
41
+ };
42
+ const wards = () => Object.fromEntries([...harbor.wards].map(([n, h]) => [n, { pk: h.pk, public: publicOf(h) !== null }]));
43
+ const tab = (ward) => {
44
+ const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), ...(ward ? { ward } : {}) };
45
+ return `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
46
+ };
47
+ return async (req, res, rest) => {
48
+ const parts = rest.split('/').filter(Boolean);
49
+ if (req.method === 'GET' && (rest === '' || rest === '/')) {
50
+ const list = [...harbor.wards]
51
+ .filter(([, h]) => publicOf(h) !== null)
52
+ .map(([n, h]) => `<li><a href="${o.at.web}/${encodeURIComponent(n)}">${esc(n)}</a> <small>${esc(publicOf(h) ?? '')} at the door, <code>${h.pk.slice(0, 16)}…</code></small></li>`)
53
+ .join('');
54
+ return html(200, res, `${list ? `<h1>worlds</h1><ul>${list}</ul>` : door({ host })}${tab()}`);
55
+ }
56
+ if (rest === '/tab.js' && req.method === 'GET') {
57
+ res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
58
+ res.end(await built());
59
+ return true;
60
+ }
61
+ const wardName = parts[0] ?? '';
62
+ if (!wardName || RESERVED_PATHS.has(wardName))
63
+ return false;
64
+ const hosted = harbor.wards.get(wardName);
65
+ if (!hosted)
66
+ return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
67
+ if (parts.length === 1 && req.method === 'GET')
68
+ return html(200, res, tab(wardName));
69
+ return false;
70
+ };
71
+ }
72
+ // The tab's stylesheet: one, light and dark, honouring the variables a look
73
+ // sets on a section. The page owns layout; a far being paints inside her
74
+ // section and nowhere else. The door page is the one designed thing in it.
75
+ const CSS = [
76
+ ':root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px;--ink:#1c1b22;--paper:#fbfaf7;--mute:#6b6a73}',
77
+ '@media(prefers-color-scheme:dark){:root{--ink:#ecebe6;--paper:#141318;--mute:#9a99a2}}',
78
+ 'html{background:var(--paper);color:var(--ink)}',
79
+ 'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}',
80
+ 'nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}',
81
+ 'nav.relations{display:flex;flex-wrap:wrap;gap:.25rem;margin:.5rem 0}nav.relations button{background:transparent;color:inherit;border:1px solid color-mix(in srgb,currentColor 30%,transparent)}nav.relations button[aria-current]{border-color:var(--accent);font-weight:600}',
82
+ '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}',
83
+ '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}',
84
+ 'fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}',
85
+ '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}',
86
+ '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}',
87
+ // the door page: one column, generous air, the mark, a word, a sentence
88
+ 'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
89
+ 'main.door .mark{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
90
+ 'main.door h1{font-size:3rem;font-weight:400;letter-spacing:-.02em;line-height:1;margin:0 0 .5rem}',
91
+ 'main.door .where{font-family:system-ui,sans-serif;font-size:.85rem;letter-spacing:.08em;text-transform:uppercase;color:var(--mute);margin:0 0 2rem}main.door .where span{text-transform:none;letter-spacing:0}',
92
+ 'main.door .lead{font-size:1.5rem;line-height:1.3;margin:0 0 1rem}',
93
+ 'main.door p{font-size:1.05rem;line-height:1.6;margin:0 0 1rem;max-width:30rem}',
94
+ 'main.door .quiet{color:var(--mute);font-size:.95rem;border-top:1px solid color-mix(in srgb,currentColor 15%,transparent);padding-top:1rem;margin-top:1rem}',
95
+ ].join('');
@@ -0,0 +1,10 @@
1
+ import type { Harbor, Hosted } from '@quo-systems/quo/harbor';
2
+ import { Avatar } from '../beings/avatar.ts';
3
+ export declare const localName: (pk: string) => string;
4
+ export declare function world(harbor: Harbor, pk: string): Promise<Hosted>;
5
+ export type Relation = {
6
+ key: string;
7
+ avatar: Avatar;
8
+ };
9
+ export declare function relations(ward: Hosted): Relation[];
10
+ export declare function fresh(ward: Hosted): Promise<Relation>;
@@ -0,0 +1,38 @@
1
+ import { Avatar, USER } from '../beings/avatar.js';
2
+ // The local ward for a far world, by its pk: created on first sight, on
3
+ // the tab's own seed, empty.
4
+ export const localName = (pk) => `w-${pk.slice(0, 16)}`;
5
+ export async function world(harbor, pk) {
6
+ const name = localName(pk);
7
+ return harbor.wards.get(name) ?? (await harbor.create(name, 'me'));
8
+ }
9
+ export function relations(ward) {
10
+ const beings = ward.partition.beings ?? {};
11
+ const out = [];
12
+ for (const key of Object.keys(beings)) {
13
+ const a = ward.being(key);
14
+ if (a instanceof Avatar && a.standings[USER])
15
+ out.push({ key, avatar: a });
16
+ }
17
+ return out;
18
+ }
19
+ // An avatar for a relation not yet made: one who holds no standing, whether
20
+ // left by a guest who never came in or booted now under the next free key.
21
+ // She is the one who knocks, as a guest or with an invitation, and she
22
+ // becomes a relation the moment she takes her standing.
23
+ export async function fresh(ward) {
24
+ const beings = ward.partition.beings ?? {};
25
+ for (const key of Object.keys(beings)) {
26
+ const a = ward.being(key);
27
+ if (a instanceof Avatar && !a.standings[USER])
28
+ return { key, avatar: a };
29
+ }
30
+ let n = 1;
31
+ while (beings[`r${n}`])
32
+ n++;
33
+ const key = `r${n}`;
34
+ const out = (await ward.ask('boot', { key, class: 'Avatar' }));
35
+ if (out.error)
36
+ throw new Error(`the tab could not boot an avatar: ${out.error}`);
37
+ return { key, avatar: ward.being(key) };
38
+ }
@@ -0,0 +1,12 @@
1
+ import { OAuth } from './oauth.ts';
2
+ import type { McpHttp } from './http.ts';
3
+ import type { Handler } from '../cli/http.ts';
4
+ export type Routes = {
5
+ mcp: string;
6
+ web: string;
7
+ quo?: string;
8
+ };
9
+ export declare function mcpRoute(dir: string, routes: Routes, mcp: McpHttp | null): Promise<{
10
+ oauth: OAuth;
11
+ handler: Handler;
12
+ }>;
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The mcp. route: the credential exchange and the MCP endpoint, served by
3
+ // the daemon under `/mcp` and mapped by the proxy from the mcp. hostname.
4
+ // The OAuth store is `<dir>/oauth.json`, the route's own and never a
5
+ // ward's: clients, pending requests, codes and tokens, each mapping to a
6
+ // client identity at most. A bearer names an identity in a world; the
7
+ // endpoint hands both to the model side, which runs beside her avatar.
8
+ import { readFile, writeFile, rename } from 'node:fs/promises';
9
+ import { existsSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { OAuth, emptyStore } from './oauth.js';
12
+ export async function mcpRoute(dir, routes, mcp) {
13
+ const file = join(dir, 'oauth.json');
14
+ const store = existsSync(file) ? { ...emptyStore(), ...JSON.parse(await readFile(file, 'utf8')) } : emptyStore();
15
+ let queue = Promise.resolve();
16
+ const persist = (s) => (queue = queue.then(async () => {
17
+ await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
18
+ await rename(file + '.tmp', file);
19
+ }));
20
+ const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/login?request=${id}`, store, persist });
21
+ const handler = async (req, res, rest) => {
22
+ if (await oauth.handle(req, res, rest))
23
+ return;
24
+ if (rest === '/mcp') {
25
+ const grant = oauth.bearer(req);
26
+ if (grant === null || !mcp)
27
+ return oauth.challenge(res);
28
+ return mcp.handle(req, res, grant.identity, grant.ward);
29
+ }
30
+ res.writeHead(404, { 'content-type': 'application/json' });
31
+ res.end(JSON.stringify({ error: 'no such route' }));
32
+ };
33
+ return { oauth, handler };
34
+ }
@@ -200,14 +200,17 @@ rotation, and nothing else.
200
200
 
201
201
  ## Reach, directory, rendezvous
202
202
 
203
- A harbor needs three reach kinds and nothing more:
203
+ A harbor needs two reach kinds and nothing more, which is what
204
+ `packages/quo/SPEC.md` says and what `packages/quo/src/harbor/reach.ts` is:
204
205
 
205
206
  - **request**: a URL. Send bytes, get bytes. Listener to listener.
206
207
  - **socket**: a held connection, opened by whichever side can dial, used in
207
208
  both directions with a frame id. A WebSocket, a WebRTC channel and a
208
209
  native socket are one socket reach.
209
- - **channel**: an in-process port between two runtimes on one device. Tabs
210
- on one machine, a webview and its sidecar.
210
+
211
+ Two runtimes on one device are no third kind. Either they are one harbor,
212
+ and it calls its other door with no transport at all, or they are two
213
+ harbors, and one of them dials the other as any dialer does.
211
214
 
212
215
  Plus one forwarding rule: a pk whose reach is another harbor that holds a
213
216
  socket to it. That rule is the **rendezvous**, and with it every dialer is
@@ -260,7 +263,8 @@ rebinds it.
260
263
  The reach interface and its two implementations are the library's,
261
264
  `packages/quo/src/harbor/reach.ts`, and pass its reach suite here: the request
262
265
  and a socket to a daemon in `packages/dock/test/wire.test.ts`, the request
263
- through the worker inside the edge. The listener half is the terrain's, below:
266
+ through the worker inside the edge. The listener half is the terrain's,
267
+ `packages/dock/harbor/quo.ts`, the route the daemon mounts under `/quo`:
264
268
 
265
269
  ```
266
270
  request POST <url>/<pk>, the sealed ask as the body, the sealed reply as the answer;
@@ -289,19 +293,18 @@ request. So a third harbor with a hint that a phone's pk is at acme's
289
293
  `quo serve --dial URL`, or `<dir>/dial.json`, makes a daemon a dialer: it
290
294
  holds one socket per URL, announces its wards on open, binds the listener's
291
295
  pks, and reconnects with backoff from a second to thirty when the line
292
- drops. A pk is 64 bytes on the wire, the ward key and the padlock, which the
293
- first version of the ask frame got wrong and the suite caught in one run.
296
+ drops. A pk is 64 bytes on the wire, the ward key and the padlock.
294
297
 
295
298
  The proof is `packages/dock/test/wire.test.ts`: the library's conformance suite,
296
299
  untouched, against two disk harbors in two folders with HTTP doors on
297
300
  loopback, once with every ask a request and once with the second harbor
298
301
  dialing the first; down is the reach refused, migrate is the ward folder
299
- moved and adopted. Then the rendezvous on its own. What it taught: a harbor
300
- must write the partition before it lets a folder go, because a being driven
301
- in-process changes it without passing a door; listeners left open keep a
302
- test process alive forever, so every pair is closed after the file; and a
303
- proxy sends the route root as `/quo/`, trailing slash, which the door takes.
304
- The first live crossing, Mac to lab and back, is in `estates/lab/lab.md`.
302
+ moved and adopted. Then the rendezvous on its own.
303
+
304
+ Two rules the daemon obeys and a reader would otherwise rediscover: a harbor
305
+ writes the partition before it lets a folder go, because a being driven
306
+ in-process changes it without passing a door; and a proxy sends the route
307
+ root as `/quo/`, with the trailing slash, which the door takes.
305
308
 
306
309
  ## The two cores, as built
307
310
 
@@ -338,7 +341,7 @@ harbors in one tab with two databases, both dialing a Node daemon on
338
341
  loopback, so every relation crosses the rendezvous; down is the reach
339
342
  refused on both, migrate lifts seed, partition and record from one database
340
343
  into the other and boots there, the tab-to-home scene. And the lock: a
341
- second harbor over one database does not boot. It passed on 2026-09-04.
344
+ second harbor over one database does not boot.
342
345
 
343
346
  ## The edge, as built
344
347
 
@@ -370,11 +373,10 @@ against two edge harbors in one object over two prefixed views of its
370
373
  storage, reaching each other by request through the worker; the root door
371
374
  refused without the secret and answering under it; and a Node daemon
372
375
  dialing the edge, its pk held there, reached by a third harbor through the
373
- edge, unreached when the line drops. What it taught: one object may not
374
- touch another's storage, the platform says so at the first put, and so the
375
- two harbors of the exercise share an object the way two tabs share a page;
376
- and a Uint8Array over a shared buffer is refused by the platform's crypto,
377
- which wants a plain one.
376
+ edge, unreached when the line drops. Two platform rules shape it: one object
377
+ may not touch another's storage, so the exercise's two harbors share one
378
+ object the way two tabs share a page; and the platform's crypto refuses a
379
+ Uint8Array over a shared buffer and wants a plain one.
378
380
 
379
381
  The `ws` question the road asked is answered by this terrain: the socket
380
382
  reach's framing and the dialer are written on the standard surface and
@@ -399,10 +401,10 @@ device
399
401
  quo.sock owner asks; local only; never behind a proxy
400
402
  side.sock local sides; local only; never behind a proxy
401
403
  127.0.0.1:8787 the HTTP door, loopback only; routes mount by path
402
- /health the wards it hosts, by name and pk
403
- /mcp the model side
404
- /web the bundle for tabs, and the exchange pages
405
- /quo later: the socket door: request in, sockets held, the rendezvous
404
+ /health the wards it hosts, by name and pk; cli/http.ts
405
+ /mcp the model side; mcp/route.ts
406
+ /web the worlds' pages and the exchange; human/web.ts, mcp/web/
407
+ /quo the socket door: request in, sockets held, the rendezvous; harbor/quo.ts
406
408
  reverse proxy
407
409
  mcp.example.com -> 127.0.0.1:8787/mcp
408
410
  web.example.com -> 127.0.0.1:8787/web
@@ -426,15 +428,13 @@ There is no `cli.` route.
426
428
 
427
429
  ## Acceptance
428
430
 
429
- Every harbor here is accepted the day it passes the library's conformance suite
430
- untouched, and by nothing else. The disk harbor over the wire passed it on
431
- 2026-09-04, under both reaches; the browser harbor through a daemon passed it
432
- the same day; the edge harbor inside the platform's runtime passed it the same
433
- day. Reach, store and the core now hold on three terrains, which is where the
434
- library takes them. Every store passes the library's store suite,
431
+ A harbor here is accepted when it passes the library's conformance suite
432
+ untouched, and by nothing else. Three do: the disk harbor over the wire
433
+ under both reaches, the browser harbor through a daemon, and the edge harbor
434
+ inside the platform's runtime. Every store passes the library's store suite,
435
435
  `packages/quo/src/conformance/store.ts`: files in
436
436
  `packages/dock/test/store.test.ts`, IndexedDB and the edge's storage inside
437
437
  their terrains behind `check:terrain`. Every reach passes the library's reach
438
438
  suite, `packages/quo/src/conformance/reach.ts`. The store, the reach and the
439
- core are in the library since 2026-09-04, each with `packages/quo/SPEC.md` in
440
- the same commit, and every harbor here is one of them over its own terrain.
439
+ core are the library's, and every harbor here is one of them over its own
440
+ terrain.