@quo-systems/dock 0.2.3 → 0.2.5

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 (54) hide show
  1. package/beings/carry.ts +7 -3
  2. package/beings/desk.ts +5 -1
  3. package/beings/look.ts +12 -3
  4. package/beings/quo-dock.md +22 -13
  5. package/beings/user.ts +20 -6
  6. package/dist/beings/carry.js +10 -4
  7. package/dist/beings/desk.d.ts +0 -1
  8. package/dist/beings/desk.js +5 -1
  9. package/dist/beings/look.d.ts +2 -0
  10. package/dist/beings/look.js +9 -0
  11. package/dist/beings/user.d.ts +24 -0
  12. package/dist/beings/user.js +20 -6
  13. package/dist/harbor/browser.d.ts +4 -4
  14. package/dist/harbor/browser.js +18 -9
  15. package/dist/harbor/capacitor.d.ts +19 -0
  16. package/dist/harbor/capacitor.js +151 -0
  17. package/dist/harbor/disk.js +8 -3
  18. package/dist/harbor/edge/edge.js +2 -1
  19. package/dist/harbor/edge/exercise.js +2 -1
  20. package/dist/harbor/edge/storage.d.ts +0 -3
  21. package/dist/harbor/edge/storage.js +6 -25
  22. package/dist/harbor/files.d.ts +2 -1
  23. package/dist/harbor/files.js +87 -22
  24. package/dist/harbor/seal.d.ts +3 -0
  25. package/dist/harbor/seal.js +25 -0
  26. package/dist/human/html.d.ts +1 -0
  27. package/dist/human/html.js +40 -19
  28. package/dist/human/local.d.ts +10 -0
  29. package/dist/human/local.js +10 -0
  30. package/dist/human/screen.js +32 -18
  31. package/dist/human/tab.d.ts +1 -0
  32. package/dist/human/tab.js +59 -3
  33. package/dist/human/web.d.ts +1 -0
  34. package/dist/human/web.js +34 -4
  35. package/dist/mcp/runner.js +5 -3
  36. package/dist/mcp/server.js +5 -3
  37. package/harbor/browser.ts +28 -17
  38. package/harbor/capacitor.ts +159 -0
  39. package/harbor/disk.ts +8 -3
  40. package/harbor/edge/edge.ts +2 -1
  41. package/harbor/edge/exercise.ts +2 -1
  42. package/harbor/edge/storage.ts +6 -25
  43. package/harbor/files.ts +79 -19
  44. package/harbor/quo-harbor.md +66 -14
  45. package/harbor/seal.ts +26 -0
  46. package/human/html.ts +39 -18
  47. package/human/local.ts +26 -0
  48. package/human/quo-human.md +70 -13
  49. package/human/screen.ts +27 -15
  50. package/human/tab.ts +50 -5
  51. package/human/web.ts +34 -5
  52. package/mcp/runner.ts +5 -3
  53. package/mcp/server.ts +5 -3
  54. package/package.json +14 -2
@@ -17,7 +17,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
17
17
  // <dir>/
18
18
  // lease pid of the one process that holds this harbor
19
19
  // classes/index.ts the default class source, a module exporting classes
20
- // wards/<name>/ seed, partition.json, ward.json: see files.ts
20
+ // tab/index.ts the classes for the tab, served bundled by the web route: human/web.ts
21
+ // wards/<name>/ seed, partition.json, ward.json, or one sealed
22
+ // blob when the daemon holds QUO_SEED_KEY: see files.ts
21
23
  // reach.json the directory's hints
22
24
  import { mkdir, readFile, writeFile, unlink, stat } from 'node:fs/promises';
23
25
  import { existsSync } from 'node:fs';
@@ -54,7 +56,10 @@ export class DiskHarbor extends Harbor {
54
56
  #held = false;
55
57
  constructor(dir) {
56
58
  const abs = resolve(dir);
57
- super(new Files(abs), async (rec) => ({ ...BUILT_IN, ...(await loadClasses(isAbsolute(rec.code) ? rec.code : join(abs, rec.code))) }));
59
+ // The key the environment gives, the edge's name for it: from the
60
+ // Keychain through the app that spawned this daemon, or nothing on a
61
+ // droplet, whose folder stays plain under its file modes.
62
+ super(new Files(abs, process.env.QUO_SEED_KEY), async (rec) => ({ ...BUILT_IN, ...(await loadClasses(isAbsolute(rec.code) ? rec.code : join(abs, rec.code))) }));
58
63
  this.dir = abs;
59
64
  }
60
65
  // Create a harbor folder with one ward, and the root's setup in it: the
@@ -64,7 +69,7 @@ export class DiskHarbor extends Harbor {
64
69
  // has one.
65
70
  static async init(dir, name = 'main', user = 'me') {
66
71
  const h = new DiskHarbor(dir);
67
- if (existsSync(join(h.dir, 'wards', name, 'seed')))
72
+ if ((await h.store.list()).includes(name))
68
73
  throw new Error(`ward ${name} already exists in ${h.dir}`);
69
74
  await mkdir(join(h.dir, 'classes'), { recursive: true });
70
75
  const classes = join(h.dir, DEFAULT_CODE);
@@ -2,7 +2,8 @@ import { isSilence } from '@quo-systems/quo';
2
2
  import { User, Desk, Avatar } from '../../beings/index.js';
3
3
  import { setup } from '../../beings/setup.js';
4
4
  import { Harbor, Socket as Held, SUITE } from '@quo-systems/quo/harbor';
5
- import { DurableStorage, sealKey } from './storage.js';
5
+ import { DurableStorage } from './storage.js';
6
+ import { sealKey } from '../seal.js';
6
7
  export const BUILT_IN = { User, Desk, Avatar };
7
8
  const json = (status, v, headers = {}) => new Response(JSON.stringify(v), { status, headers: { 'content-type': 'application/json', ...headers } });
8
9
  const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, quo-suite' };
@@ -11,7 +11,8 @@ import { conform, conformStore, conformReach } from '@quo-systems/quo/conformanc
11
11
  import { request as byRequest } from '@quo-systems/quo/harbor';
12
12
  import { Printer, Shop, Customer, Echo, Member } from '@quo-systems/quo/conformance';
13
13
  import { EdgeHarbor } from './edge.js';
14
- import { DurableStorage, sealKey } from './storage.js';
14
+ import { DurableStorage } from './storage.js';
15
+ import { sealKey } from '../seal.js';
15
16
  const assert = {
16
17
  equal(a, b, m) {
17
18
  if (a !== b)
@@ -1,8 +1,5 @@
1
1
  import { type Kept, type Store, type WardRecord } from '@quo-systems/quo/harbor';
2
2
  import type { Storage } from './platform.d.ts';
3
- export declare function sealKey(secret: string): Promise<CryptoKey>;
4
- export declare function seal(key: CryptoKey, seed: Uint8Array): Promise<string>;
5
- export declare function open(key: CryptoKey, sealed: string): Promise<Uint8Array>;
6
3
  export declare class DurableStorage implements Store {
7
4
  #private;
8
5
  readonly storage: Storage;
@@ -2,32 +2,13 @@
2
2
  // The store as Durable Object storage: one object per harbor, its storage
3
3
  // holding one row per ward under `ward:<name>`, seed, partition and record,
4
4
  // and one row per hint under `hint:<pk>`. The seed is kept sealed under a
5
- // key from the platform's secrets, `QUO_SEED_KEY`, so the storage holds
6
- // ciphertext and the secret store holds the one key, which is what the
7
- // harbor document's table says the edge does. Values cross as JSON, as
8
- // they do into a file, because the ward hands the partition out through a
9
- // guard that structured clone refuses.
10
- import { arithmetic } from '@quo-systems/quo/ward';
5
+ // key from the platform's secrets, `QUO_SEED_KEY`, with the seal in
6
+ // `../seal.ts`, so the storage holds ciphertext and the secret store holds
7
+ // the one key, which is what the harbor document's table says the edge
8
+ // does. Values cross as JSON, as they do into a file, because the ward
9
+ // hands the partition out through a guard that structured clone refuses.
11
10
  import { values } from '@quo-systems/quo/harbor';
12
- // Bytes as the platform's crypto wants them: over a plain ArrayBuffer.
13
- const plain = (b) => new Uint8Array(b);
14
- const { hex, unhex } = arithmetic;
15
- // The seal on a seed: AES-GCM under the platform key, a fresh nonce each
16
- // time, nonce and ciphertext together as hex.
17
- export async function sealKey(secret) {
18
- if (!/^[0-9a-f]{64}$/.test(secret))
19
- throw new Error('QUO_SEED_KEY is 32 bytes as hex');
20
- return crypto.subtle.importKey('raw', plain(unhex(secret)), 'AES-GCM', false, ['encrypt', 'decrypt']);
21
- }
22
- export async function seal(key, seed) {
23
- const iv = crypto.getRandomValues(new Uint8Array(12));
24
- const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain(seed)));
25
- return hex(iv) + hex(ct);
26
- }
27
- export async function open(key, sealed) {
28
- const b = plain(unhex(sealed));
29
- return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, key, b.subarray(12)));
30
- }
11
+ import { seal, open } from '../seal.js';
31
12
  // `prefix` keeps more than one harbor apart in one object's storage: the
32
13
  // exercise does that, a deployment never does.
33
14
  export class DurableStorage {
@@ -2,7 +2,8 @@ import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
2
2
  export declare class Files implements Store {
3
3
  #private;
4
4
  readonly dir: string;
5
- constructor(dir: string);
5
+ constructor(dir: string, key?: string);
6
+ get sealed(): boolean;
6
7
  list(): Promise<string[]>;
7
8
  load(name: string): Promise<Kept | undefined>;
8
9
  put(name: string, kept: Kept): Promise<void>;
@@ -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
+ }
@@ -28,6 +28,7 @@ export type Model = {
28
28
  blueprint: Blueprint | null;
29
29
  look: Look;
30
30
  tree: Node | null;
31
+ pages: Record<string, Node | null>;
31
32
  notice: string;
32
33
  answers: Record<string, Word>;
33
34
  pushes: JsonObject[];
@@ -1,5 +1,5 @@
1
1
  import { SILENCE_TEXT, UNREACHED_TEXT, wordText } from '../beings/side.js';
2
- import { hint, hintFor, sanitise, groups as grouped } from '../beings/look.js';
2
+ import { hint, hintFor, sanitise, groups as grouped, presentation } from '../beings/look.js';
3
3
  import { paint } from './tree.js';
4
4
  export const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
5
5
  const props = (schema) => Object.entries((schema?.properties ?? {}));
@@ -154,17 +154,35 @@ export function ordered(asks, l) {
154
154
  return [...want.map((n) => asks.find((a) => a.name === n)).filter((a) => a !== undefined), ...asks.filter((a) => !want.includes(a.name))];
155
155
  }
156
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']);
159
157
  export function page(m) {
160
158
  const bp = m.blueprint;
159
+ const pres = presentation(bp);
161
160
  const one = (l, prefix = '') => (a) => {
162
161
  const w = m.answers[a.name];
163
162
  const h = hint(l, a.name.startsWith(prefix) ? a.name.slice(prefix.length) : a.name);
164
163
  return `<section>${form(a, h)}${w ? face(w, a.output) : ''}</section>`;
165
164
  };
165
+ // an answer shown as the page asks: as text, as cards, as a list, or by its shape
166
+ const answer = (name, as) => {
167
+ const a = bp?.asks.find((x) => x.name === name);
168
+ const w = a ? m.answers[name] : undefined;
169
+ if (!a || !w)
170
+ return '';
171
+ if (w.word === 'object' && as === 'text')
172
+ return `<div class="answer">${view(w.value === null || typeof w.value !== 'object' ? w.value : JSON.stringify(w.value))}</div>`;
173
+ if (w.word === 'object' && as === 'cards' && Array.isArray(w.value))
174
+ return `<div class="cards">${w.value.map((v) => `<div class="card">${view(v)}</div>`).join('')}</div>`;
175
+ if (w.word === 'object' && as === 'list' && Array.isArray(w.value))
176
+ return `<ul class="answer">${w.value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>`;
177
+ return face(w, a.output);
178
+ };
166
179
  const groups = grouped(bp);
167
180
  const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
181
+ // A standing's section. With her page, the far tree is painted inside it
182
+ // through a painter of her own: a name it uses is one of her asks, carried,
183
+ // and never one of the carrier's or another standing's, so a far page
184
+ // reaches nothing outside its section; her title is the page's, and the
185
+ // section keeps her look. Without one, her asks are forms under her name.
168
186
  const section = (id) => {
169
187
  const g = groups[id];
170
188
  const asks = bp && g ? bp.asks.filter((a) => g.asks?.includes(a.name)) : [];
@@ -173,6 +191,19 @@ export function page(m) {
173
191
  const kept = sanitise(g.look);
174
192
  const l = look(kept);
175
193
  const prefix = `${id}-`;
194
+ const tree = m.pages[id];
195
+ if (tree) {
196
+ const hers = {
197
+ form: (name) => {
198
+ const a = asks.find((x) => x.name === prefix + name);
199
+ return a ? one(kept, prefix)(a) : '';
200
+ },
201
+ answer: (name, as) => (asks.some((x) => x.name === prefix + name) ? answer(prefix + name, as) : ''),
202
+ standing: () => '',
203
+ standings: () => '',
204
+ };
205
+ return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div></section>`;
206
+ }
176
207
  const want = (kept.order ?? []).map((n) => prefix + n);
177
208
  const inOrder = ordered(asks, { order: want });
178
209
  return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
@@ -182,30 +213,20 @@ export function page(m) {
182
213
  // uses looked up in her describe, so a page shows nothing her gate hid.
183
214
  const painter = {
184
215
  form: (name) => {
185
- const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !PRESENTATION.has(x.name));
216
+ const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !pres.has(x.name));
186
217
  return a ? one(m.look)(a) : '';
187
218
  },
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)
192
- return '';
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
- },
219
+ answer: (name, as) => (pres.has(name) ? '' : answer(name, as)),
201
220
  standing: section,
202
221
  standings: () => far,
203
222
  };
204
- const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !PRESENTATION.has(a.name)), m.look).map(one(m.look)).join('') : '';
223
+ const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !pres.has(a.name)), m.look).map(one(m.look)).join('') : '';
205
224
  const asks = m.tree ? `<div class="page">${paint(m.tree, painter)}</div>` : own + far;
206
225
  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;
207
226
  const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown)}</aside>` : '';
208
227
  const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
209
228
  const mine = look(m.look);
210
- 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}`;
229
+ // a page carries its own title, so the header keeps only the notice; a being painted as forms is headed by her name
230
+ const head = m.tree ? '' : `${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1>`;
231
+ return `<header${mine.style}>${head}<p class="notice">${escape(m.notice)}</p></header>${notes}<main${mine.style}>${asks}</main>${pushes}`;
211
232
  }
@@ -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
+ }
@@ -11,12 +11,12 @@ import { word } from '../beings/side.js';
11
11
  import { isInvitation } from '../beings/link.js';
12
12
  import { page, values, hintFor } from './html.js';
13
13
  import { sanitiseTree, answersIn } from './tree.js';
14
- import { sanitise } from '../beings/look.js';
14
+ import { groups, sanitise } from '../beings/look.js';
15
15
  import { isSilence, isWord } from '@quo-systems/quo';
16
16
  export async function screenSide(avatar, surface, options = {}) {
17
17
  const after = options.after ?? (async () => { });
18
18
  const notice = options.notice ?? '';
19
- const model = { blueprint: null, look: {}, tree: null, notice, answers: {}, pushes: [] };
19
+ const model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [] };
20
20
  let seen = null;
21
21
  const show = () => surface.show(page(model));
22
22
  // Her describe, again: the page follows the digest.
@@ -29,7 +29,8 @@ export async function screenSide(avatar, surface, options = {}) {
29
29
  }
30
30
  else {
31
31
  const d = await digest(bp);
32
- if (d !== seen) {
32
+ const moved = d !== seen;
33
+ if (moved) {
33
34
  seen = d;
34
35
  model.blueprint = bp;
35
36
  for (const k of Object.keys(model.answers))
@@ -42,21 +43,34 @@ export async function screenSide(avatar, surface, options = {}) {
42
43
  }
43
44
  else
44
45
  model.look = {};
45
- // her page, once per digest: a tree of values, or nothing and her asks are forms
46
- if (bp.asks.some((a) => a.name === 'page')) {
47
- const t = await avatar.call('page');
48
- model.tree = isSilence(t) || isWord(t) ? null : sanitiseTree(t);
49
- }
50
- else
51
- model.tree = null;
52
- // a read-only ask that needs nothing typed, or one her page shows the answer of, is run on
53
- // her behalf, so the page opens with what it shows
54
- const wanted = new Set(model.tree ? answersIn(model.tree) : []);
55
- for (const a of bp.asks) {
56
- const needs = (a.input.required ?? []).length > 0;
57
- if ((hintFor(bp, model.look, a.name).readOnly || wanted.has(a.name)) && !needs && !(a.name in model.answers))
58
- model.answers[a.name] = word(await avatar.call(a.name, {}));
59
- }
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 trees = () => [model.tree ? answersIn(model.tree) : [], ...Object.entries(model.pages).map(([id, t]) => (t ? answersIn(t).map((n) => `${id}-${n}`) : []))].flat();
51
+ const shows = new Set(trees());
52
+ const tree = async (name) => {
53
+ const t = await avatar.call(name);
54
+ return isSilence(t) || isWord(t) ? null : sanitiseTree(t);
55
+ };
56
+ model.tree = bp.asks.some((a) => a.name === 'page') ? await tree('page') : null;
57
+ // and the page of every standing she carries, the same way, painted in that standing's section;
58
+ // a name a carried page uses is one of that standing's asks, carried, and nothing else
59
+ model.pages = {};
60
+ for (const [id, g] of Object.entries(groups(bp)))
61
+ if (typeof g.page === 'string' && bp.asks.some((a) => a.name === g.page))
62
+ model.pages[id] = await tree(g.page);
63
+ const wanted = new Set(trees());
64
+ for (const n of shows)
65
+ if (wanted.has(n))
66
+ delete model.answers[n];
67
+ // a read-only ask that needs nothing typed is run on her behalf once per digest, and one her page
68
+ // shows the answer of after every ask, so the page opens and stays with what it shows
69
+ for (const a of bp.asks) {
70
+ const needs = (a.input.required ?? []).length > 0;
71
+ const run = wanted.has(a.name) || (moved && hintFor(bp, model.look, a.name).readOnly);
72
+ if (run && !needs && !(a.name in model.answers))
73
+ model.answers[a.name] = word(await avatar.call(a.name, {}));
60
74
  }
61
75
  }
62
76
  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();