@quo-systems/dock 0.2.0 → 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.
Files changed (76) hide show
  1. package/beings/avatar.ts +9 -3
  2. package/beings/carry.ts +101 -0
  3. package/beings/desk.ts +2 -2
  4. package/beings/index.ts +3 -1
  5. package/beings/link.ts +54 -0
  6. package/beings/look.ts +96 -0
  7. package/beings/quo-dock.md +163 -277
  8. package/beings/side.ts +10 -1
  9. package/beings/user.ts +67 -13
  10. package/cli/daemon.ts +58 -251
  11. package/cli/http.ts +70 -0
  12. package/cli/quo.ts +13 -5
  13. package/dist/beings/avatar.js +10 -3
  14. package/dist/beings/carry.d.ts +10 -0
  15. package/dist/beings/carry.js +106 -0
  16. package/dist/beings/desk.d.ts +1 -0
  17. package/dist/beings/desk.js +1 -1
  18. package/dist/beings/index.d.ts +2 -0
  19. package/dist/beings/index.js +3 -1
  20. package/dist/beings/link.d.ts +7 -0
  21. package/dist/beings/link.js +42 -0
  22. package/dist/beings/look.d.ts +27 -0
  23. package/dist/beings/look.js +71 -0
  24. package/dist/beings/side.d.ts +8 -1
  25. package/dist/beings/user.d.ts +29 -3
  26. package/dist/beings/user.js +71 -14
  27. package/dist/cli/daemon.d.ts +7 -19
  28. package/dist/cli/daemon.js +51 -240
  29. package/dist/cli/http.d.ts +17 -0
  30. package/dist/cli/http.js +60 -0
  31. package/dist/cli/quo.js +15 -5
  32. package/dist/harbor/quo.d.ts +4 -0
  33. package/dist/harbor/quo.js +53 -0
  34. package/dist/human/door.d.ts +5 -0
  35. package/dist/human/door.js +19 -0
  36. package/dist/human/guest.d.ts +3 -0
  37. package/dist/human/guest.js +25 -0
  38. package/dist/human/html.d.ts +10 -2
  39. package/dist/human/html.js +61 -10
  40. package/dist/human/screen.d.ts +8 -3
  41. package/dist/human/screen.js +26 -6
  42. package/dist/human/tab.d.ts +5 -0
  43. package/dist/human/tab.js +161 -43
  44. package/dist/human/web.d.ts +9 -0
  45. package/dist/human/web.js +95 -0
  46. package/dist/human/worlds.d.ts +10 -0
  47. package/dist/human/worlds.js +38 -0
  48. package/dist/mcp/http.d.ts +4 -3
  49. package/dist/mcp/http.js +6 -6
  50. package/dist/mcp/oauth.d.ts +9 -4
  51. package/dist/mcp/oauth.js +15 -14
  52. package/dist/mcp/pilot.js +1 -1
  53. package/dist/mcp/route.d.ts +12 -0
  54. package/dist/mcp/route.js +34 -0
  55. package/dist/mcp/server.d.ts +5 -10
  56. package/dist/mcp/server.js +35 -4
  57. package/dist/mcp/web/exchange.d.ts +5 -2
  58. package/dist/mcp/web/exchange.js +21 -7
  59. package/harbor/quo-harbor.md +67 -30
  60. package/harbor/quo.ts +67 -0
  61. package/human/door.ts +26 -0
  62. package/human/guest.ts +26 -0
  63. package/human/html.ts +58 -10
  64. package/human/quo-human.md +158 -67
  65. package/human/screen.ts +34 -8
  66. package/human/tab.ts +193 -53
  67. package/human/web.ts +123 -0
  68. package/human/worlds.ts +52 -0
  69. package/mcp/http.ts +10 -9
  70. package/mcp/oauth.ts +20 -17
  71. package/mcp/pilot.ts +1 -1
  72. package/mcp/quo-mcp.md +20 -2
  73. package/mcp/route.ts +39 -0
  74. package/mcp/server.ts +37 -18
  75. package/mcp/web/exchange.ts +23 -9
  76. package/package.json +2 -2
@@ -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);
@@ -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
+ }
@@ -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>;
@@ -11,19 +11,37 @@
11
11
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
12
12
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
13
13
  import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
14
+ import { hintFor, sanitise } from '../beings/look.js';
15
+ import { isSilence, isWord, digest } from '@quo-systems/quo';
14
16
  export const NAME = 'quo';
15
17
  export const VERSION = '0.0.0';
16
18
  // The empty ask as a tool: her describe whole, asks and notes.
17
19
  export const DESCRIBE = { name: 'describe', description: 'the empty ask: her describe, the asks and the notes', inputSchema: { type: 'object' } };
18
20
  // Her describe, spoken as tools, the empty ask first. Name, description and
19
- // input are verbatim; an output schema crosses when she declared one.
20
- export function tools(bp) {
21
- const asks = bp.asks.map((a) => {
21
+ // input are verbatim; an output schema crosses when she declared one. Her
22
+ // look, when she has one, is the hints: a title, and the annotations a host
23
+ // reads. The `look` ask itself is presentation, and is not a tool.
24
+ export function tools(bp, look = {}) {
25
+ const asks = bp.asks
26
+ .filter((a) => a.name !== 'look')
27
+ .map((a) => {
22
28
  const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
23
29
  if (a.description !== undefined)
24
30
  t.description = a.description;
25
31
  if (a.output !== undefined)
26
32
  t.outputSchema = { ...a.output, type: 'object' };
33
+ const h = hintFor(bp, look, a.name);
34
+ if (h.title !== undefined)
35
+ t.title = h.title;
36
+ const notes = {};
37
+ if (h.readOnly !== undefined)
38
+ notes.readOnlyHint = h.readOnly;
39
+ if (h.destructive !== undefined)
40
+ notes.destructiveHint = h.destructive;
41
+ if (h.idempotent !== undefined)
42
+ notes.idempotentHint = h.idempotent;
43
+ if (Object.keys(notes).length)
44
+ t.annotations = notes;
27
45
  return t;
28
46
  });
29
47
  return [DESCRIBE, ...asks];
@@ -32,11 +50,24 @@ export function tools(bp) {
32
50
  // changed hooks it, since a same-ward ask never crosses the harbor.
33
51
  export async function mcpSide(avatar, transport, after = async () => { }) {
34
52
  const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: { listChanged: true }, logging: {} } });
53
+ // Her look, asked once per digest of her describe.
54
+ let seen = null;
55
+ let look = {};
35
56
  server.setRequestHandler(ListToolsRequestSchema, async () => {
36
57
  const bp = await avatar.tools();
37
58
  if ('error' in bp && !('asks' in bp))
38
59
  return { tools: [] }; // not joined: nothing to show, and nothing to call
39
- return { tools: tools(bp) };
60
+ const d = await digest(bp);
61
+ if (d !== seen) {
62
+ seen = d;
63
+ if (bp.asks.some((a) => a.name === 'look')) {
64
+ const l = await avatar.call('look', {});
65
+ look = isSilence(l) || isWord(l) ? {} : sanitise(l);
66
+ }
67
+ else
68
+ look = {};
69
+ }
70
+ return { tools: tools(bp, look) };
40
71
  });
41
72
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
42
73
  const w = word(req.params.name === DESCRIBE.name ? await avatar.tools() : await avatar.call(req.params.name, (req.params.arguments ?? {})));
@@ -1,14 +1,17 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import type { OAuth } from '../oauth.ts';
3
3
  export declare const SESSION_TTL: number;
4
- export type Admit = (identity: string, wake: boolean) => Promise<{
4
+ export type Admit = (identity: string, wake: boolean, reach: boolean, ward: string) => Promise<{
5
5
  error?: string;
6
6
  }>;
7
7
  export type Options = {
8
8
  oauth: OAuth;
9
9
  password: () => string | undefined;
10
10
  admit: Admit;
11
- user: string;
11
+ worlds: () => {
12
+ ward: string;
13
+ user: string;
14
+ }[];
12
15
  now?: () => number;
13
16
  };
14
17
  export declare const suggest: (name: string) => string;
@@ -90,7 +90,7 @@ export class Exchange {
90
90
  return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`), true;
91
91
  if (!this.valid(req))
92
92
  return go(`/login?request=${encodeURIComponent(request)}`), true;
93
- return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.user)), true;
93
+ return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.worlds())), true;
94
94
  }
95
95
  if (rest === '/allow' && req.method === 'POST') {
96
96
  const f = await readForm(req);
@@ -104,13 +104,17 @@ export class Exchange {
104
104
  const out = await this.o.oauth.deny(request);
105
105
  return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`), true;
106
106
  }
107
+ const worlds = this.o.worlds();
108
+ const world = worlds.find((w) => w.ward === (f.get('ward') ?? worlds[0]?.ward));
109
+ if (!world)
110
+ return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), worlds, 'That is not a world of this harbor.')), true;
107
111
  const identity = word(f.get('identity'));
108
- if (identity === null || identity === this.o.user || identity === 'desk')
109
- return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.user, 'An identity is one word, and not the user or the desk.')), true;
110
- const admitted = await this.o.admit(identity, f.get('wake') === 'on');
112
+ if (identity === null || identity === world.user || identity === 'desk')
113
+ return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), worlds, 'An identity is one word, and not the user or the desk.')), true;
114
+ const admitted = await this.o.admit(identity, f.get('wake') === 'on', f.get('reach') === 'on', world.ward);
111
115
  if (admitted.error)
112
116
  return page(500, `<h1>Not admitted</h1><p>${esc(admitted.error)}</p>`), true;
113
- const out = await this.o.oauth.complete(request, identity);
117
+ const out = await this.o.oauth.complete(request, identity, world.ward);
114
118
  return 'redirect' in out ? go(out.redirect, { 'set-cookie': `${COOKIE}=; Path=/; Max-Age=0` }) : page(400, `<h1>Gone</h1>`), true;
115
119
  }
116
120
  return false;
@@ -125,12 +129,22 @@ ${err ? `<p class="err">${esc(err)}</p>` : ''}
125
129
  <form method="post" action="/login"><input type="hidden" name="request" value="${esc(request)}">
126
130
  <label for="p">Owner password</label><input id="p" name="password" type="password" autocomplete="current-password" autofocus required>
127
131
  <button type="submit">Log in</button></form>`;
128
- const allowForm = (request, client, redirect, identity, user, err = '') => `<h1>Allow ${esc(client)}?</h1>
129
- <div class="who"><p><strong>${esc(client)}</strong> asks to be an occupant of <strong>${esc(user)}</strong>.</p>
132
+ // The world is a choice when the harbor has more than one; the user named
133
+ // is the first world's, and the page says which world each identity lands in.
134
+ const allowForm = (request, client, redirect, identity, worlds, err = '') => {
135
+ const user = worlds[0]?.user ?? '';
136
+ const pick = worlds.length > 1
137
+ ? `<label for="wd">World</label><select id="wd" name="ward">${worlds.map((w) => `<option value="${esc(w.ward)}">${esc(w.ward)}, ${esc(w.user)}'s</option>`).join('')}</select>`
138
+ : `<input type="hidden" name="ward" value="${esc(worlds[0]?.ward ?? 'main')}">`;
139
+ return `<h1>Allow ${esc(client)}?</h1>
140
+ <div class="who"><p><strong>${esc(client)}</strong> asks to be an occupant of <strong>${esc(user)}</strong>${worlds.length > 1 ? ', or of another world below' : ''}.</p>
130
141
  <p>It will see exactly what ${esc(user)} shows the identity below, and nothing else. You can remove it any time.</p>
131
142
  <p>It returns to <code>${esc(redirect)}</code>.</p></div>
132
143
  ${err ? `<p class="err">${esc(err)}</p>` : ''}
133
144
  <form method="post" action="/allow"><input type="hidden" name="request" value="${esc(request)}">
145
+ ${pick}
134
146
  <label for="i">Identity</label><input id="i" name="identity" value="${esc(identity)}" pattern="[\\w.-]{1,40}" required>
147
+ <label for="r"><input id="r" name="reach" type="checkbox" style="width:auto"> May reach what ${esc(user)} holds: her standings, acme and the rest, as asks of hers</label>
135
148
  <label for="w"><input id="w" name="wake" type="checkbox" style="width:auto"> May wake your other devices: hand an agent an event through ${esc(user)}</label>
136
149
  <button type="submit" name="decision" value="allow">Allow</button><button type="submit" name="decision" value="deny">Deny</button></form>`;
150
+ };
@@ -88,6 +88,38 @@ the new harbor pointed at the same code. Same seed, same pk, every standing
88
88
  anyone holds still points at her. The old harbor deletes its copies first:
89
89
  two harbors holding one seed are two wards with one pk, diverging silently.
90
90
 
91
+ What a move costs is not the three parts, which are bytes, but how many
92
+ peers wrote down where the ward was. A pk is permanent by arithmetic; a
93
+ location is soft state in every other harbor's directory, and only that goes
94
+ stale. So the bill is proportional to who hard-coded it, and there are two
95
+ kinds of ward.
96
+
97
+ A ward whose harbor dials out is free. It announces what it holds when a
98
+ line opens and whenever that changes, the listener proves each claim at the
99
+ door and binds it, and a ward that arrives on a new box is reachable as soon
100
+ as its harbor dials. Nobody is told and no peer acts.
101
+
102
+ A ward that is dialed keeps a second name. Peers hold a hint, pk at a URL,
103
+ in their own stores and across their own restarts, so that URL is a promise
104
+ the same way the pk is. Moving boxes does not break it, because a hostname
105
+ is not a location: DNS and the proxy point it wherever the harbor now runs,
106
+ which is the whole reason the route is a hostname and not an address.
107
+ Changing the URL itself is the move that costs, and it cannot be announced
108
+ to a peer this ward cannot reach.
109
+
110
+ When it must change, what is republished is a hint and never an invitation.
111
+ The relation, its keys and its count are untouched, and the invitation is
112
+ as good as it was: that is why the two travel separately, a link being one
113
+ next to the other. Peers on a rendezvous this ward also dials relearn by
114
+ themselves, since a binding is proven at the door and names no URL; peers
115
+ holding a hint need a new one, and a hint is not a capability, so it may be
116
+ published in the open to all of them at once. Nobody is invited twice.
117
+
118
+ The rule, then: a dialable ward keeps two names and owes the second the same
119
+ care as the first, and a ward that only dials keeps one, which is why a
120
+ laptop, a tab and a phone move home without ceremony and the droplet does
121
+ not.
122
+
91
123
  ### Code is a harbor decision
92
124
 
93
125
  A ward's code has an origin: a folder the owner wrote, a bundle a world
@@ -168,14 +200,17 @@ rotation, and nothing else.
168
200
 
169
201
  ## Reach, directory, rendezvous
170
202
 
171
- 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:
172
205
 
173
206
  - **request**: a URL. Send bytes, get bytes. Listener to listener.
174
207
  - **socket**: a held connection, opened by whichever side can dial, used in
175
208
  both directions with a frame id. A WebSocket, a WebRTC channel and a
176
209
  native socket are one socket reach.
177
- - **channel**: an in-process port between two runtimes on one device. Tabs
178
- 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.
179
214
 
180
215
  Plus one forwarding rule: a pk whose reach is another harbor that holds a
181
216
  socket to it. That rule is the **rendezvous**, and with it every dialer is
@@ -228,13 +263,19 @@ rebinds it.
228
263
  The reach interface and its two implementations are the library's,
229
264
  `packages/quo/src/harbor/reach.ts`, and pass its reach suite here: the request
230
265
  and a socket to a daemon in `packages/dock/test/wire.test.ts`, the request
231
- 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`:
232
268
 
233
269
  ```
234
270
  request POST <url>/<pk>, the sealed ask as the body, the sealed reply as the answer;
235
271
  <url> is the world's quo. route, which the proxy maps onto the daemon's /quo
236
272
  404 is "no reach for that pk" and comes back as nothing; a line lost after
237
273
  sending answers nothing at all, and the ward's bound ends the ask
274
+ at most 1 MiB is read, and a body over it is answered by that same 404: an
275
+ ask too big for this door is nothing delivered, never a silence, so it is
276
+ safe to send again and it will fail again. The number is this daemon's and
277
+ is on no wire; a caller cannot ask for it and is never told which of the
278
+ two a 404 was
238
279
  socket a WebSocket at <url>, held by the dialer, used both ways
239
280
  text frame { announce: [pk, ...] } the ward pks this side holds
240
281
  ask frame [0][id 4][pk 64][bytes] a frame id matches the reply
@@ -252,19 +293,18 @@ request. So a third harbor with a hint that a phone's pk is at acme's
252
293
  `quo serve --dial URL`, or `<dir>/dial.json`, makes a daemon a dialer: it
253
294
  holds one socket per URL, announces its wards on open, binds the listener's
254
295
  pks, and reconnects with backoff from a second to thirty when the line
255
- drops. A pk is 64 bytes on the wire, the ward key and the padlock, which the
256
- 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.
257
297
 
258
298
  The proof is `packages/dock/test/wire.test.ts`: the library's conformance suite,
259
299
  untouched, against two disk harbors in two folders with HTTP doors on
260
300
  loopback, once with every ask a request and once with the second harbor
261
301
  dialing the first; down is the reach refused, migrate is the ward folder
262
- moved and adopted. Then the rendezvous on its own. What it taught: a harbor
263
- must write the partition before it lets a folder go, because a being driven
264
- in-process changes it without passing a door; listeners left open keep a
265
- test process alive forever, so every pair is closed after the file; and a
266
- proxy sends the route root as `/quo/`, trailing slash, which the door takes.
267
- 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.
268
308
 
269
309
  ## The two cores, as built
270
310
 
@@ -301,7 +341,7 @@ harbors in one tab with two databases, both dialing a Node daemon on
301
341
  loopback, so every relation crosses the rendezvous; down is the reach
302
342
  refused on both, migrate lifts seed, partition and record from one database
303
343
  into the other and boots there, the tab-to-home scene. And the lock: a
304
- second harbor over one database does not boot. It passed on 2026-09-04.
344
+ second harbor over one database does not boot.
305
345
 
306
346
  ## The edge, as built
307
347
 
@@ -333,11 +373,10 @@ against two edge harbors in one object over two prefixed views of its
333
373
  storage, reaching each other by request through the worker; the root door
334
374
  refused without the secret and answering under it; and a Node daemon
335
375
  dialing the edge, its pk held there, reached by a third harbor through the
336
- edge, unreached when the line drops. What it taught: one object may not
337
- touch another's storage, the platform says so at the first put, and so the
338
- two harbors of the exercise share an object the way two tabs share a page;
339
- and a Uint8Array over a shared buffer is refused by the platform's crypto,
340
- 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.
341
380
 
342
381
  The `ws` question the road asked is answered by this terrain: the socket
343
382
  reach's framing and the dialer are written on the standard surface and
@@ -362,10 +401,10 @@ device
362
401
  quo.sock owner asks; local only; never behind a proxy
363
402
  side.sock local sides; local only; never behind a proxy
364
403
  127.0.0.1:8787 the HTTP door, loopback only; routes mount by path
365
- /health the wards it hosts, by name and pk
366
- /mcp the model side
367
- /web the bundle for tabs, and the exchange pages
368
- /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
369
408
  reverse proxy
370
409
  mcp.example.com -> 127.0.0.1:8787/mcp
371
410
  web.example.com -> 127.0.0.1:8787/web
@@ -389,15 +428,13 @@ There is no `cli.` route.
389
428
 
390
429
  ## Acceptance
391
430
 
392
- Every harbor here is accepted the day it passes the library's conformance suite
393
- untouched, and by nothing else. The disk harbor over the wire passed it on
394
- 2026-09-04, under both reaches; the browser harbor through a daemon passed it
395
- the same day; the edge harbor inside the platform's runtime passed it the same
396
- day. Reach, store and the core now hold on three terrains, which is where the
397
- 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,
398
435
  `packages/quo/src/conformance/store.ts`: files in
399
436
  `packages/dock/test/store.test.ts`, IndexedDB and the edge's storage inside
400
437
  their terrains behind `check:terrain`. Every reach passes the library's reach
401
438
  suite, `packages/quo/src/conformance/reach.ts`. The store, the reach and the
402
- core are in the library since 2026-09-04, each with `packages/quo/SPEC.md` in
403
- 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.