@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
@@ -1,35 +1,99 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The store as a folder on disk, one folder per ward:
2
+ // The store as a folder on disk, one folder per ward, in one of two forms.
3
+ //
4
+ // Plain, when the store has no key, what the droplets run:
3
5
  //
4
6
  // <dir>/wards/<name>/seed 32 bytes, hex, mode 0600
5
7
  // <dir>/wards/<name>/partition.json
6
8
  // <dir>/wards/<name>/ward.json the ward record
9
+ //
10
+ // Sealed, when the store holds a key, what a device's daemon runs with the
11
+ // key from its Keychain:
12
+ //
13
+ // <dir>/wards/<name>/ward.sealed seed, partition and record as one
14
+ // JSON, sealed under the key, hex
15
+ //
16
+ // and in both:
17
+ //
7
18
  // <dir>/reach.json the directory's hints
8
19
  //
9
- // Every write of the partition goes through a temp file and a rename, and
10
- // writes are queued per ward so two calls never race on one file.
20
+ // A ward is whole in either form, and a store that meets the other form
21
+ // refuses it by name: a daemon started without its key, or with one against
22
+ // a plain folder, fails loudly instead of booting on what it cannot read.
23
+ // Sealing a plain folder is a deliberate command, never a boot's doing.
24
+ // Every write of a ward goes through a temp file and a rename, and writes
25
+ // are queued per ward so two calls never race on one file.
11
26
  import { mkdir, readFile, writeFile, rename, readdir, rm, chmod } from 'node:fs/promises';
12
27
  import { existsSync } from 'node:fs';
13
28
  import { join } from 'node:path';
14
29
  import { arithmetic } from '@quo-systems/quo/ward';
30
+ import { sealKey, seal, open } from './seal.js';
15
31
  const { hex, unhex } = arithmetic;
16
32
  export class Files {
17
33
  dir;
34
+ #key;
18
35
  #queues = new Map();
19
- constructor(dir) {
36
+ constructor(dir, key) {
20
37
  this.dir = dir;
38
+ if (key)
39
+ this.#key = sealKey(key);
21
40
  }
22
41
  #ward(name) {
23
42
  return join(this.dir, 'wards', name);
24
43
  }
44
+ get sealed() {
45
+ return this.#key !== undefined;
46
+ }
47
+ // The form a folder holds, checked against the form this store speaks.
48
+ #form(name) {
49
+ const wd = this.#ward(name);
50
+ const form = existsSync(join(wd, 'ward.sealed')) ? 'sealed' : existsSync(join(wd, 'seed')) ? 'plain' : undefined;
51
+ if (form === 'sealed' && !this.sealed)
52
+ throw new Error(`ward ${name} in ${this.dir} is sealed and this harbor has no key`);
53
+ if (form === 'plain' && this.sealed)
54
+ throw new Error(`ward ${name} in ${this.dir} is plain and this harbor holds a key`);
55
+ return form;
56
+ }
57
+ // One write at a time per ward, through a temp file and a rename.
58
+ #write(name, file, body) {
59
+ const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
60
+ const tmp = join(this.#ward(name), `${file}.tmp`);
61
+ await writeFile(tmp, body, { mode: 0o600 });
62
+ await rename(tmp, join(this.#ward(name), file));
63
+ });
64
+ this.#queues.set(name, next.catch(() => { }));
65
+ return next;
66
+ }
67
+ async #read(name) {
68
+ const blob = (await readFile(join(this.#ward(name), 'ward.sealed'), 'utf8')).trim();
69
+ return JSON.parse(new TextDecoder().decode(await open(await this.#key, blob)));
70
+ }
71
+ // A sealed ward is rewritten whole, so the read sits inside the queue
72
+ // with the write: two changes to one ward never lose each other's part.
73
+ #keep(name, change) {
74
+ const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
75
+ const wd = this.#ward(name);
76
+ const blob = change(existsSync(join(wd, 'ward.sealed')) ? await this.#read(name) : undefined);
77
+ const sealed = await seal(await this.#key, new TextEncoder().encode(JSON.stringify(blob)));
78
+ await writeFile(join(wd, 'ward.sealed.tmp'), sealed + '\n', { mode: 0o600 });
79
+ await rename(join(wd, 'ward.sealed.tmp'), join(wd, 'ward.sealed'));
80
+ });
81
+ this.#queues.set(name, next.catch(() => { }));
82
+ return next;
83
+ }
25
84
  async list() {
26
85
  const wards = join(this.dir, 'wards');
27
- return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed'))) : [];
86
+ return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed')) || existsSync(join(wards, n, 'ward.sealed'))) : [];
28
87
  }
29
88
  async load(name) {
30
- const wd = this.#ward(name);
31
- if (!existsSync(join(wd, 'seed')))
89
+ const form = this.#form(name);
90
+ if (!form)
32
91
  return undefined;
92
+ if (form === 'sealed') {
93
+ const b = await this.#read(name);
94
+ return { seed: unhex(b.seed), partition: b.partition, record: b.record };
95
+ }
96
+ const wd = this.#ward(name);
33
97
  return {
34
98
  seed: unhex((await readFile(join(wd, 'seed'), 'utf8')).trim()),
35
99
  partition: JSON.parse(await readFile(join(wd, 'partition.json'), 'utf8')),
@@ -37,31 +101,32 @@ export class Files {
37
101
  };
38
102
  }
39
103
  async put(name, kept) {
40
- const wd = this.#ward(name);
41
- if (existsSync(join(wd, 'seed')))
104
+ if (this.#form(name))
42
105
  throw new Error(`ward ${name} already exists in ${this.dir}`);
106
+ const wd = this.#ward(name);
43
107
  await mkdir(wd, { recursive: true, mode: 0o700 });
108
+ if (this.sealed)
109
+ return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
44
110
  await writeFile(join(wd, 'seed'), hex(kept.seed), { mode: 0o600 });
45
111
  await chmod(join(wd, 'seed'), 0o600);
46
112
  await writeFile(join(wd, 'partition.json'), JSON.stringify(kept.partition) + '\n', { mode: 0o600 });
47
113
  await writeFile(join(wd, 'ward.json'), JSON.stringify(kept.record, null, 2) + '\n', { mode: 0o600 });
48
114
  }
49
- save(name, partition) {
50
- const wd = this.#ward(name);
51
- if (!existsSync(join(wd, 'seed')))
52
- return Promise.resolve(); // a name not kept is nothing
53
- const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
54
- const tmp = join(wd, 'partition.json.tmp');
55
- await writeFile(tmp, JSON.stringify(partition) + '\n', { mode: 0o600 });
56
- await rename(tmp, join(wd, 'partition.json'));
57
- });
58
- this.#queues.set(name, next.catch(() => { }));
59
- return next;
115
+ async save(name, partition) {
116
+ const form = this.#form(name);
117
+ if (!form)
118
+ return; // a name not kept is nothing
119
+ if (form === 'sealed')
120
+ return this.#keep(name, (b) => ({ ...b, partition }));
121
+ return this.#write(name, 'partition.json', JSON.stringify(partition) + '\n');
60
122
  }
61
123
  async record(name, record) {
62
- if (!existsSync(join(this.#ward(name), 'seed')))
124
+ const form = this.#form(name);
125
+ if (!form)
63
126
  return;
64
- await writeFile(join(this.#ward(name), 'ward.json'), JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
127
+ if (form === 'sealed')
128
+ return this.#keep(name, (b) => ({ ...b, record }));
129
+ return this.#write(name, 'ward.json', JSON.stringify(record, null, 2) + '\n');
65
130
  }
66
131
  async take(name) {
67
132
  const kept = await this.load(name);
@@ -0,0 +1,3 @@
1
+ export declare function sealKey(secret: string): Promise<CryptoKey>;
2
+ export declare function seal(key: CryptoKey, bytes: Uint8Array): Promise<string>;
3
+ export declare function open(key: CryptoKey, sealed: string): Promise<Uint8Array>;
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The seal: what a store does to a ward's bytes before they rest on a data
3
+ // store, under one key the secret store holds. One piece with three users,
4
+ // the edge under the platform's secret, the daemon under a key from its
5
+ // environment, the phone under a key from the Keychain. AES-GCM under a
6
+ // 32-byte key, a fresh nonce each time, nonce and ciphertext together as
7
+ // hex. WebCrypto only, so it runs wherever a store does.
8
+ import { arithmetic } from '@quo-systems/quo/ward';
9
+ const { hex, unhex } = arithmetic;
10
+ // Bytes as the platform's crypto wants them: over a plain ArrayBuffer.
11
+ const plain = (b) => new Uint8Array(b);
12
+ export async function sealKey(secret) {
13
+ if (!/^[0-9a-f]{64}$/.test(secret))
14
+ throw new Error('a seal key is 32 bytes as hex');
15
+ return crypto.subtle.importKey('raw', plain(unhex(secret)), 'AES-GCM', false, ['encrypt', 'decrypt']);
16
+ }
17
+ export async function seal(key, bytes) {
18
+ const iv = crypto.getRandomValues(new Uint8Array(12));
19
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain(bytes)));
20
+ return hex(iv) + hex(ct);
21
+ }
22
+ export async function open(key, sealed) {
23
+ const b = plain(unhex(sealed));
24
+ return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, key, b.subarray(12)));
25
+ }
@@ -1,5 +1,7 @@
1
+ import { type Node } from './tree.ts';
1
2
  export type Door = {
2
3
  world?: string;
3
4
  host: string;
4
5
  };
6
+ export declare function doorTree(at: Door): Node;
5
7
  export declare function door(at: Door): string;
@@ -5,15 +5,27 @@
5
5
  // Quo's page and not the estate's: it says what this is and that a world
6
6
  // here is entered by a link someone sends you, and it asks for nothing,
7
7
  // since there is nothing a stranger could type that would let them in. A
8
- // world that wants a face of its own has a public being with a look, or
9
- // its own page on its own origin; this is the default and nothing more.
10
- // Pure, strings in and strings out, under the tab's policy: no script, no
11
- // image that is not a data URI, styles from the one stylesheet.
12
- import { escape } from './html.js';
8
+ // world that wants a face of its own has a public being with a page and a
9
+ // look, or its own page on its own origin; this is the default and nothing
10
+ // more. It is the first page written in the grammar, `tree.ts`, painted
11
+ // with nobody behind it: no ask, no answer, no standing.
12
+ import { paint, nobody } from './tree.js';
13
13
  // The mark: a ring with a gap, one being's voice reaching another's door.
14
14
  const MARK = 'data:image/svg+xml;base64,' +
15
15
  btoa(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"><circle cx="32" cy="32" r="22" stroke-dasharray="110 30" transform="rotate(-60 32 32)"/><circle cx="32" cy="32" r="5" fill="currentColor" stroke="none"/></svg>`);
16
+ export function doorTree(at) {
17
+ return {
18
+ kind: 'stack',
19
+ of: [
20
+ { kind: 'image', src: MARK, alt: '' },
21
+ { kind: 'text', text: 'quo', role: 'title' },
22
+ { kind: 'text', text: at.world ? `${at.world} at ${at.host}` : at.host, role: 'label' },
23
+ { kind: 'text', text: at.world ? 'This world is entered by invitation.' : 'The worlds here are entered by invitation.', role: 'lead' },
24
+ { kind: 'text', text: 'An invitation is a link someone sends you. Open it here, and you are in: no account, no password, nothing to type. What you can do inside is what the world shows you, and it is yours to keep on this device.' },
25
+ { kind: 'text', text: 'Nothing on this page asks anything of you. If you were sent here without a link, ask the person who sent you for one.', role: 'quiet' },
26
+ ],
27
+ };
28
+ }
16
29
  export function door(at) {
17
- const where = at.world ? `<p class="where">${escape(at.world)} <span>at ${escape(at.host)}</span></p>` : `<p class="where">${escape(at.host)}</p>`;
18
- return `<main class="door"><img class="mark" alt="" src="${MARK}"><h1>quo</h1>${where}<p class="lead">${at.world ? 'This world is entered by invitation.' : 'The worlds here are entered by invitation.'}</p><p>An invitation is a link someone sends you. Open it here, and you are in: no account, no password, nothing to type. What you can do inside is what the world shows you, and it is yours to keep on this device.</p><p class="quiet">Nothing on this page asks anything of you. If you were sent here without a link, ask the person who sent you for one.</p></main>`;
30
+ return `<main class="door">${paint(doorTree(at), nobody)}</main>`;
19
31
  }
@@ -1,6 +1,7 @@
1
1
  import type { Ask, Blueprint, Json, JsonObject } from '@quo-systems/quo';
2
2
  import { type Word } from '../beings/side.ts';
3
3
  import { hintFor, type Look, type Hint } from '../beings/look.ts';
4
+ import { type Node } from './tree.ts';
4
5
  export type Property = {
5
6
  type?: string;
6
7
  description?: string;
@@ -26,6 +27,7 @@ export declare function face(w: Word, schema?: JsonObject): string;
26
27
  export type Model = {
27
28
  blueprint: Blueprint | null;
28
29
  look: Look;
30
+ tree: Node | null;
29
31
  notice: string;
30
32
  answers: Record<string, Word>;
31
33
  pushes: JsonObject[];
@@ -1,5 +1,6 @@
1
1
  import { SILENCE_TEXT, UNREACHED_TEXT, wordText } from '../beings/side.js';
2
2
  import { hint, hintFor, sanitise, groups as grouped } from '../beings/look.js';
3
+ import { paint } from './tree.js';
3
4
  export const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
4
5
  const props = (schema) => Object.entries((schema?.properties ?? {}));
5
6
  const required = (schema) => new Set((schema?.required ?? []));
@@ -153,6 +154,8 @@ export function ordered(asks, l) {
153
154
  return [...want.map((n) => asks.find((a) => a.name === n)).filter((a) => a !== undefined), ...asks.filter((a) => !want.includes(a.name))];
154
155
  }
155
156
  export { hintFor };
157
+ // The asks that are presentation and never a form: her look and her page.
158
+ const PRESENTATION = new Set(['look', 'page']);
156
159
  export function page(m) {
157
160
  const bp = m.blueprint;
158
161
  const one = (l, prefix = '') => (a) => {
@@ -162,26 +165,49 @@ export function page(m) {
162
165
  };
163
166
  const groups = grouped(bp);
164
167
  const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
165
- const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && a.name !== 'look'), m.look).map(one(m.look)).join('') : '';
166
- const far = bp
167
- ? Object.entries(groups)
168
- .map(([id, g]) => {
169
- const asks = bp.asks.filter((a) => g.asks?.includes(a.name));
170
- if (!asks.length)
168
+ const section = (id) => {
169
+ const g = groups[id];
170
+ const asks = bp && g ? bp.asks.filter((a) => g.asks?.includes(a.name)) : [];
171
+ if (!asks.length)
172
+ return '';
173
+ const kept = sanitise(g.look);
174
+ const l = look(kept);
175
+ const prefix = `${id}-`;
176
+ const want = (kept.order ?? []).map((n) => prefix + n);
177
+ const inOrder = ordered(asks, { order: want });
178
+ return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
179
+ };
180
+ const far = Object.keys(groups).map(section).join('');
181
+ // Her page, when she answered one: painted from her tree, each name it
182
+ // uses looked up in her describe, so a page shows nothing her gate hid.
183
+ const painter = {
184
+ form: (name) => {
185
+ const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !PRESENTATION.has(x.name));
186
+ return a ? one(m.look)(a) : '';
187
+ },
188
+ answer: (name, as) => {
189
+ const a = bp?.asks.find((x) => x.name === name);
190
+ const w = a ? m.answers[name] : undefined;
191
+ if (!a || !w)
171
192
  return '';
172
- const kept = sanitise(g.look);
173
- const l = look(kept);
174
- const prefix = `${id}-`;
175
- const want = (kept.order ?? []).map((n) => prefix + n);
176
- const inOrder = ordered(asks, { order: want });
177
- return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
178
- })
179
- .join('')
180
- : '';
181
- const asks = own + far;
193
+ if (w.word === 'object' && as === 'text')
194
+ return `<div class="answer">${view(w.value === null || typeof w.value !== 'object' ? w.value : JSON.stringify(w.value))}</div>`;
195
+ if (w.word === 'object' && as === 'cards' && Array.isArray(w.value))
196
+ return `<div class="cards">${w.value.map((v) => `<div class="card">${view(v)}</div>`).join('')}</div>`;
197
+ if (w.word === 'object' && as === 'list' && Array.isArray(w.value))
198
+ return `<ul class="answer">${w.value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>`;
199
+ return face(w, a.output);
200
+ },
201
+ standing: section,
202
+ standings: () => far,
203
+ };
204
+ const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !PRESENTATION.has(a.name)), m.look).map(one(m.look)).join('') : '';
205
+ const asks = m.tree ? `<div class="page">${paint(m.tree, painter)}</div>` : own + far;
182
206
  const shown = bp && bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? Object.fromEntries(Object.entries(bp.notes).filter(([k]) => k !== 'standings' && k !== 'name')) : bp?.notes;
183
207
  const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown)}</aside>` : '';
184
208
  const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
185
209
  const mine = look(m.look);
186
- return `<header${mine.style}>${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1><p class="notice">${escape(m.notice)}</p></header>${notes}<main${mine.style}>${asks}</main>${pushes}`;
210
+ // a page carries its own title, so the header keeps only the notice; a being painted as forms is headed by her name
211
+ const head = m.tree ? '' : `${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1>`;
212
+ return `<header${mine.style}>${head}<p class="notice">${escape(m.notice)}</p></header>${notes}<main${mine.style}>${asks}</main>${pushes}`;
187
213
  }
@@ -0,0 +1,10 @@
1
+ import type { JsonObject } from '@quo-systems/quo';
2
+ import type { Subject } from '../beings/side.ts';
3
+ export declare const TAB = "tab";
4
+ type Door = {
5
+ answer(asker: {
6
+ id: string;
7
+ }, method?: string, args?: JsonObject): Promise<unknown>;
8
+ };
9
+ export declare function local(being: Door): Subject;
10
+ export {};
@@ -0,0 +1,10 @@
1
+ // The asker the tab speaks under to its own beings.
2
+ export const TAB = 'tab';
3
+ export function local(being) {
4
+ const me = { id: TAB };
5
+ return {
6
+ tools: async () => (await being.answer(me)),
7
+ call: (name, args = {}) => being.answer(me, name, args),
8
+ ears: new Set(),
9
+ };
10
+ }
@@ -10,12 +10,13 @@ import { digest } from '@quo-systems/quo';
10
10
  import { word } from '../beings/side.js';
11
11
  import { isInvitation } from '../beings/link.js';
12
12
  import { page, values, hintFor } from './html.js';
13
+ import { sanitiseTree, answersIn } from './tree.js';
13
14
  import { sanitise } from '../beings/look.js';
14
15
  import { isSilence, isWord } from '@quo-systems/quo';
15
16
  export async function screenSide(avatar, surface, options = {}) {
16
17
  const after = options.after ?? (async () => { });
17
18
  const notice = options.notice ?? '';
18
- const model = { blueprint: null, look: {}, notice, answers: {}, pushes: [] };
19
+ const model = { blueprint: null, look: {}, tree: null, notice, answers: {}, pushes: [] };
19
20
  let seen = null;
20
21
  const show = () => surface.show(page(model));
21
22
  // Her describe, again: the page follows the digest.
@@ -28,7 +29,8 @@ export async function screenSide(avatar, surface, options = {}) {
28
29
  }
29
30
  else {
30
31
  const d = await digest(bp);
31
- if (d !== seen) {
32
+ const moved = d !== seen;
33
+ if (moved) {
32
34
  seen = d;
33
35
  model.blueprint = bp;
34
36
  for (const k of Object.keys(model.answers))
@@ -41,12 +43,28 @@ export async function screenSide(avatar, surface, options = {}) {
41
43
  }
42
44
  else
43
45
  model.look = {};
44
- // a read-only ask that needs nothing typed is run on her behalf, so the page opens with what it shows
45
- for (const a of bp.asks) {
46
- const needs = (a.input.required ?? []).length > 0;
47
- if (hintFor(bp, model.look, a.name).readOnly && !needs && !(a.name in model.answers))
48
- model.answers[a.name] = word(await avatar.call(a.name, {}));
49
- }
46
+ }
47
+ // her page, after every ask: a tree of values from what she holds now, or nothing and her asks
48
+ // are forms. A cell moved is a page moved, and her describe need not have; so the answers the
49
+ // page shows are run again with it, and the page never stands more than one ask behind her
50
+ const shows = new Set(model.tree ? answersIn(model.tree) : []);
51
+ if (bp.asks.some((a) => a.name === 'page')) {
52
+ const t = await avatar.call('page');
53
+ model.tree = isSilence(t) || isWord(t) ? null : sanitiseTree(t);
54
+ }
55
+ else
56
+ model.tree = null;
57
+ const wanted = new Set(model.tree ? answersIn(model.tree) : []);
58
+ for (const n of shows)
59
+ if (wanted.has(n))
60
+ delete model.answers[n];
61
+ // a read-only ask that needs nothing typed is run on her behalf once per digest, and one her page
62
+ // shows the answer of after every ask, so the page opens and stays with what it shows
63
+ for (const a of bp.asks) {
64
+ const needs = (a.input.required ?? []).length > 0;
65
+ const run = wanted.has(a.name) || (moved && hintFor(bp, model.look, a.name).readOnly);
66
+ if (run && !needs && !(a.name in model.answers))
67
+ model.answers[a.name] = word(await avatar.call(a.name, {}));
50
68
  }
51
69
  }
52
70
  await after();
@@ -7,6 +7,7 @@ export type Config = {
7
7
  public: boolean;
8
8
  }>;
9
9
  ward?: string;
10
+ beings?: boolean;
10
11
  };
11
12
  export declare function start(cfg: Config, root?: HTMLElement): Promise<void>;
12
13
  export { USER };
package/dist/human/tab.js CHANGED
@@ -1,3 +1,11 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
1
9
  import { BrowserHarbor } from '../harbor/browser.js';
2
10
  import { USER } from '../beings/avatar.js';
3
11
  import { parse, strip } from '../beings/link.js';
@@ -6,6 +14,7 @@ import { domSurface } from './dom.js';
6
14
  import { guest } from './guest.js';
7
15
  import { door } from './door.js';
8
16
  import { world, relations, fresh } from './worlds.js';
17
+ import { local } from './local.js';
9
18
  const kept = (key, fallback) => {
10
19
  try {
11
20
  return JSON.parse(localStorage.getItem(key) ?? 'null') ?? fallback;
@@ -48,7 +57,22 @@ export async function start(cfg, root = document.body) {
48
57
  root.append(nav, who, status, screen);
49
58
  const say = (s) => (status.textContent = s);
50
59
  // The harbor in the tab: one database per origin, one ward per world.
60
+ // The world's code first, when its origin serves any: the classes for
61
+ // the tab, handed to the harbor beside the built-in ones before any ward
62
+ // boots, since a being kept from the last visit is booted by class name.
63
+ // A world that serves none has no beings in the tab, and the page is the
64
+ // far being's alone.
51
65
  const harbor = new BrowserHarbor('quo');
66
+ const classes = cfg.beings
67
+ ? await import(__rewriteRelativeImportExtension(`${cfg.web}/beings.js`)).then((mod) => {
68
+ const out = {};
69
+ for (const [name, v] of Object.entries(mod))
70
+ if (typeof v === 'function' && 'prototype' in v)
71
+ out[name] = v;
72
+ return out;
73
+ })
74
+ : {};
75
+ Object.assign(harbor.classes, classes);
52
76
  await harbor.boot();
53
77
  harbor.dial(cfg.quo);
54
78
  // The world this page is about: the link's, since an invitation names
@@ -100,16 +124,45 @@ export async function start(cfg, root = document.body) {
100
124
  who.append(more);
101
125
  }
102
126
  };
103
- // In, as one relation: her page. Every call rotates her keys and a
104
- // same-ward ask never crosses the harbor, so the side saves after each.
127
+ // The world's beings in the tab, for one relation: one of each class,
128
+ // booted under her key the first time and found there after, since their
129
+ // cells are in the ward and come back with it. Each is a subject of her
130
+ // own with her own side in her own section, so a note she keeps and a
131
+ // form she shows are hers, painted by the same painter as the far page.
132
+ const locals = [];
133
+ const boot = async (rel) => {
134
+ const beings = ward.partition.beings ?? {};
135
+ for (const name of Object.keys(classes)) {
136
+ const key = `${rel.key}-${name}`;
137
+ if (!beings[key]) {
138
+ const out = (await ward.ask('boot', { key, class: name }));
139
+ if (out.error)
140
+ continue;
141
+ await ward.save();
142
+ }
143
+ const being = ward.being(key);
144
+ if (!being)
145
+ continue;
146
+ const section = el('section', '', { class: 'local', 'data-being': key });
147
+ screen.append(section);
148
+ locals.push(await screenSide(local(being), domSurface(section), { after: () => ward.save() }));
149
+ }
150
+ };
151
+ // In, as one relation: her page, then the world's beings for her. Every
152
+ // call rotates her keys and a same-ward ask never crosses the harbor, so
153
+ // the side saves after each.
105
154
  const inside = async (rel, notice, called) => {
106
155
  await side?.close();
156
+ for (const l of locals.splice(0))
157
+ await l.close();
107
158
  status.remove();
108
159
  root.querySelector('form.password')?.remove();
109
160
  screen.replaceChildren();
110
161
  current = rel;
111
162
  keep(AT(pk), rel.key);
112
- const s = await screenSide(rel.avatar, domSurface(screen), { after: () => ward.save(), notice });
163
+ const mine = el('div', '', { class: 'far' });
164
+ screen.append(mine);
165
+ const s = await screenSide(rel.avatar, domSurface(mine), { after: () => ward.save(), notice });
113
166
  side = s;
114
167
  const bp = s.model.blueprint;
115
168
  const notesName = typeof bp?.notes?.name === 'string' ? bp.notes.name : '';
@@ -118,6 +171,7 @@ export async function start(cfg, root = document.body) {
118
171
  keep(NAMES(pk), { ...names(), [rel.key]: called });
119
172
  switcher();
120
173
  people();
174
+ await boot(rel);
121
175
  };
122
176
  // The way in, from any of the three: a fresh avatar joins, the ward is
123
177
  // saved, and she is the one on screen.
@@ -138,6 +192,8 @@ export async function start(cfg, root = document.body) {
138
192
  // there, the door page: a link is the only way in, and nothing to type.
139
193
  const atDoor = async () => {
140
194
  await side?.close();
195
+ for (const l of locals.splice(0))
196
+ await l.close();
141
197
  side = null;
142
198
  current = null;
143
199
  screen.replaceChildren();
@@ -0,0 +1,40 @@
1
+ import type { Json } from '@quo-systems/quo';
2
+ export type Role = 'title' | 'lead' | 'body' | 'quiet' | 'label';
3
+ export type As = 'auto' | 'text' | 'table' | 'cards' | 'list';
4
+ export type Node = {
5
+ kind: 'stack';
6
+ of: Node[];
7
+ } | {
8
+ kind: 'row';
9
+ of: Node[];
10
+ } | {
11
+ kind: 'text';
12
+ text: string;
13
+ role?: Role;
14
+ } | {
15
+ kind: 'image';
16
+ src: string;
17
+ alt?: string;
18
+ } | {
19
+ kind: 'form';
20
+ ask: string;
21
+ } | {
22
+ kind: 'answer';
23
+ ask: string;
24
+ as?: As;
25
+ } | {
26
+ kind: 'standing';
27
+ id: string;
28
+ } | {
29
+ kind: 'standings';
30
+ };
31
+ export declare function sanitiseTree(v: Json, depth?: number): Node | null;
32
+ export declare function answersIn(n: Node, out?: string[]): string[];
33
+ export type Painter = {
34
+ form(ask: string): string;
35
+ answer(ask: string, as: As): string;
36
+ standing(id: string): string;
37
+ standings(): string;
38
+ };
39
+ export declare function paint(n: Node, p: Painter): string;
40
+ export declare const nobody: Painter;