@quo-systems/dock 0.2.2 → 0.2.4

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 (68) hide show
  1. package/beings/avatar.ts +8 -1
  2. package/beings/carry.ts +1 -1
  3. package/beings/quo-dock.md +53 -39
  4. package/beings/setup.ts +2 -1
  5. package/beings/user.ts +48 -1
  6. package/cli/daemon.ts +51 -25
  7. package/cli/estate/quo.service +0 -1
  8. package/dist/beings/avatar.js +9 -2
  9. package/dist/beings/carry.js +2 -2
  10. package/dist/beings/setup.js +2 -1
  11. package/dist/beings/user.d.ts +89 -0
  12. package/dist/beings/user.js +47 -1
  13. package/dist/cli/daemon.d.ts +10 -2
  14. package/dist/cli/daemon.js +51 -23
  15. package/dist/cli/estate/quo.service +0 -1
  16. package/dist/harbor/capacitor.d.ts +16 -0
  17. package/dist/harbor/capacitor.js +135 -0
  18. package/dist/harbor/disk.js +8 -3
  19. package/dist/harbor/edge/edge.js +2 -1
  20. package/dist/harbor/edge/exercise.js +2 -1
  21. package/dist/harbor/edge/storage.d.ts +0 -3
  22. package/dist/harbor/edge/storage.js +6 -25
  23. package/dist/harbor/files.d.ts +2 -1
  24. package/dist/harbor/files.js +87 -22
  25. package/dist/harbor/seal.d.ts +3 -0
  26. package/dist/harbor/seal.js +25 -0
  27. package/dist/human/door.d.ts +2 -0
  28. package/dist/human/door.js +19 -7
  29. package/dist/human/html.d.ts +2 -0
  30. package/dist/human/html.js +43 -17
  31. package/dist/human/local.d.ts +10 -0
  32. package/dist/human/local.js +10 -0
  33. package/dist/human/screen.js +26 -8
  34. package/dist/human/tab.d.ts +1 -0
  35. package/dist/human/tab.js +59 -3
  36. package/dist/human/tree.d.ts +40 -0
  37. package/dist/human/tree.js +89 -0
  38. package/dist/human/web.d.ts +1 -0
  39. package/dist/human/web.js +36 -8
  40. package/dist/mcp/oauth.js +2 -2
  41. package/dist/mcp/route.js +1 -1
  42. package/dist/mcp/runner.js +5 -2
  43. package/dist/mcp/server.js +1 -1
  44. package/dist/mcp/web/exchange.d.ts +5 -9
  45. package/dist/mcp/web/exchange.js +47 -98
  46. package/harbor/capacitor.ts +142 -0
  47. package/harbor/disk.ts +8 -3
  48. package/harbor/edge/edge.ts +2 -1
  49. package/harbor/edge/exercise.ts +2 -1
  50. package/harbor/edge/storage.ts +6 -25
  51. package/harbor/files.ts +79 -19
  52. package/harbor/quo-harbor.md +58 -15
  53. package/harbor/seal.ts +26 -0
  54. package/human/door.ts +20 -7
  55. package/human/html.ts +41 -17
  56. package/human/local.ts +26 -0
  57. package/human/quo-human.md +114 -19
  58. package/human/screen.ts +21 -7
  59. package/human/tab.ts +50 -5
  60. package/human/tree.ts +129 -0
  61. package/human/web.ts +36 -9
  62. package/mcp/oauth.ts +2 -2
  63. package/mcp/quo-mcp.md +27 -20
  64. package/mcp/route.ts +1 -1
  65. package/mcp/runner.ts +9 -6
  66. package/mcp/server.ts +1 -1
  67. package/mcp/web/exchange.ts +62 -96
  68. package/package.json +13 -2
package/beings/avatar.ts CHANGED
@@ -59,7 +59,14 @@ export class Avatar extends Being {
59
59
  // is taken all the same, and the way back she did not take is dropped, so
60
60
  // any world's public being may admit a guest, hello or not.
61
61
  async join(invitation: Invitation): Promise<Blueprint | { error: string }> {
62
- if (this.standings[USER]) return this.tools();
62
+ if (this.standings[USER]) {
63
+ // A standing the far being removed is not one: she drops it and joins
64
+ // afresh, so an identity forgotten at the user being can be let in again.
65
+ const had = await this.tools();
66
+ if (!('error' in had) || had.error !== 'removed') return had;
67
+ this.standings.remove(USER);
68
+ this.occupants.remove(PUSHER);
69
+ }
63
70
  const mine = await this.invite(PUSHER);
64
71
  if (mine === null) return { error: 'user is taken' };
65
72
  const out = await this.knock(invitation, 'hello', { invitation: mine as unknown as JsonObject });
package/beings/carry.ts CHANGED
@@ -60,7 +60,7 @@ export class Carrier extends Being {
60
60
  if (!rec.blueprint) continue;
61
61
  const names: string[] = [];
62
62
  for (const a of rec.blueprint.asks) {
63
- if (a.name === 'look') continue;
63
+ if (a.name === 'look' || a.name === 'page') continue; // presentation is hers, never carried as an ask
64
64
  const name = `${id}-${a.name}`;
65
65
  if (own.has(name)) continue;
66
66
  carried[name] = { id, ask: a.name };
@@ -120,7 +120,9 @@ that every arrival is named and judged.
120
120
  reach sees acme's asks on her, and acme sees her.
121
121
  - **Look.** What a being may say about how she is presented, as values: a
122
122
  name, a logo, colours, a font, and a hint per ask. One optional ask,
123
- `look`, answers it, and every side reads the part it understands.
123
+ `look`, answers it, and every side reads the part it understands. A
124
+ second optional ask, `page`, answers her page as a tree of values in the
125
+ screen's closed grammar; only a screen reads it.
124
126
  - **Route.** A hostname a reverse proxy sends to one process: `web.`, `quo.`,
125
127
  `mcp.`. Routes are deployment, never boundaries.
126
128
 
@@ -149,6 +151,12 @@ taken when it can be: a knock back that is unreached, a tab whose pk the
149
151
  far harbor has not bound yet or a phone in a tunnel, spends nothing, so she
150
152
  keeps the invitation and takes it at the next push. A refusal is final.
151
153
 
154
+ A device the root minted may also call her: her `name` ask, gated to
155
+ root-minted devices alone, since the root already trusts that device with
156
+ its id and its reach. A being does not know her own key, so a name is
157
+ always given, by the desk's first hello at setup or by such a device, and
158
+ she keeps the last one. A name is a word, held to the same shape as a key.
159
+
152
160
  ## Architecture
153
161
 
154
162
  One droplet, one harbor, three routes. Every other placement is a subset.
@@ -303,7 +311,9 @@ is dropped, not fixed:
303
311
 
304
312
  A look is not capability: it is never in a blueprint or a digest, Quo never
305
313
  sees it, and a being without one renders and lists exactly as before. The
306
- `look` ask itself is presentation and is neither a form nor a tool. A
314
+ `look` ask itself is presentation and is neither a form nor a tool, and so
315
+ is `page`, the one other presentation ask, which a screen alone reads and
316
+ `packages/dock/human/quo-human.md` defines; a carrier carries neither. A
307
317
  carrier keeps each standing's look beside her asks in the notes, so acme's
308
318
  section on the human's page is painted as acme asked, inside that section
309
319
  and nowhere else. No token can carry a URL, a stylesheet or code, so a far
@@ -342,9 +352,9 @@ hello() -> { asks it will answer, and how to get in }
342
352
  device({ proof }) -> invitation | { error } proof is what a route produced
343
353
  ```
344
354
 
345
- `proof` is the outcome of a credential exchange that already happened outside
346
- Quo: a session cookie `web.` set, an OAuth grant `mcp.` verified, a signed
347
- nonce from a local process. The front desk checks it with the verifier for
355
+ `proof` is the daemon's vouch for a process it saw itself: a nonce it
356
+ minted for a side that reached its local socket, the one proof kind there
357
+ is. The front desk checks it with the verifier for
348
358
  that proof kind, finds the user being, asks her `invite({ client })`, and
349
359
  hands back what she minted. The front desk never mints an invitation itself:
350
360
  only the user being invites into her own world, and she keeps the client
@@ -353,42 +363,41 @@ identity in the occupant's notes.
353
363
  ## The credential exchange
354
364
 
355
365
  The only piece that looks like today's SaaS, and it is deliberately kept as
356
- thin as one call. Every route ends the same way:
357
-
358
- 1. The route authenticates by its own means. Password, passkey, OAuth, a
359
- bearer token from a client, a nonce from a local process.
360
- 2. The route asks the front desk `device({ proof })` and receives an
361
- invitation. Which desk is which world: a harbor holds many wards, and
362
- the human chooses the world at the allow page when there is more than
363
- one; the grant remembers it.
364
- 3. The route hands the invitation to the avatar it is about to boot, or to
365
- the one already booted for this client identity if it is a reconnect, in
366
- which case nothing is handed and the existing standing is used.
367
- 4. The avatar knocks. The heir dies. From here there is no token anywhere.
368
-
369
- The human decides two more things at step 1, on the same page that names
370
- the identity: whether this device may **wake** the human's other devices,
371
- and whether it may **reach** what she holds. Each word travels with the
372
- proof, the user being writes it as a note on the occupant she mints, and her
366
+ thin as one call. Nothing is ever typed to get in but an invitation: there
367
+ is no password anywhere in the dock, because login is a knock. Every way in
368
+ ends the same way:
369
+
370
+ 1. Somebody who may invite mints an invitation for the client identity.
371
+ The root does, on the box, with `quo invite` on the user being, and
372
+ sends it as a link or a line. On the device, a side that reached the
373
+ daemon's side socket is vouched for by the daemon itself, the one
374
+ **proof** there is: `local`, a nonce the daemon minted for that
375
+ connection and the front desk honours once, under that kind alone, so
376
+ the desk asks the user being to mint for it. File permissions on the
377
+ socket are the device's own rule, which the spec allows.
378
+ 2. The invitation reaches the avatar for that identity: handed over at the
379
+ allow page of the model side's exchange, opened as a link in a tab, or
380
+ given by the desk to a local side. On a reconnect the avatar already
381
+ holds her standing, nothing is handed and nothing is minted.
382
+ 3. The avatar knocks. The heir dies. From here there is no token anywhere,
383
+ and a route's own grant only remembers which avatar a client speaks
384
+ through.
385
+
386
+ Two more things travel with a minting: whether this device may **wake**
387
+ the human's other devices, and whether it may **reach** what she holds.
388
+ The user being writes each as a note on the occupant she mints, and her
373
389
  gate reads the note: `push` is shown to the desk and to a device with the
374
390
  `wake` note, and her standings are carried for a device with the `reach`
375
391
  note, and for nobody else. A device that may wake can hand an agent an event
376
392
  through the user being, and she sees who did; a device that may reach sees
377
393
  acme on her, and a model sees acme only because the human said it may. The
378
394
  notes are written when the occupant is minted; to change one, remove the
379
- occupant and allow again, because reconnect mints nothing. A device the
395
+ occupant and mint again, because reconnect mints nothing. A device the
380
396
  root mints on her directly, with `quo invite`, carries no note: the id the
381
397
  root chose is its client identity, and it reaches and may wake, since the
382
- root owns everything she holds and chose to mint it.
383
-
384
- Two proof kinds exist, and each arrives only the one way it is made:
385
- `local`, a side that reached the daemon's side socket; `web`, a human who
386
- passed the exchange pages. Each is a nonce the daemon minted for one kind
387
- and honours once, under that kind alone, so a proof of any other provenance
388
- names no nonce and is refused. A human at a screen passes no exchange: a
389
- tab enters by a link, an invitation a user being minted, and nothing is
390
- ever typed to get in. The root's first device is no exception; the root
391
- mints its invitation with `quo invite` on the box and opens the link.
398
+ root owns everything she holds and chose to mint it. An identity the user
399
+ being forgot and lets in again on a fresh invitation is the same avatar:
400
+ her old standing hears `removed`, she drops it, and knocks anew.
392
401
 
393
402
  The route's own lifetimes, where it speaks OAuth: a pending request and a
394
403
  code live ten minutes, an access token one hour, a refresh token thirty
@@ -398,6 +407,8 @@ and what nobody allowed must not stay on disk. A code is spent the first
398
407
  time it is offered, right or wrong. The client identity is chosen by the
399
408
  human at the allow page and never taken from the client's registration: a
400
409
  client may register afresh on every connect, and the avatar outlives that.
410
+ The world the client lands in is the world the invitation names, so the
411
+ page offers no choice of world.
401
412
 
402
413
  Tokens, cookies and grants exist for the length of step 1 above and are never
403
414
  stored on a Quo side. Revocation is `occupants.remove(id)` on the user being:
@@ -431,7 +442,10 @@ quo estate <dir> --domain D an estate folder of the shape every estate has: a d
431
442
  holds the seed, the partition and the ask pointer, on every device, the Mac
432
443
  included. Two processes over one partition are two wards with one pk, and
433
444
  they diverge silently. `init` is the one exception: it creates with no
434
- daemon running, and exits.
445
+ daemon running, and exits. Its setup, `beings/setup.ts`, boots the desk
446
+ public and one user being, and knocks the desk on her with `hello` and her
447
+ name: a being does not know her own key, so the desk's first word names
448
+ her, and she keeps the name from then on.
435
449
 
436
450
  **Everyone else connects to it locally.** `pilot` and the bare owner asks
437
451
  are clients of a unix socket the daemon opens with permissions only the
@@ -533,7 +547,7 @@ class bodies and placement, and reaches every piece through the dock.
533
547
  | reach | carrying bytes to a pk | request, socket |
534
548
  | store | keeping partitions by seed | memory, disk, IndexedDB, edge storage |
535
549
  | side | an avatar speaking to someone outside | screen, MCP server, runner, agent |
536
- | proof | turning a credential into an invitation | one verifier, a daemon nonce, three kinds |
550
+ | proof | a device's vouch as an invitation | one verifier, a daemon nonce, one kind, local |
537
551
  | envoy | a non-Quo surface as a being | none built: a name until its first user |
538
552
  | carrier | a being showing her standings as her own | the user being; any estate being that extends it |
539
553
  | look | how a being is presented, as values | read by the screen and the model side |
@@ -594,8 +608,6 @@ Two rules hold:
594
608
  keep in step with every change, and the vectors under `packages/quo/vectors/`
595
609
  are the hand that will be extended when the time comes.
596
610
 
597
- `next.md` is where the next sitting starts.
598
-
599
611
  ## Glossary
600
612
 
601
613
  - **avatar**: a being that is someone's voice from outside Quo.
@@ -611,11 +623,13 @@ Two rules hold:
611
623
  minted by user beings.
612
624
  - **gate**: `for(occupant, asker)`; the whole permission model.
613
625
  - **look**: how a being is presented, as values; one optional ask.
626
+ - **page**: a being's page as a tree of values in the screen's grammar; one
627
+ optional ask, read by a screen alone.
614
628
  - **owner pilot**: `quo pilot`, the model side over a ward's owner asks, reaching
615
629
  the daemon's socket; with `--via`, a ward elsewhere through a standing.
616
630
  - **daemon**: `quo serve`, the one process on a device that holds a harbor.
617
- - **proof**: what a route produces after authenticating; the front desk
618
- trades it for an invitation.
631
+ - **proof**: the daemon's vouch for a local side, a nonce; the front desk
632
+ trades it for an invitation. Everyone else arrives with an invitation.
619
633
  - **route**: a hostname a reverse proxy sends to one process.
620
634
  - **side**: the interface an avatar speaks through to someone outside Quo.
621
635
  - **user being**: one per human, 24/7, holding all of the human's standings.
package/beings/setup.ts CHANGED
@@ -20,6 +20,7 @@ export async function setup(hosted: Hosted, user: string): Promise<void> {
20
20
  if (pub.error) throw new Error(`init: ${pub.error}`);
21
21
  await boot({ key: user, class: 'User' });
22
22
  const inv = (await hosted.ask('invite', { being: user, id: DESK })) as Invitation;
23
- const placed = (await hosted.ask('knock', { being: 'desk', id: `user:${user}`, invitation: inv, method: 'hello' })) as { taken?: string };
23
+ // the desk's first hello names her, since a being does not know her own key
24
+ const placed = (await hosted.ask('knock', { being: 'desk', id: `user:${user}`, invitation: inv, method: 'hello', args: { name: user } })) as { taken?: string };
24
25
  if (placed.taken !== `user:${user}`) throw new Error('init: the desk could not reach the user being');
25
26
  }
package/beings/user.ts CHANGED
@@ -41,17 +41,24 @@ export class User extends Carrier {
41
41
  device: { description: 'mint an invitation for a device', input: { type: 'object', properties: { client: { type: 'string' }, wake: { type: 'boolean' }, reach: { type: 'boolean' } }, required: ['client'] }, for: isDesk },
42
42
  push: { description: 'push an object to a device: wake it with an event', input: { type: 'object', properties: { client: { type: 'string' }, object: { type: 'object' } }, required: ['client', 'object'] }, for: mayWake },
43
43
  chores: { description: 'what the agent may run', input: { type: 'object' }, for: (occ: OccupantRecord | undefined) => client(occ) === 'agent' },
44
+ look: { description: 'how she is shown', input: { type: 'object' }, for: isDevice },
45
+ page: { description: 'her page, as a tree of values', input: { type: 'object' }, for: isDevice },
46
+ name: { description: 'what she is called', input: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, for: rootMinted },
44
47
  forget: { description: 'revoke a device: drop its way in and her way back to it, in one act', input: { type: 'object', properties: { client: { type: 'string' } }, required: ['client'] }, for: isDesk },
45
48
  report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
46
49
  };
47
50
 
48
51
  // Anyone may say hello. A device that hands her an invitation in the args
49
52
  // is taken as a standing under its own id, so she can push to it later;
50
- // if it cannot be taken now, it is kept and tried at the next push.
53
+ // if it cannot be taken now, it is kept and tried at the next push. The
54
+ // desk's first hello names her: a being does not know her own key, and the
55
+ // root's setup knocks with the name it booted her under. Once named, she
56
+ // keeps it.
51
57
  async hello(args: JsonObject, asker: Asker) {
52
58
  if (asker.id !== undefined && args.invitation && typeof args.invitation === 'object' && !Array.isArray(args.invitation)) {
53
59
  await this.wayBack(asker.id, args.invitation as Invitation);
54
60
  }
61
+ if (asker.id === DESK && !this.cells.name && typeof args.name === 'string' && /^[\w.-]{1,80}$/.test(args.name)) this.cells.name = args.name;
55
62
  return { welcome: asker.id ?? null, name: this.cells.name };
56
63
  }
57
64
 
@@ -123,6 +130,46 @@ export class User extends Carrier {
123
130
  return { chores: ['census', 'report'] };
124
131
  }
125
132
 
133
+ // How a device shows her: her name as the title, and what each ask is
134
+ // called. Values only; the screen and the model side each read their part.
135
+ look() {
136
+ return {
137
+ name: (this.cells.name as string) || 'you',
138
+ order: ['whoami', 'push', 'name'],
139
+ asks: { whoami: { title: 'who am I', readOnly: true }, push: { title: 'wake a device' }, name: { title: 'call her' } },
140
+ };
141
+ }
142
+
143
+ // Her page for a device: her name, who this device is, the worlds she
144
+ // holds as sections, and the two things a human does here by hand, waking
145
+ // another device and naming her. `hello` and `report` are wiring, a
146
+ // device's first word and an agent's callback; they stay in her describe
147
+ // under the gate and off her page, since a page is presentation and the
148
+ // gate is permission. A device that may not do a thing sees no form for
149
+ // it, however the page names it.
150
+ page() {
151
+ return {
152
+ kind: 'stack',
153
+ of: [
154
+ { kind: 'text', text: (this.cells.name as string) || 'you', role: 'title' },
155
+ { kind: 'row', of: [{ kind: 'text', text: 'this device', role: 'label' }, { kind: 'answer', ask: 'whoami' }] },
156
+ { kind: 'standings' },
157
+ { kind: 'form', ask: 'push' },
158
+ { kind: 'form', ask: 'name' },
159
+ ],
160
+ };
161
+ }
162
+
163
+ // A device the root minted may say what she is called: the root trusts
164
+ // it with its id and its reach already, and a being does not know her own
165
+ // key. A name is a word; she keeps the last one given.
166
+ name(args: JsonObject) {
167
+ const name = typeof args.name === 'string' ? args.name.trim() : '';
168
+ if (!/^[\w.-]{1,80}$/.test(name)) return { error: 'a name is a word' };
169
+ this.cells.name = name;
170
+ return { named: name };
171
+ }
172
+
126
173
  // A device that ran something for her says what it found. Kept, so that
127
174
  // whoever renders her can show it; the ask itself is the callback.
128
175
  report(args: JsonObject, asker: Asker) {
package/cli/daemon.ts CHANGED
@@ -55,7 +55,7 @@ import { createHash, randomBytes } from 'node:crypto';
55
55
  import { tmpdir } from 'node:os';
56
56
  import { join, resolve } from 'node:path';
57
57
  import { createInterface } from 'node:readline';
58
- import { isSilence, isWord, wordOf, type JsonObject } from '@quo-systems/quo';
58
+ import { isSilence, isWord, wordOf, type Invitation, type JsonObject } from '@quo-systems/quo';
59
59
  import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
60
60
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
61
61
  import { DiskHarbor, type Hosted } from '../harbor/disk.ts';
@@ -77,7 +77,7 @@ export type Serving = { harbor: DiskHarbor; sock: string; side: string; http: Ht
77
77
  // The agents this daemon runs, by client identity. From `<dir>/agents.json`
78
78
  // on a device, or given.
79
79
  export type Agents = Record<string, { command: string; args?: string[]; dir: string }>;
80
- export type Options = { http?: { port: number; host?: string }; routes?: Routes; agents?: Agents; dial?: string[]; password?: () => string | undefined };
80
+ export type Options = { http?: { port: number; host?: string }; routes?: Routes; agents?: Agents; dial?: string[] };
81
81
 
82
82
  // The sockets live in the harbor's folder, where the folder's own permissions
83
83
  // guard them. A unix socket path is short by law, about a hundred bytes on
@@ -91,14 +91,11 @@ const socketPath = (dir: string, name: string): string => {
91
91
  export const sockPath = (dir: string) => socketPath(dir, 'quo');
92
92
  export const sidePath = (dir: string) => socketPath(dir, 'side');
93
93
 
94
- // The nonces this daemon minted, honoured once each. Two proof kinds share
95
- // them: `local`, for a side that reached side.sock, and `web`, for a human
96
- // who passed the exchange pages. Both are the daemon vouching for someone it
97
- // saw itself; a proof of either kind arriving any other way names no nonce.
94
+ // The nonces this daemon minted, honoured once each, for the one proof kind
95
+ // there is: `local`, a side that reached side.sock. That is the daemon
96
+ // vouching for a process it saw itself, by the device's own rule, file
97
+ // permissions on the socket; a proof arriving any other way names no nonce.
98
98
  const NONCES = new Map<string, { kind: string; user: string; client: string; wake?: boolean; reach?: boolean }>();
99
- // A nonce is minted for one kind and honoured under that kind alone: a tab's
100
- // nonce offered as a `local` proof names no nonce, so a proof of a kind still
101
- // arrives only the one way that kind is made.
102
99
  const honour =
103
100
  (kind: string): Verifier =>
104
101
  (proof) => {
@@ -109,14 +106,10 @@ const honour =
109
106
  return rest;
110
107
  };
111
108
  Desk.verifiers.local = honour('local');
112
- Desk.verifiers.web = honour('web');
113
109
 
114
- // Admit a client identity into a ward: find or boot her avatar, and enter
115
- // her with a nonce the desk honours once. On a reconnect she already holds
116
- // `user` and nothing is minted, so `wake`, the human's word that this
117
- // device may wake her others, is read the first time only; to change it,
118
- // remove the occupant and allow again. The one path for every side.
119
- export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake = false, reach = false): Promise<{ avatar?: Avatar; error?: string }> {
110
+ // Her avatar in a ward, found or booted under the identity's key. An
111
+ // identity is a word, and never a being of the ward.
112
+ async function avatarOf(hosted: Hosted, identity: string): Promise<{ avatar?: Avatar; error?: string }> {
120
113
  if (!/^[\w.-]+$/.test(identity) || identity === hosted.record.user || identity === 'desk') return { error: 'an identity is a word, and not a being of the ward' };
121
114
  const key = `avatar:${identity}`;
122
115
  let avatar = hosted.being(key) as Avatar | undefined;
@@ -125,12 +118,48 @@ export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'w
125
118
  if (out.error) return { error: out.error };
126
119
  avatar = hosted.being(key) as Avatar;
127
120
  }
121
+ return { avatar };
122
+ }
123
+
124
+ // Admit a local client identity into a ward: find or boot her avatar, and
125
+ // enter her with a nonce the desk honours once. On a reconnect she already
126
+ // holds `user` and nothing is minted, so `wake` and `reach` are read the
127
+ // first time only; to change one, remove the occupant and admit again. The
128
+ // one path for a side on the device: agents, and sides on side.sock.
129
+ export async function admit(hosted: Hosted, identity: string, kind: 'local', wake = false, reach = false): Promise<{ avatar?: Avatar; error?: string }> {
130
+ const found = await avatarOf(hosted, identity);
131
+ if (!found.avatar) return found;
128
132
  const nonce = randomBytes(16).toString('hex');
129
133
  NONCES.set(nonce, { kind, user: hosted.record.user, client: identity, ...(wake ? { wake: true } : {}), ...(reach ? { reach: true } : {}) });
130
- const entered = await avatar.enter({ ward: hosted.pk }, { kind, nonce });
134
+ const entered = await found.avatar.enter({ ward: hosted.pk }, { kind, nonce });
131
135
  NONCES.delete(nonce);
132
136
  await hosted.save(); // the knock went through the ward's own door, which the harbor never sees
133
137
  if ('error' in entered && !('asks' in entered)) return { error: entered.error };
138
+ return { avatar: found.avatar };
139
+ }
140
+
141
+ // Join a client identity to a ward with an invitation the root minted for
142
+ // it: find or boot her avatar, and she knocks. The one path for an identity
143
+ // from elsewhere, the exchange page. Nothing is typed to get in but the
144
+ // invitation, and the knock spends it.
145
+ export async function letIn(hosted: Hosted, identity: string, invitation: Invitation): Promise<{ avatar?: Avatar; error?: string }> {
146
+ const found = await avatarOf(hosted, identity);
147
+ if (!found.avatar) return found;
148
+ const joined = await found.avatar.join(invitation);
149
+ await hosted.save();
150
+ if ('error' in joined && !('asks' in joined)) return { error: joined.error };
151
+ return { avatar: found.avatar };
152
+ }
153
+
154
+ // Her avatar as she stands, for a session opening under a grant: she is in
155
+ // or she is not, and nothing is minted here. An identity the user being
156
+ // removed hears `removed` on her describe, which the endpoint speaks as 401.
157
+ export async function find(hosted: Hosted, identity: string): Promise<{ avatar?: Avatar; error?: string }> {
158
+ const avatar = hosted.being(`avatar:${identity}`) as Avatar | undefined;
159
+ if (!avatar) return { error: 'not joined' };
160
+ const bp = await avatar.tools();
161
+ await hosted.save();
162
+ if ('error' in bp && !('asks' in bp)) return { error: bp.error };
134
163
  return { avatar };
135
164
  }
136
165
 
@@ -173,22 +202,20 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
173
202
  http = door;
174
203
  http.mount('/quo', quoRoute(harbor, server as HttpServer, quo));
175
204
  const routes = options.routes ?? (await readRoutes(harbor.dir));
176
- const password = options.password ?? (() => process.env.QUO_OWNER_PASSWORD);
177
205
  const here = `http://${http.host}:${http.port}`;
178
206
  // the worlds' pages: always, on the daemon's own door when no route names a public one
179
207
  const web = webRoute(harbor, { at: { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` } });
180
208
  if (routes) {
181
- // the worlds a client may be allowed into: every ward with a public being, main first
209
+ // the worlds a client may be allowed into: every ward here, by name and pk, main first
182
210
  const worlds = () =>
183
211
  [...harbor.wards]
184
- .filter(([, h]) => (h.partition as { public?: string | null }).public !== null)
185
212
  .sort(([a], [b]) => (a === 'main' ? -1 : b === 'main' ? 1 : a.localeCompare(b)))
186
- .map(([ward, h]) => ({ ward, user: h.record.user }));
213
+ .map(([ward, h]) => ({ ward, pk: h.pk, user: h.record.user }));
187
214
  // the MCP endpoint: a bearer names an identity in a world, the identity names her avatar there, the side runs beside her
188
215
  mcp = new McpHttp(
189
216
  async (identity, ward) => {
190
217
  const hosted = harbor.wards.get(ward);
191
- return hosted ? admit(hosted, identity, 'web') : { error: 'no such world' };
218
+ return hosted ? find(hosted, identity) : { error: 'no such world' };
192
219
  },
193
220
  async () => {
194
221
  for (const h of harbor.wards.values()) await h.save();
@@ -200,12 +227,11 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
200
227
  mcp.gone = (identity, ward) => oauth!.revoke(identity, ward); // removal at the ward ends the grant at the route
201
228
  exchange = new Exchange({
202
229
  oauth,
203
- password,
204
230
  worlds,
205
- admit: async (identity, wake, reach, ward) => {
231
+ join: async (identity, invitation, ward) => {
206
232
  const hosted = harbor.wards.get(ward);
207
233
  if (!hosted) return { error: 'no such world' };
208
- const r = await admit(hosted, identity, 'web', wake, reach);
234
+ const r = await letIn(hosted, identity, invitation);
209
235
  return r.error ? { error: r.error } : {};
210
236
  },
211
237
  });
@@ -14,7 +14,6 @@ User=quo
14
14
  Group=quo
15
15
  Environment=QUO_DIR=/home/quo/.quo
16
16
  Environment=QUO_HTTP=8787
17
- # QUO_OWNER_PASSWORD, root-only. Without it the exchange pages are closed.
18
17
  # CLAUDE_CODE_OAUTH_TOKEN, when an agent folder runs `claude -p`: the agent
19
18
  # reads its credential from the device, never from cells.
20
19
  EnvironmentFile=-/etc/quo/env
@@ -55,8 +55,15 @@ export class Avatar extends Being {
55
55
  // is taken all the same, and the way back she did not take is dropped, so
56
56
  // any world's public being may admit a guest, hello or not.
57
57
  async join(invitation) {
58
- if (this.standings[USER])
59
- return this.tools();
58
+ if (this.standings[USER]) {
59
+ // A standing the far being removed is not one: she drops it and joins
60
+ // afresh, so an identity forgotten at the user being can be let in again.
61
+ const had = await this.tools();
62
+ if (!('error' in had) || had.error !== 'removed')
63
+ return had;
64
+ this.standings.remove(USER);
65
+ this.occupants.remove(PUSHER);
66
+ }
60
67
  const mine = await this.invite(PUSHER);
61
68
  if (mine === null)
62
69
  return { error: 'user is taken' };
@@ -58,8 +58,8 @@ export class Carrier extends Being {
58
58
  continue;
59
59
  const names = [];
60
60
  for (const a of rec.blueprint.asks) {
61
- if (a.name === 'look')
62
- continue;
61
+ if (a.name === 'look' || a.name === 'page')
62
+ continue; // presentation is hers, never carried as an ask
63
63
  const name = `${id}-${a.name}`;
64
64
  if (own.has(name))
65
65
  continue;
@@ -13,7 +13,8 @@ export async function setup(hosted, user) {
13
13
  throw new Error(`init: ${pub.error}`);
14
14
  await boot({ key: user, class: 'User' });
15
15
  const inv = (await hosted.ask('invite', { being: user, id: DESK }));
16
- const placed = (await hosted.ask('knock', { being: 'desk', id: `user:${user}`, invitation: inv, method: 'hello' }));
16
+ // the desk's first hello names her, since a being does not know her own key
17
+ const placed = (await hosted.ask('knock', { being: 'desk', id: `user:${user}`, invitation: inv, method: 'hello', args: { name: user } }));
17
18
  if (placed.taken !== `user:${user}`)
18
19
  throw new Error('init: the desk could not reach the user being');
19
20
  }
@@ -3,6 +3,7 @@ import { Carrier } from './carry.ts';
3
3
  export declare const DESK = "desk";
4
4
  declare const isDesk: (occ: OccupantRecord | undefined) => boolean;
5
5
  declare const isDevice: (occ: OccupantRecord | undefined) => boolean;
6
+ declare const rootMinted: (occ: OccupantRecord | undefined) => boolean;
6
7
  declare const mayWake: (occ: OccupantRecord | undefined) => boolean;
7
8
  export declare class User extends Carrier {
8
9
  static carries(occ: OccupantRecord | undefined): boolean;
@@ -72,6 +73,33 @@ export declare class User extends Carrier {
72
73
  };
73
74
  for: (occ: OccupantRecord | undefined) => boolean;
74
75
  };
76
+ look: {
77
+ description: string;
78
+ input: {
79
+ type: string;
80
+ };
81
+ for: typeof isDevice;
82
+ };
83
+ page: {
84
+ description: string;
85
+ input: {
86
+ type: string;
87
+ };
88
+ for: typeof isDevice;
89
+ };
90
+ name: {
91
+ description: string;
92
+ input: {
93
+ type: string;
94
+ properties: {
95
+ name: {
96
+ type: string;
97
+ };
98
+ };
99
+ required: string[];
100
+ };
101
+ for: typeof rootMinted;
102
+ };
75
103
  forget: {
76
104
  description: string;
77
105
  input: {
@@ -127,6 +155,67 @@ export declare class User extends Carrier {
127
155
  chores(): {
128
156
  chores: string[];
129
157
  };
158
+ look(): {
159
+ name: string;
160
+ order: string[];
161
+ asks: {
162
+ whoami: {
163
+ title: string;
164
+ readOnly: boolean;
165
+ };
166
+ push: {
167
+ title: string;
168
+ };
169
+ name: {
170
+ title: string;
171
+ };
172
+ };
173
+ };
174
+ page(): {
175
+ kind: string;
176
+ of: ({
177
+ kind: string;
178
+ text: string;
179
+ role: string;
180
+ of?: undefined;
181
+ ask?: undefined;
182
+ } | {
183
+ kind: string;
184
+ of: ({
185
+ kind: string;
186
+ text: string;
187
+ role: string;
188
+ ask?: undefined;
189
+ } | {
190
+ text?: undefined;
191
+ role?: undefined;
192
+ kind: string;
193
+ ask: string;
194
+ })[];
195
+ text?: undefined;
196
+ role?: undefined;
197
+ ask?: undefined;
198
+ } | {
199
+ of?: undefined;
200
+ text?: undefined;
201
+ role?: undefined;
202
+ kind: string;
203
+ ask?: undefined;
204
+ } | {
205
+ of?: undefined;
206
+ text?: undefined;
207
+ role?: undefined;
208
+ kind: string;
209
+ ask: string;
210
+ })[];
211
+ };
212
+ name(args: JsonObject): {
213
+ error: string;
214
+ named?: undefined;
215
+ } | {
216
+ error?: undefined;
217
+ named: string;
218
+ };
130
219
  report(args: JsonObject, asker: Asker): {
131
220
  reported: boolean;
132
221
  };