@quo-systems/dock 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +9 -2
  2. package/beings/avatar.ts +6 -2
  3. package/beings/carry.ts +101 -0
  4. package/beings/desk.ts +2 -2
  5. package/beings/index.ts +2 -0
  6. package/beings/link.ts +54 -0
  7. package/beings/look.ts +96 -0
  8. package/beings/quo-dock.md +185 -38
  9. package/beings/setup.ts +3 -1
  10. package/beings/side.ts +10 -1
  11. package/beings/user.ts +31 -5
  12. package/cli/daemon.ts +98 -44
  13. package/cli/estate/Caddyfile +25 -0
  14. package/cli/estate/quo.service +36 -0
  15. package/cli/estate.ts +44 -0
  16. package/cli/quo.ts +14 -3
  17. package/dist/beings/avatar.js +7 -2
  18. package/dist/beings/carry.d.ts +10 -0
  19. package/dist/beings/carry.js +106 -0
  20. package/dist/beings/desk.d.ts +1 -0
  21. package/dist/beings/desk.js +1 -1
  22. package/dist/beings/index.d.ts +2 -0
  23. package/dist/beings/index.js +2 -0
  24. package/dist/beings/link.d.ts +7 -0
  25. package/dist/beings/link.js +42 -0
  26. package/dist/beings/look.d.ts +27 -0
  27. package/dist/beings/look.js +71 -0
  28. package/dist/beings/setup.js +4 -1
  29. package/dist/beings/side.d.ts +8 -1
  30. package/dist/beings/user.d.ts +26 -2
  31. package/dist/beings/user.js +32 -5
  32. package/dist/cli/daemon.d.ts +1 -1
  33. package/dist/cli/daemon.js +98 -44
  34. package/dist/cli/estate/Caddyfile +25 -0
  35. package/dist/cli/estate/quo.service +36 -0
  36. package/dist/cli/estate.d.ts +5 -0
  37. package/dist/cli/estate.js +46 -0
  38. package/dist/cli/quo.js +14 -3
  39. package/dist/harbor/edge/exercise.js +3 -1
  40. package/dist/harbor/edge/platform.d.ts +28 -0
  41. package/dist/human/guest.d.ts +3 -0
  42. package/dist/human/guest.js +25 -0
  43. package/dist/human/html.d.ts +10 -2
  44. package/dist/human/html.js +61 -10
  45. package/dist/human/screen.d.ts +8 -3
  46. package/dist/human/screen.js +25 -6
  47. package/dist/human/tab.d.ts +2 -0
  48. package/dist/human/tab.js +127 -42
  49. package/dist/mcp/http.d.ts +4 -3
  50. package/dist/mcp/http.js +6 -6
  51. package/dist/mcp/oauth.d.ts +11 -4
  52. package/dist/mcp/oauth.js +32 -21
  53. package/dist/mcp/pilot.d.ts +3 -4
  54. package/dist/mcp/pilot.js +19 -65
  55. package/dist/mcp/server.d.ts +6 -4
  56. package/dist/mcp/server.js +48 -11
  57. package/dist/mcp/web/exchange.d.ts +5 -2
  58. package/dist/mcp/web/exchange.js +21 -7
  59. package/harbor/edge/exercise.ts +2 -1
  60. package/harbor/quo-harbor.md +37 -0
  61. package/human/guest.ts +26 -0
  62. package/human/html.ts +58 -10
  63. package/human/quo-human.md +123 -66
  64. package/human/screen.ts +28 -7
  65. package/human/tab.ts +153 -51
  66. package/mcp/http.ts +10 -9
  67. package/mcp/oauth.ts +39 -23
  68. package/mcp/pilot.ts +26 -65
  69. package/mcp/quo-mcp.md +34 -19
  70. package/mcp/server.ts +52 -19
  71. package/mcp/web/exchange.ts +23 -9
  72. package/package.json +7 -3
@@ -6,6 +6,7 @@ export type Client = {
6
6
  client_id: string;
7
7
  client_name: string;
8
8
  redirect_uris: string[];
9
+ exp: number;
9
10
  };
10
11
  export type Pending = {
11
12
  client_id: string;
@@ -17,6 +18,7 @@ export type Pending = {
17
18
  };
18
19
  export type Grant = {
19
20
  identity: string;
21
+ ward: string;
20
22
  client_id: string;
21
23
  exp: number;
22
24
  };
@@ -25,6 +27,7 @@ export type Store = {
25
27
  pending: Record<string, Pending>;
26
28
  codes: Record<string, Pending & {
27
29
  identity: string;
30
+ ward: string;
28
31
  }>;
29
32
  access: Record<string, Grant>;
30
33
  refresh: Record<string, Grant>;
@@ -62,6 +65,7 @@ export declare class OAuth {
62
65
  register(body: unknown): Promise<Client | {
63
66
  error: string;
64
67
  }>;
68
+ client(id: string): Client | undefined;
65
69
  authorize(q: URLSearchParams): Promise<{
66
70
  redirect: string;
67
71
  } | {
@@ -70,7 +74,7 @@ export declare class OAuth {
70
74
  pending(id: string): (Pending & {
71
75
  client: Client;
72
76
  }) | null;
73
- complete(id: string, identity: string): Promise<{
77
+ complete(id: string, identity: string, ward?: string): Promise<{
74
78
  redirect: string;
75
79
  } | {
76
80
  error: string;
@@ -81,15 +85,18 @@ export declare class OAuth {
81
85
  error: string;
82
86
  }>;
83
87
  token(body: URLSearchParams): Promise<Record<string, unknown>>;
84
- issue(identity: string, client_id: string): Promise<{
88
+ issue(identity: string, ward: string, client_id: string): Promise<{
85
89
  access_token: string;
86
90
  token_type: string;
87
91
  expires_in: number;
88
92
  refresh_token: string;
89
93
  scope: string;
90
94
  }>;
91
- bearer(req: IncomingMessage): string | null;
92
- 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>;
93
100
  sweep(): void;
94
101
  handle(req: IncomingMessage, res: ServerResponse, rest: string): Promise<boolean>;
95
102
  challenge(res: ServerResponse): void;
package/dist/mcp/oauth.js CHANGED
@@ -60,15 +60,22 @@ export class OAuth {
60
60
  const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u) => typeof u === 'string' && /^https?:\/\//.test(u)) : [];
61
61
  if (uris.length === 0)
62
62
  return { error: 'invalid_redirect_uri' };
63
- const client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris };
63
+ const client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris, exp: this.now() + CODE_TTL };
64
64
  this.store.clients[client.client_id] = client;
65
+ this.sweep();
65
66
  await this.o.persist(this.store);
66
- return client;
67
+ const { exp: _, ...shown } = client;
68
+ return shown;
69
+ }
70
+ // A client still alive, by id.
71
+ client(id) {
72
+ const c = own(this.store.clients, id);
73
+ return c && c.exp >= this.now() ? c : undefined;
67
74
  }
68
75
  // Start a request. What comes back is where to send the browser: the web
69
76
  // route with the request id, or the client's redirect with an error.
70
77
  async authorize(q) {
71
- const client = own(this.store.clients, q.get('client_id') ?? '');
78
+ const client = this.client(q.get('client_id') ?? '');
72
79
  const redirect = q.get('redirect_uri') ?? client?.redirect_uris[0] ?? null;
73
80
  if (!client || redirect === null || !client.redirect_uris.includes(redirect))
74
81
  return { error: 'invalid_client' };
@@ -89,17 +96,18 @@ export class OAuth {
89
96
  const p = own(this.store.pending, id);
90
97
  if (!p || p.exp < this.now())
91
98
  return null;
92
- const client = own(this.store.clients, p.client_id);
99
+ const client = this.client(p.client_id);
93
100
  return client ? { ...p, client } : null;
94
101
  }
95
- // The web route's last step: the human allowed this client as this identity.
96
- 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') {
97
105
  const p = this.pending(id);
98
106
  if (!p)
99
107
  return { error: 'expired' };
100
108
  delete this.store.pending[id];
101
109
  const code = token();
102
- this.store.codes[code] = { ...p, identity, exp: this.now() + CODE_TTL };
110
+ this.store.codes[code] = { ...p, identity, ward, exp: this.now() + CODE_TTL };
103
111
  await this.o.persist(this.store);
104
112
  return { redirect: withQuery(p.redirect_uri, { code, state: p.state }) };
105
113
  }
@@ -124,7 +132,7 @@ export class OAuth {
124
132
  await this.o.persist(this.store);
125
133
  return { error: 'invalid_grant' };
126
134
  }
127
- return this.issue(c.identity, c.client_id);
135
+ return this.issue(c.identity, c.ward ?? 'main', c.client_id);
128
136
  }
129
137
  if (grant === 'refresh_token') {
130
138
  const rt = body.get('refresh_token') ?? '';
@@ -132,40 +140,43 @@ export class OAuth {
132
140
  if (!r || r.exp < this.now())
133
141
  return { error: 'invalid_grant' };
134
142
  delete this.store.refresh[rt]; // rotated: the old one is gone with the new one's birth
135
- return this.issue(r.identity, r.client_id);
143
+ return this.issue(r.identity, r.ward ?? 'main', r.client_id);
136
144
  }
137
145
  return { error: 'unsupported_grant_type' };
138
146
  }
139
- async issue(identity, client_id) {
147
+ async issue(identity, ward, client_id) {
140
148
  const access_token = token(), refresh_token = token();
141
- this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
142
- 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 };
151
+ const c = own(this.store.clients, client_id);
152
+ if (c)
153
+ c.exp = this.now() + REFRESH_TTL; // allowed: the client lives as long as what it was granted
143
154
  this.sweep();
144
155
  await this.o.persist(this.store);
145
156
  return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
146
157
  }
147
- // 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.
148
159
  bearer(req) {
149
160
  const h = req.headers.authorization ?? '';
150
161
  const t = h.startsWith('Bearer ') ? h.slice(7) : '';
151
162
  const g = own(this.store.access, t);
152
- 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;
153
164
  }
154
- // Every grant an identity holds, gone: the route's half of revocation.
155
- // The other half is the user being removing the occupant.
156
- 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') {
157
168
  for (const k of ['access', 'refresh'])
158
169
  for (const [t, g] of Object.entries(this.store[k]))
159
- if (g.identity === identity)
170
+ if (g.identity === identity && (g.ward ?? 'main') === ward)
160
171
  delete this.store[k][t];
161
172
  await this.o.persist(this.store);
162
173
  }
163
174
  sweep() {
164
175
  const now = this.now();
165
- for (const k of ['pending', 'codes', 'access', 'refresh'])
176
+ for (const k of ['clients', 'pending', 'codes', 'access', 'refresh'])
166
177
  for (const [t, g] of Object.entries(this.store[k]))
167
- if (g.exp < now)
168
- delete this.store[k][t];
178
+ if (!(g.exp >= now))
179
+ delete this.store[k][t]; // a record with no exp is from before there was one, and goes too
169
180
  }
170
181
  // The HTTP face. `rest` is the path under the route.
171
182
  async handle(req, res, rest) {
@@ -1,6 +1,5 @@
1
1
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
- import { type Tool } from '@modelcontextprotocol/sdk/types.js';
3
2
  import type { Serving } from '../beings/side.ts';
4
- export declare const PILOT_TOOLS: Tool[];
5
- export type Log = (line: string) => void;
6
- export declare function pilotSide(dir: string, ward: string, transport: Transport, log?: Log, via?: string): Promise<Serving>;
3
+ import { type Subject } from './server.ts';
4
+ export declare function owner(dir: string, ward: string, via?: string): Subject;
5
+ export declare function pilotSide(dir: string, ward: string, transport: Transport, via?: string): Promise<Serving>;
package/dist/mcp/pilot.js CHANGED
@@ -1,68 +1,22 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- // The owner pilot: `quo pilot`. An MCP server over stdio that reaches a
3
- // ward's owner asks through the daemon's root socket, and exposes four tools
4
- // and no more, plus the one read. Whoever runs this process is the owner, by
5
- // the device's own rules: the root of the ward here, or, with `via`, an
6
- // owner at another ward's door, on a standing the user being here holds
7
- // there. Every call is logged with what it made. The owner creates and
8
- // places; the work goes through `quo side`, under a gate.
9
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
10
- import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
11
1
  import { ask } from '../cli/client.js';
12
- export const PILOT_TOOLS = [
13
- {
14
- name: 'census',
15
- description: 'the empty ask: the ward pk and every being, with class, public and digest',
16
- inputSchema: { type: 'object', properties: {} },
17
- },
18
- {
19
- name: 'boot',
20
- description: 'boot a being by class name under a key; public marks the one public being',
21
- inputSchema: { type: 'object', properties: { key: { type: 'string' }, class: { type: 'string' }, public: { type: 'boolean' } }, required: ['key', 'class'] },
22
- },
23
- {
24
- name: 'invite',
25
- description: 'mint an invitation on a being of the ward, under an id she will know the occupant by',
26
- inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
27
- },
28
- {
29
- name: 'knock',
30
- description: 'knock for a being of the ward with an invitation, and take the standing under id if answered',
31
- inputSchema: {
32
- type: 'object',
33
- properties: {
34
- being: { description: 'a key already booted, or { boot: class, key } to boot her first' },
35
- id: { type: 'string' },
36
- invitation: { type: 'object' },
37
- method: { type: 'string' },
38
- args: { type: 'object' },
39
- wanted: { type: 'object', properties: { time: { type: 'number' } } },
40
- },
41
- required: ['being', 'id', 'invitation'],
42
- },
43
- },
44
- {
45
- name: 'remove',
46
- description: 'take a relation out of a being of the ward by id, occupant or standing; on the ward pk, an owner, by the root alone',
47
- inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
48
- },
49
- ];
2
+ import { mcpSide } from './server.js';
50
3
  const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
51
- export async function pilotSide(dir, ward, transport, log = () => { }, via) {
52
- const server = new Server({ name: 'quo-pilot', version: '0.0.0' }, { capabilities: { tools: {} } });
53
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: PILOT_TOOLS }));
54
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
55
- const name = req.params.name;
56
- const args = req.params.arguments ?? {};
57
- if (!PILOT_TOOLS.some((t) => t.name === name))
58
- return { content: [{ type: 'text', text: JSON.stringify({ error: 'unknown ask' }) }], structuredContent: { error: 'unknown ask' }, isError: true };
59
- const out = await ask(dir, name === 'census' ? undefined : name, args, ward, via);
60
- const value = 'error' in out ? { error: out.error } : out.result;
61
- const failed = 'error' in out || (typeof value === 'object' && value !== null && 'error' in value);
62
- log(`${via === undefined ? ward : `${ward} via ${via}`} ${name} ${JSON.stringify(args)} -> ${JSON.stringify(value)}`);
63
- const structured = isRecord(value) ? { structuredContent: value } : {};
64
- return { content: [{ type: 'text', text: JSON.stringify(value) }], ...structured, ...(failed ? { isError: true } : {}) };
65
- });
66
- await server.connect(transport);
67
- return { close: () => server.close() };
4
+ // The owner hears objects: the socket's own failure, a silence and a word
5
+ // each come back as an error object named for what it was.
6
+ export function owner(dir, ward, via) {
7
+ const one = async (method, args) => {
8
+ const out = await ask(dir, method, args, ward, via);
9
+ if ('error' in out)
10
+ return { error: out.error };
11
+ const r = out.result;
12
+ if (isRecord(r) && r.silence === true)
13
+ return { error: 'silence' };
14
+ if (isRecord(r) && typeof r.word === 'string')
15
+ return { error: r.word };
16
+ return r;
17
+ };
18
+ return { tools: () => one(undefined, {}), call: (name, args) => one(name, args ?? {}), ears: new Set() };
19
+ }
20
+ export function pilotSide(dir, ward, transport, via) {
21
+ return mcpSide(owner(dir, ward, via), transport);
68
22
  }
@@ -1,9 +1,11 @@
1
1
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
2
  import { type Tool } from '@modelcontextprotocol/sdk/types.js';
3
3
  import type { Blueprint } from '@quo-systems/quo';
4
- import type { Avatar } from '../beings/avatar.ts';
5
- import { type Serving } from '../beings/side.ts';
4
+ import { type Serving, type Subject } from '../beings/side.ts';
5
+ import { type Look } from '../beings/look.ts';
6
6
  export declare const NAME = "quo";
7
7
  export declare const VERSION = "0.0.0";
8
- export declare function tools(bp: Blueprint): Tool[];
9
- export declare function mcpSide(avatar: Avatar, transport: Transport, after?: () => Promise<void>): Promise<Serving>;
8
+ export type { Subject } from '../beings/side.ts';
9
+ export declare const DESCRIBE: Tool;
10
+ export declare function tools(bp: Blueprint, look?: Look): Tool[];
11
+ export declare function mcpSide(avatar: Subject, transport: Transport, after?: () => Promise<void>): Promise<Serving>;
@@ -1,39 +1,76 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The model side as an MCP server: one avatar, one transport. The mapping is
3
- // total and needs nothing beyond this envelope. tools/list is her describe,
4
- // tools/call is a named ask on her one standing, a push is a logging
5
- // notification, and the three words for "no object" each cross as the table
6
- // in quo-mcp.md says. The transport is whatever the caller connected: the
2
+ // The model side as an MCP server: one subject, one transport. The subject
3
+ // is whoever answers the empty ask and a named one: an avatar on her one
4
+ // standing, or a ward's owner asks through the pilot. The mapping is total
5
+ // and needs nothing beyond this envelope. tools/list is her describe, and
6
+ // the empty ask itself is the first tool, so the notes of the describe are
7
+ // readable; tools/call is a named ask, a push is a logging notification,
8
+ // and the three words for "no object" each cross as the table in
9
+ // quo-mcp.md says. The transport is whatever the caller connected: the
7
10
  // SDK's in-memory pair in a test, stdio for a local client, HTTP on a route.
8
11
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
12
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
10
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';
11
16
  export const NAME = 'quo';
12
17
  export const VERSION = '0.0.0';
13
- // Her describe, spoken as tools. Name, description and input are verbatim;
14
- // an output schema crosses when she declared one.
15
- export function tools(bp) {
16
- return bp.asks.map((a) => {
18
+ // The empty ask as a tool: her describe whole, asks and notes.
19
+ export const DESCRIBE = { name: 'describe', description: 'the empty ask: her describe, the asks and the notes', inputSchema: { type: 'object' } };
20
+ // Her describe, spoken as tools, the empty ask first. Name, description and
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) => {
17
28
  const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
18
29
  if (a.description !== undefined)
19
30
  t.description = a.description;
20
31
  if (a.output !== undefined)
21
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;
22
45
  return t;
23
46
  });
47
+ return [DESCRIBE, ...asks];
24
48
  }
25
49
  // `after` runs when a call is done: a harbor that must write what the ward
26
50
  // changed hooks it, since a same-ward ask never crosses the harbor.
27
51
  export async function mcpSide(avatar, transport, after = async () => { }) {
28
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 = {};
29
56
  server.setRequestHandler(ListToolsRequestSchema, async () => {
30
57
  const bp = await avatar.tools();
31
58
  if ('error' in bp && !('asks' in bp))
32
59
  return { tools: [] }; // not joined: nothing to show, and nothing to call
33
- 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) };
34
71
  });
35
72
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
36
- const w = word(await avatar.call(req.params.name, (req.params.arguments ?? {})));
73
+ const w = word(req.params.name === DESCRIBE.name ? await avatar.tools() : await avatar.call(req.params.name, (req.params.arguments ?? {})));
37
74
  await after();
38
75
  if (w.word === 'object') {
39
76
  const structured = w.value !== null && typeof w.value === 'object' && !Array.isArray(w.value) ? { structuredContent: w.value } : {};
@@ -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
+ };
@@ -70,7 +70,8 @@ function probe(base: string, state: State, env: Env): () => Promise<World> {
70
70
  learn(i, w);
71
71
  }
72
72
  wardOf.set(key, name);
73
- assert.deepEqual(await w.ask('boot', { key, class: Class.name, public: isPublic }), { booted: key });
73
+ assert.deepEqual(await w.ask('boot', { key, class: Class.name }), { booted: key });
74
+ if (isPublic) assert.deepEqual(await w.ask('public', { key }), { public: key });
74
75
  if (Object.keys(cells).length) Object.assign(at(name).p.beings[key]!, cells);
75
76
  return {
76
77
  pk: w.pk,
@@ -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
@@ -235,6 +267,11 @@ request POST <url>/<pk>, the sealed ask as the body, the sealed reply as th
235
267
  <url> is the world's quo. route, which the proxy maps onto the daemon's /quo
236
268
  404 is "no reach for that pk" and comes back as nothing; a line lost after
237
269
  sending answers nothing at all, and the ward's bound ends the ask
270
+ at most 1 MiB is read, and a body over it is answered by that same 404: an
271
+ ask too big for this door is nothing delivered, never a silence, so it is
272
+ safe to send again and it will fail again. The number is this daemon's and
273
+ is on no wire; a caller cannot ask for it and is never told which of the
274
+ two a 404 was
238
275
  socket a WebSocket at <url>, held by the dialer, used both ways
239
276
  text frame { announce: [pk, ...] } the ward pks this side holds
240
277
  ask frame [0][id 4][pk 64][bytes] a frame id matches the reply
package/human/guest.ts ADDED
@@ -0,0 +1,26 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A guest at a world's door: the world's public being, whatever class she
3
+ // is, as a subject the screen can render. Her describe for a stranger is the
4
+ // guest page; a form on it is a knock on the public invitation; and an
5
+ // answer that is an invitation is the way in, which the tab takes by
6
+ // joining. Nothing here knows the desk: the desk's `device` is one ask
7
+ // that answers an invitation, and any being may write another.
8
+ import { isSilence, isWord, wordOf } from '@quo-systems/quo';
9
+ import type { Answer, Blueprint, Invitation, JsonObject, Wanted } from '@quo-systems/quo';
10
+ import type { Avatar } from '../beings/avatar.ts';
11
+ import type { Subject } from '../beings/side.ts';
12
+
13
+ export function guest(avatar: Avatar, ward: string): Subject {
14
+ const at: Invitation = { ward };
15
+ return {
16
+ tools: async () => {
17
+ const bp = await avatar.knock(at);
18
+ if (isSilence(bp)) return { error: 'silence' };
19
+ if (isWord(bp)) return { error: wordOf(bp) };
20
+ if (bp === null || typeof bp !== 'object' || Array.isArray(bp) || !Array.isArray((bp as Blueprint).asks)) return { error: 'nobody is home' };
21
+ return bp as Blueprint;
22
+ },
23
+ call: (name: string, args: JsonObject = {}, wanted?: Wanted): Promise<Answer> => avatar.knock(at, name, args, wanted),
24
+ ears: new Set(),
25
+ };
26
+ }