@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/mcp/quo-mcp.md CHANGED
@@ -39,10 +39,11 @@ connected to the same user being see two different tool lists, and neither
39
39
  can call what it cannot see: the gate is one decision for describe and for
40
40
  dispatch.
41
41
 
42
- A being who answers `look`, the trunk's one optional ask, is listed with
42
+ A being who answers `look`, the trunk's presentation ask, is listed with
43
43
  what it says: `asks.NAME.title` is the tool's title, and `readOnly`,
44
44
  `destructive` and `idempotent` are the annotations of the same names with
45
- `Hint` after them. The `look` ask itself is not a tool. A carried standing's
45
+ `Hint` after them. Neither `look` nor `page`, the screen's presentation
46
+ ask, is a tool: a model reads no page. A carried standing's
46
47
  asks, `acme-book` on the user being for a model the human let reach, carry
47
48
  the far being's hints the same way, read from the notes the carrier writes.
48
49
  The side asks `look` once per digest of the describe. Nothing else changes:
@@ -67,26 +68,32 @@ the two words give it what it needs to decide.
67
68
 
68
69
  ### A remote MCP client connects
69
70
 
70
- One harbor holds many worlds, and the allow page names the one the client
71
- is let into; the grant remembers it, so a bearer is an identity in a world
72
- and a session is that identity's there. `/mcp` stays one endpoint per
73
- harbor: `mcp/route.ts` is the route the daemon mounts, the exchange in
74
- `oauth.ts` in front and the endpoint in `http.ts` behind it, with the
75
- route's store, `<dir>/oauth.json`, the route's own and never a ward's.
71
+ The client speaks OAuth and lands the human on the allow page of the web
72
+ route, where nothing is typed but an invitation: the one the root minted
73
+ for this client on the user being with `quo invite`, and the identity it
74
+ will be. The invitation names its ward, so the world the client is let
75
+ into is the invitation's, and the grant remembers it: a bearer is an
76
+ identity in a world and a session is that identity's there. A session
77
+ opening under a grant finds the avatar as she stands and mints nothing; an
78
+ identity the user being removed hears `removed` there, which is 401.
79
+ `/mcp` stays one endpoint per harbor: `mcp/route.ts` is the route the
80
+ daemon mounts, the exchange in `oauth.ts` in front and the endpoint in
81
+ `http.ts` behind it, with the route's store, `<dir>/oauth.json`, the
82
+ route's own and never a ward's.
76
83
 
77
84
  ```
78
- client mcp. route front desk user being avatar
79
- |-- OAuth ------->| | | |
80
- | |-- device(proof) --->| | |
81
- | | |-- invite ------->| |
82
- | | |<-- invitation ---| |
83
- | |<-- invitation ------| | |
84
- | |-- boot or find avatar for (human, client) ------------>|
85
- | |-- knock(invitation) --------------------------------->|
86
- | | | |<-- knock ------|
87
- | | | |--- answer ---->|
88
- | | take |
89
- |<-- initialize --| |
85
+ root user being client mcp. route avatar
86
+ |-- invite ------->| | | |
87
+ |<-- invitation ---| | | |
88
+ | ... the link reaches the human by any channel ... | |
89
+ | | |-- OAuth -------->| |
90
+ | | | the allow page: identity, invitation
91
+ | | | |-- boot or find avatar for the identity -->|
92
+ | | | |-- knock(invitation) -------------------->|
93
+ | |<-- knock -------------------------------------------------------------------|
94
+ | |--- answer ----------------------------------------------------------------->|
95
+ | | | | take |
96
+ | | |<-- initialize ---| |
90
97
  |-- tools/list -->|-- ask() ------------------------------------------------>|--> user being
91
98
  |<-- tool list ---|<-- describe for this asker --------------------------------|
92
99
  |-- tools/call -->|-- ask(name, args, { time }) ---------------------------->|--> user being
package/mcp/route.ts CHANGED
@@ -24,7 +24,7 @@ export async function mcpRoute(dir: string, routes: Routes, mcp: McpHttp | null)
24
24
  await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
25
25
  await rename(file + '.tmp', file);
26
26
  }));
27
- const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/login?request=${id}`, store, persist });
27
+ const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/allow?request=${id}`, store, persist });
28
28
  const handler: Handler = async (req, res, rest) => {
29
29
  if (await oauth.handle(req, res, rest)) return;
30
30
  if (rest === '/mcp') {
package/mcp/runner.ts CHANGED
@@ -32,13 +32,16 @@ type Completion = { choices: { message: { role: 'assistant'; content: string | n
32
32
  // Her describe, spoken as a tools array. Name, description and input are
33
33
  // verbatim, with the one narrowing an endpoint has asked for: an ask that
34
34
  // declares no properties is sent with an empty `properties`, because
35
- // LM Studio refuses a parameters schema without one.
35
+ // LM Studio refuses a parameters schema without one. `look` and `page` are
36
+ // presentation, for a screen, and are not functions.
36
37
  export function tools(bp: Blueprint): Tool[] {
37
- return bp.asks.map((a) => {
38
- const t: Tool = { type: 'function', function: { name: a.name, parameters: { properties: {}, ...a.input, type: 'object' } } };
39
- if (a.description !== undefined) t.function.description = a.description;
40
- return t;
41
- });
38
+ return bp.asks
39
+ .filter((a) => a.name !== 'look' && a.name !== 'page')
40
+ .map((a) => {
41
+ const t: Tool = { type: 'function', function: { name: a.name, parameters: { properties: {}, ...a.input, type: 'object' } } };
42
+ if (a.description !== undefined) t.function.description = a.description;
43
+ return t;
44
+ });
42
45
  }
43
46
 
44
47
  // One tool call, one ask, one tool message: the three words cross as the
package/mcp/server.ts CHANGED
@@ -31,7 +31,7 @@ export const DESCRIBE: Tool = { name: 'describe', description: 'the empty ask: h
31
31
  // reads. The `look` ask itself is presentation, and is not a tool.
32
32
  export function tools(bp: Blueprint, look: Look = {}): Tool[] {
33
33
  const asks = bp.asks
34
- .filter((a) => a.name !== 'look')
34
+ .filter((a) => a.name !== 'look' && a.name !== 'page') // presentation, for a screen, and not a tool
35
35
  .map((a) => {
36
36
  const t: Tool = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
37
37
  if (a.description !== undefined) t.description = a.description;
@@ -1,32 +1,32 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The exchange pages on the web route. Two static pages, hand-written, and
3
- // explicitly not the human side: they render no blueprint. They end the
4
- // credential exchange the way the trunk says every route does: a proof is
5
- // made, the front desk trades it for an invitation, the avatar knocks, and
6
- // the OAuth request completes with the client identity the human chose.
2
+ // The exchange page on the web route. One static page, hand-written, and
3
+ // explicitly not the human side: it renders no blueprint. It ends the
4
+ // credential exchange the way every way in ends: with a knock. Nothing is
5
+ // typed to get in but an invitation, the one the root minted for this
6
+ // client on the user being with `quo invite` and sent as a link or a line;
7
+ // the avatar the identity boots knocks with it, and the OAuth request
8
+ // completes with the identity the human chose. No password, no cookie, no
9
+ // session: the invitation is the credential, it is used once, and from the
10
+ // knock on there is no token anywhere but the route's own grant.
7
11
  //
8
- // GET /login?request=ID the owner password, from QUO_OWNER_PASSWORD
9
- // POST /login sets a short session cookie, goes to /allow
10
- // GET /allow?request=ID who is asking, and the identity they will be
11
- // POST /allow allow as that identity, or deny
12
+ // GET /allow?request=ID who is asking, the identity it will be, and
13
+ // the field for the invitation
14
+ // POST /allow allow as that identity with that invitation,
15
+ // or deny
12
16
  //
13
- // The password is the device's, read from the environment the way an envoy
14
- // reads a secret, never through cells. With none set, the exchange is closed.
15
- import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
17
+ // The invitation names its ward, so the world the client lands in is the
18
+ // one the invitation was minted on, and there is nothing to choose.
16
19
  import type { IncomingMessage, ServerResponse } from 'node:http';
20
+ import type { Invitation } from '@quo-systems/quo';
17
21
  import type { OAuth } from '../oauth.ts';
18
22
  import { readForm } from '../oauth.ts';
23
+ import { parse } from '../../beings/link.ts';
19
24
 
20
- export const SESSION_TTL = 10 * 60 * 1000; // a login lives as long as a request: ten minutes
21
- const COOKIE = 'quo_exchange';
22
-
23
- export type Admit = (identity: string, wake: boolean, reach: boolean, ward: string) => Promise<{ error?: string }>;
25
+ export type Join = (identity: string, invitation: Invitation, ward: string) => Promise<{ error?: string }>;
24
26
  export type Options = {
25
27
  oauth: OAuth;
26
- password: () => string | undefined; // QUO_OWNER_PASSWORD, read at every login
27
- admit: Admit; // the daemon: boot or find the avatar for this identity in that world, and enter her
28
- worlds: () => { ward: string; user: string }[]; // the worlds of this harbor, the first the default: a ward with a public being, and its user being's name
29
- now?: () => number;
28
+ join: Join; // the daemon: boot or find the avatar for this identity in that world, and knock
29
+ worlds: () => { ward: string; pk: string; user: string }[]; // the worlds of this harbor, by name and pk
30
30
  };
31
31
 
32
32
  const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
@@ -39,122 +39,88 @@ export const suggest = (name: string) =>
39
39
  .replace(/^-|-$/g, '')
40
40
  .slice(0, 40) || 'client';
41
41
 
42
+ // The invitation as typed: the link's compact form, `ward.heir.secret`, or
43
+ // the whole link it came in, or the JSON value `quo invite` printed.
44
+ export function invitationOf(text: string): Invitation | null {
45
+ const t = text.trim();
46
+ const hash = t.indexOf('#');
47
+ const linked = parse(hash === -1 ? `#quo=${t}` : t.slice(hash));
48
+ if (linked?.heir) return linked;
49
+ try {
50
+ const v = JSON.parse(t) as Record<string, unknown>;
51
+ if (v && typeof v === 'object' && typeof v.ward === 'string' && typeof v.heir === 'string' && typeof v.secret === 'string') return { ward: v.ward, heir: v.heir, secret: v.secret };
52
+ } catch {
53
+ /* not JSON either */
54
+ }
55
+ return null;
56
+ }
57
+
42
58
  export class Exchange {
43
59
  readonly o: Options;
44
- readonly secret = randomBytes(32); // sessions die with the daemon, and that is fine: they live ten minutes
45
60
  constructor(o: Options) {
46
61
  this.o = o;
47
62
  }
48
- now() {
49
- return this.o.now?.() ?? Date.now();
50
- }
51
-
52
- // ---- the session cookie: an expiry, signed
53
-
54
- mint(): string {
55
- const exp = String(this.now() + SESSION_TTL);
56
- return `${exp}.${createHmac('sha256', this.secret).update(exp).digest('base64url')}`;
57
- }
58
- valid(req: IncomingMessage): boolean {
59
- const m = /(?:^|;\s*)quo_exchange=([^;]+)/.exec(req.headers.cookie ?? '');
60
- const [exp, sig] = (m?.[1] ?? '').split('.');
61
- if (!exp || !sig) return false;
62
- const want = createHmac('sha256', this.secret).update(exp).digest('base64url');
63
- return sig.length === want.length && timingSafeEqual(Buffer.from(sig), Buffer.from(want)) && Number(exp) > this.now();
64
- }
65
63
 
66
64
  async handle(req: IncomingMessage, res: ServerResponse, rest: string): Promise<boolean> {
67
65
  const url = new URL(req.url ?? '/', 'http://localhost');
68
- const page = (status: number, body: string, headers: Record<string, string> = {}) => {
69
- res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
66
+ const page = (status: number, body: string): true => {
67
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
70
68
  res.end(shell(body));
69
+ return true;
71
70
  };
72
- const go = (to: string, headers: Record<string, string> = {}) => {
73
- res.writeHead(302, { location: to, 'cache-control': 'no-store', ...headers });
71
+ const go = (to: string): true => {
72
+ res.writeHead(302, { location: to, 'cache-control': 'no-store' });
74
73
  res.end();
74
+ return true;
75
75
  };
76
76
 
77
- if (rest === '/login' && req.method === 'GET') {
78
- const request = url.searchParams.get('request') ?? '';
79
- const p = this.o.oauth.pending(request);
80
- if (!p) return page(400, `<h1>Nothing to allow</h1><p>This request is gone. Start again from the app that sent you here.</p>`), true;
81
- if (this.o.password() === undefined) return page(503, `<h1>Closed</h1><p>This world takes no logins: no owner password is set.</p>`), true;
82
- if (this.valid(req)) return go(`/allow?request=${encodeURIComponent(request)}`), true;
83
- return page(200, loginForm(request, p.client.client_name)), true;
84
- }
85
- if (rest === '/login' && req.method === 'POST') {
86
- const f = await readForm(req);
87
- const request = f.get('request') ?? '';
88
- const p = this.o.oauth.pending(request);
89
- if (!p) return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`), true;
90
- const want = this.o.password();
91
- const got = f.get('password') ?? '';
92
- if (want === undefined) return page(503, `<h1>Closed</h1>`), true;
93
- if (got.length !== want.length || !timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
94
- await new Promise((ok) => setTimeout(ok, 300));
95
- return page(401, loginForm(request, p.client.client_name, 'That is not the password.')), true;
96
- }
97
- return go(`/allow?request=${encodeURIComponent(request)}`, { 'set-cookie': `${COOKIE}=${this.mint()}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${SESSION_TTL / 1000}` }), true;
98
- }
99
77
  if (rest === '/allow' && req.method === 'GET') {
100
78
  const request = url.searchParams.get('request') ?? '';
101
79
  const p = this.o.oauth.pending(request);
102
- if (!p) return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`), true;
103
- if (!this.valid(req)) return go(`/login?request=${encodeURIComponent(request)}`), true;
104
- return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.worlds())), true;
80
+ if (!p) return page(400, `<h1>Nothing to allow</h1><p>This request is gone. Start again from the app that sent you here.</p>`);
81
+ return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.worlds()));
105
82
  }
106
83
  if (rest === '/allow' && req.method === 'POST') {
107
84
  const f = await readForm(req);
108
85
  const request = f.get('request') ?? '';
109
86
  const p = this.o.oauth.pending(request);
110
- if (!p) return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`), true;
111
- if (!this.valid(req)) return go(`/login?request=${encodeURIComponent(request)}`), true;
87
+ if (!p) return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`);
112
88
  if (f.get('decision') !== 'allow') {
113
89
  const out = await this.o.oauth.deny(request);
114
- return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`), true;
90
+ return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`);
115
91
  }
116
92
  const worlds = this.o.worlds();
117
- const world = worlds.find((w) => w.ward === (f.get('ward') ?? worlds[0]?.ward));
118
- if (!world) 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;
93
+ const again = (err: string) => page(400, allowForm(request, p.client.client_name, p.redirect_uri, word(f.get('identity')) ?? suggest(p.client.client_name), worlds, err));
94
+ const inv = invitationOf(f.get('invitation') ?? '');
95
+ if (!inv) return again('That is not an invitation.');
96
+ const world = worlds.find((w) => w.pk === inv.ward);
97
+ if (!world) return again('That invitation is for a world that does not live here.');
119
98
  const identity = word(f.get('identity'));
120
- if (identity === null || identity === world.user || identity === 'desk') 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;
121
- const admitted = await this.o.admit(identity, f.get('wake') === 'on', f.get('reach') === 'on', world.ward);
122
- if (admitted.error) return page(500, `<h1>Not admitted</h1><p>${esc(admitted.error)}</p>`), true;
99
+ if (identity === null || identity === world.user || identity === 'desk') return again('An identity is one word, and not the user or the desk.');
100
+ const joined = await this.o.join(identity, inv, world.ward);
101
+ if (joined.error) return again(`Not in: ${joined.error}.`);
123
102
  const out = await this.o.oauth.complete(request, identity, world.ward);
124
- return 'redirect' in out ? go(out.redirect, { 'set-cookie': `${COOKIE}=; Path=/; Max-Age=0` }) : page(400, `<h1>Gone</h1>`), true;
103
+ return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`);
125
104
  }
126
105
  return false;
127
106
  }
128
107
  }
129
108
 
130
109
  const shell = (body: string) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
131
- <style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#222}h1{font-size:1.4rem}label{display:block;margin:1rem 0 .25rem}input{font:inherit;padding:.5rem;width:100%;box-sizing:border-box}button{font:inherit;padding:.5rem 1rem;margin:1rem .5rem 0 0}.err{color:#b00}.who{background:#f4f4f4;padding:.75rem 1rem;border-radius:.5rem}code{word-break:break-all}</style>
110
+ <style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#222}h1{font-size:1.4rem}label{display:block;margin:1rem 0 .25rem}input,textarea{font:inherit;padding:.5rem;width:100%;box-sizing:border-box}textarea{font-family:ui-monospace,monospace;min-height:5rem}button{font:inherit;padding:.5rem 1rem;margin:1rem .5rem 0 0}.err{color:#b00}.who{background:#f4f4f4;padding:.75rem 1rem;border-radius:.5rem}code{word-break:break-all}</style>
132
111
  </head><body>${body}</body></html>`;
133
112
 
134
- const loginForm = (request: string, client: string, err = '') => `<h1>${esc(client)} wants in</h1>
135
- <p>Log in as the owner of this world to decide.</p>
136
- ${err ? `<p class="err">${esc(err)}</p>` : ''}
137
- <form method="post" action="/login"><input type="hidden" name="request" value="${esc(request)}">
138
- <label for="p">Owner password</label><input id="p" name="password" type="password" autocomplete="current-password" autofocus required>
139
- <button type="submit">Log in</button></form>`;
140
-
141
- // The world is a choice when the harbor has more than one; the user named
142
- // is the first world's, and the page says which world each identity lands in.
113
+ // The one page. The world is the invitation's, so the page only says which
114
+ // worlds live here and whose they are.
143
115
  const allowForm = (request: string, client: string, redirect: string, identity: string, worlds: { ward: string; user: string }[], err = '') => {
144
- const user = worlds[0]?.user ?? '';
145
- const pick =
146
- worlds.length > 1
147
- ? `<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>`
148
- : `<input type="hidden" name="ward" value="${esc(worlds[0]?.ward ?? 'main')}">`;
116
+ const whose = worlds.map((w) => `<strong>${esc(w.user)}</strong> in ${esc(w.ward)}`).join(', ');
149
117
  return `<h1>Allow ${esc(client)}?</h1>
150
- <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>
151
- <p>It will see exactly what ${esc(user)} shows the identity below, and nothing else. You can remove it any time.</p>
118
+ <div class="who"><p><strong>${esc(client)}</strong> asks to be an occupant of ${whose || 'a world here'}.</p>
119
+ <p>It will see exactly what the user being shows the identity below, and nothing else. You can remove it any time.</p>
152
120
  <p>It returns to <code>${esc(redirect)}</code>.</p></div>
153
121
  ${err ? `<p class="err">${esc(err)}</p>` : ''}
154
122
  <form method="post" action="/allow"><input type="hidden" name="request" value="${esc(request)}">
155
- ${pick}
156
123
  <label for="i">Identity</label><input id="i" name="identity" value="${esc(identity)}" pattern="[\\w.-]{1,40}" required>
157
- <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>
158
- <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>
124
+ <label for="v">Invitation</label><textarea id="v" name="invitation" autofocus required placeholder="the invitation the owner minted for this client, as the link or as printed"></textarea>
159
125
  <button type="submit" name="decision" value="allow">Allow</button><button type="submit" name="decision" value="deny">Deny</button></form>`;
160
126
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quo-systems/dock",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "The dock: what every estate on Quo needs and nobody writes twice. A daemon and the quo command, the front desk, the user being and the avatar, harbors on disk, in a tab and on the edge, the model sides and the screen.",
5
5
  "keywords": [
6
6
  "quo",
@@ -31,6 +31,10 @@
31
31
  "types": "./dist/harbor/browser.d.ts",
32
32
  "default": "./dist/harbor/browser.js"
33
33
  },
34
+ "./harbor/capacitor": {
35
+ "types": "./dist/harbor/capacitor.d.ts",
36
+ "default": "./dist/harbor/capacitor.js"
37
+ },
34
38
  "./cli/daemon": {
35
39
  "types": "./dist/cli/daemon.d.ts",
36
40
  "default": "./dist/cli/daemon.js"
@@ -55,6 +59,10 @@
55
59
  "types": "./dist/human/html.d.ts",
56
60
  "default": "./dist/human/html.js"
57
61
  },
62
+ "./human/local": {
63
+ "types": "./dist/human/local.d.ts",
64
+ "default": "./dist/human/local.js"
65
+ },
58
66
  "./harbor/edge/worker": {
59
67
  "types": "./dist/harbor/edge/worker.d.ts",
60
68
  "default": "./dist/harbor/edge/worker.js"
@@ -67,8 +75,11 @@
67
75
  "prepublishOnly": "cd ../.. && npm run check && npm run check:terrain"
68
76
  },
69
77
  "dependencies": {
78
+ "@aparajita/capacitor-secure-storage": "^8.0.0",
79
+ "@capacitor/core": "^8.5.1",
80
+ "@capacitor/filesystem": "^8.1.3",
70
81
  "@modelcontextprotocol/sdk": "^1.30.0",
71
- "@quo-systems/quo": "^0.2.2",
82
+ "@quo-systems/quo": "^0.2.4",
72
83
  "esbuild": "^0.28.2",
73
84
  "ws": "^8.21.3"
74
85
  },