@quo-systems/dock 0.2.3 → 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.
package/harbor/files.ts CHANGED
@@ -1,39 +1,101 @@
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';
15
30
  import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
31
+ import { sealKey, seal, open } from './seal.ts';
16
32
 
17
33
  const { hex, unhex } = arithmetic;
34
+ type Blob = { seed: string; partition: Record<string, unknown>; record: WardRecord };
18
35
 
19
36
  export class Files implements Store {
20
37
  readonly dir: string;
38
+ readonly #key?: Promise<CryptoKey>;
21
39
  readonly #queues = new Map<string, Promise<void>>();
22
- constructor(dir: string) {
40
+ constructor(dir: string, key?: string) {
23
41
  this.dir = dir;
42
+ if (key) this.#key = sealKey(key);
24
43
  }
25
44
  #ward(name: string) {
26
45
  return join(this.dir, 'wards', name);
27
46
  }
47
+ get sealed(): boolean {
48
+ return this.#key !== undefined;
49
+ }
50
+ // The form a folder holds, checked against the form this store speaks.
51
+ #form(name: string): 'plain' | 'sealed' | undefined {
52
+ const wd = this.#ward(name);
53
+ const form = existsSync(join(wd, 'ward.sealed')) ? 'sealed' : existsSync(join(wd, 'seed')) ? 'plain' : undefined;
54
+ if (form === 'sealed' && !this.sealed) throw new Error(`ward ${name} in ${this.dir} is sealed and this harbor has no key`);
55
+ if (form === 'plain' && this.sealed) throw new Error(`ward ${name} in ${this.dir} is plain and this harbor holds a key`);
56
+ return form;
57
+ }
58
+ // One write at a time per ward, through a temp file and a rename.
59
+ #write(name: string, file: string, body: string): Promise<void> {
60
+ const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
61
+ const tmp = join(this.#ward(name), `${file}.tmp`);
62
+ await writeFile(tmp, body, { mode: 0o600 });
63
+ await rename(tmp, join(this.#ward(name), file));
64
+ });
65
+ this.#queues.set(name, next.catch(() => {}));
66
+ return next;
67
+ }
68
+ async #read(name: string): Promise<Blob> {
69
+ const blob = (await readFile(join(this.#ward(name), 'ward.sealed'), 'utf8')).trim();
70
+ return JSON.parse(new TextDecoder().decode(await open(await this.#key!, blob))) as Blob;
71
+ }
72
+ // A sealed ward is rewritten whole, so the read sits inside the queue
73
+ // with the write: two changes to one ward never lose each other's part.
74
+ #keep(name: string, change: (b: Blob | undefined) => Blob): Promise<void> {
75
+ const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
76
+ const wd = this.#ward(name);
77
+ const blob = change(existsSync(join(wd, 'ward.sealed')) ? await this.#read(name) : undefined);
78
+ const sealed = await seal(await this.#key!, new TextEncoder().encode(JSON.stringify(blob)));
79
+ await writeFile(join(wd, 'ward.sealed.tmp'), sealed + '\n', { mode: 0o600 });
80
+ await rename(join(wd, 'ward.sealed.tmp'), join(wd, 'ward.sealed'));
81
+ });
82
+ this.#queues.set(name, next.catch(() => {}));
83
+ return next;
84
+ }
28
85
 
29
86
  async list(): Promise<string[]> {
30
87
  const wards = join(this.dir, 'wards');
31
- return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed'))) : [];
88
+ return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed')) || existsSync(join(wards, n, 'ward.sealed'))) : [];
32
89
  }
33
90
 
34
91
  async load(name: string): Promise<Kept | undefined> {
92
+ const form = this.#form(name);
93
+ if (!form) return undefined;
94
+ if (form === 'sealed') {
95
+ const b = await this.#read(name);
96
+ return { seed: unhex(b.seed), partition: b.partition, record: b.record };
97
+ }
35
98
  const wd = this.#ward(name);
36
- if (!existsSync(join(wd, 'seed'))) return undefined;
37
99
  return {
38
100
  seed: unhex((await readFile(join(wd, 'seed'), 'utf8')).trim()),
39
101
  partition: JSON.parse(await readFile(join(wd, 'partition.json'), 'utf8')) as Record<string, unknown>,
@@ -42,30 +104,28 @@ export class Files implements Store {
42
104
  }
43
105
 
44
106
  async put(name: string, kept: Kept): Promise<void> {
107
+ if (this.#form(name)) throw new Error(`ward ${name} already exists in ${this.dir}`);
45
108
  const wd = this.#ward(name);
46
- if (existsSync(join(wd, 'seed'))) throw new Error(`ward ${name} already exists in ${this.dir}`);
47
109
  await mkdir(wd, { recursive: true, mode: 0o700 });
110
+ if (this.sealed) return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
48
111
  await writeFile(join(wd, 'seed'), hex(kept.seed), { mode: 0o600 });
49
112
  await chmod(join(wd, 'seed'), 0o600);
50
113
  await writeFile(join(wd, 'partition.json'), JSON.stringify(kept.partition) + '\n', { mode: 0o600 });
51
114
  await writeFile(join(wd, 'ward.json'), JSON.stringify(kept.record, null, 2) + '\n', { mode: 0o600 });
52
115
  }
53
116
 
54
- save(name: string, partition: Record<string, unknown>): Promise<void> {
55
- const wd = this.#ward(name);
56
- if (!existsSync(join(wd, 'seed'))) return Promise.resolve(); // a name not kept is nothing
57
- const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
58
- const tmp = join(wd, 'partition.json.tmp');
59
- await writeFile(tmp, JSON.stringify(partition) + '\n', { mode: 0o600 });
60
- await rename(tmp, join(wd, 'partition.json'));
61
- });
62
- this.#queues.set(name, next.catch(() => {}));
63
- return next;
117
+ async save(name: string, partition: Record<string, unknown>): Promise<void> {
118
+ const form = this.#form(name);
119
+ if (!form) return; // a name not kept is nothing
120
+ if (form === 'sealed') return this.#keep(name, (b) => ({ ...b!, partition }));
121
+ return this.#write(name, 'partition.json', JSON.stringify(partition) + '\n');
64
122
  }
65
123
 
66
124
  async record(name: string, record: WardRecord): Promise<void> {
67
- if (!existsSync(join(this.#ward(name), 'seed'))) return;
68
- await writeFile(join(this.#ward(name), 'ward.json'), JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
125
+ const form = this.#form(name);
126
+ if (!form) return;
127
+ if (form === 'sealed') return this.#keep(name, (b) => ({ ...b!, record }));
128
+ return this.#write(name, 'ward.json', JSON.stringify(record, null, 2) + '\n');
69
129
  }
70
130
 
71
131
  async take(name: string): Promise<Kept | undefined> {
@@ -38,8 +38,9 @@ needs:
38
38
 
39
39
  | a ward is | Mac, iPhone | droplet | edge | browser tab |
40
40
  | ----------- | --------------------- | -------------------------- | ----------------------- | ---------------------- |
41
- | seed | Keychain | `.env` or a secrets file | the platform's secrets | IndexedDB, per origin |
42
- | partition | app data, SQLite | a file on disk | Durable Object storage | IndexedDB, per origin |
41
+ | seed | a file, sealed | `.env` or a secrets file | the platform's secrets | IndexedDB, per origin |
42
+ | partition | a file, sealed | a file on disk | Durable Object storage | IndexedDB, per origin |
43
+ | the key | Keychain | the environment, if any | the platform's secrets | none |
43
44
  | code | the app bundle | a folder | the deployed worker | a bundle URL |
44
45
 
45
46
  Envoys read their own secrets from the same secret store as the seed. A
@@ -60,14 +61,27 @@ it likes, so it may keep the secret branch apart from the data, by terrain:
60
61
  - **Split.** The bind branch goes to the secret store beside the seed, the
61
62
  rest to the data store. Fits a phone with a few relations; keychain items
62
63
  are small and a being with a thousand standings has a thousand key pairs.
63
- - **Wrapped.** The secret store holds one key per ward; the data store holds
64
- the whole partition encrypted under it. Any size, one secret. The droplet
65
- and edge answer, and it works on the phone too.
64
+ - **Wrapped.** The secret store holds one key per harbor; the data store
65
+ holds each ward whole, seed, partition and record as one blob encrypted
66
+ under it. Any size, one secret. The droplet and edge answer, and it
67
+ works on the phone too.
66
68
 
67
69
  The ward never knows. It hands values and gets values back. A heir given away
68
70
  as an invitation is a live credential until it speaks, and on a device other
69
71
  apps share that is the argument for the secret store.
70
72
 
73
+ Wrapped is what is built, and it is one piece, `seal.ts`: AES-GCM under a
74
+ 32-byte key, a fresh nonce each time, nonce and ciphertext together as hex,
75
+ on WebCrypto so it runs wherever a store does. The key is the one secret a
76
+ harbor reads from its terrain's secret store, named `QUO_SEED_KEY` on every
77
+ terrain: a platform secret on the edge, the environment of a daemon on a
78
+ device, handed to it from the Keychain by the app that spawned it. A
79
+ droplet gives no key and its folder stays plain under its file modes. A
80
+ store speaks one form, the one its key decides, and refuses a ward kept in
81
+ the other by name, so a daemon started without its key, or with one
82
+ against a plain folder, fails loudly and boots nothing. Sealing a plain
83
+ folder is a deliberate operator command, never a boot's doing.
84
+
71
85
  ### Custody is a lease
72
86
 
73
87
  `packages/quo/SPEC.md` makes custody the harbor's vouch: two harbors over one
@@ -314,8 +328,10 @@ root as `/quo/`, with the trailing slash, which the door takes.
314
328
  The harbor core is the library's, `packages/quo/src/harbor/core.ts`, and so is
315
329
  the dialer, `packages/quo/src/harbor/dial.ts`; `packages/quo/SPEC.md` says what
316
330
  they are. Every harbor here extends the core and hands it three things: a
317
- **store**, the library's interface, with `files.ts` on a disk and `idb.ts` in a
318
- tab, each passing the library's store suite; a **loader**, the code half, a
331
+ **store**, the library's interface, with `files.ts` on a disk, plain as three
332
+ files per ward or sealed as one blob when the daemon holds `QUO_SEED_KEY`,
333
+ and `idb.ts` in a tab, each passing the library's store suite, the files
334
+ store in both forms; a **loader**, the code half, a
319
335
  module from a folder on a daemon and the bundle in a tab; and a **lease**, a pid
320
336
  file on disk and a web lock on the database name in a tab, so a second tab on
321
337
  one origin meets the lock and is a screen. The disk harbor, `disk.ts`, is the
@@ -352,13 +368,13 @@ second harbor over one database does not boot.
352
368
  is the harbor. The platform runs one instance of it at a time, which is
353
369
  the lease and the single writer every ward needs. Its storage is the
354
370
  store, `storage.ts`: one row per ward, seed, partition and record, with the
355
- seed sealed under a key from the platform's secrets, `QUO_SEED_KEY`, so the
356
- storage holds ciphertext and the secret store holds the one key, as the
357
- table above says. The deployed worker is the code, the built-in beings and
358
- whatever it hands in. It is a listener and never a dialer: reached by
359
- request at `<origin>/h/<name>/quo`, holding the sockets dialers open to it
360
- on the platform's own socket pair, the rendezvous for them, awake per
361
- request and kept awake by a held socket.
371
+ seed sealed by `seal.ts` under a key from the platform's secrets,
372
+ `QUO_SEED_KEY`, so the storage holds ciphertext and the secret store holds
373
+ the one key, as the table above says. The deployed worker is the code, the
374
+ built-in beings and whatever it hands in. It is a listener and never a
375
+ dialer: reached by request at `<origin>/h/<name>/quo`, holding the sockets
376
+ dialers open to it on the platform's own socket pair, the rendezvous for
377
+ them, awake per request and kept awake by a held socket.
362
378
 
363
379
  Its owner door is a route, not a socket, because the platform has no local
364
380
  process: the root is whoever holds `QUO_ROOT`, a platform secret, and the
@@ -387,6 +403,30 @@ hold on every terrain; the listener half is the terrain's own, `ws` on
387
403
  Node and the socket pair on the edge, and stays where the terrain is. What
388
404
  the library takes, when it takes the reach, is the framing and the dialer.
389
405
 
406
+ ## The phone, as built
407
+
408
+ `capacitor.ts` is the store on a phone, the wrapped form over Capacitor's
409
+ Filesystem plugin and a secure-storage plugin: one key per harbor in the
410
+ Keychain, marked this device only so it never travels in a backup, and one
411
+ sealed file per ward, seed, partition and record as one blob, in the
412
+ folder iCloud does not copy. The seal is `seal.ts`, the same piece the
413
+ daemon and the edge use. Opening the store is where custody is decided,
414
+ since a phone's backup is the one copy nobody makes on purpose: a key with
415
+ no folder is a reinstall, and the stale key is deleted; a folder with no
416
+ key is a restore to another device, and the unreadable files are deleted;
417
+ either way the harbor starts fresh and there is never a twin. The app that
418
+ holds the store is `packages/app/`, which the dock does not know.
419
+
420
+ The proof is `packages/dock/test/terrain/ios.test.ts`, behind
421
+ `npm run check:terrain`: the store suite, untouched, and the custody rule,
422
+ run inside the real app in the iOS Simulator against a real Keychain and a
423
+ real folder. The app is synced with the test's origin as its page, built
424
+ with xcodebuild, installed fresh and launched with simctl; the page loads
425
+ the bundled exercise, `native.ts`, runs it and posts the list back. Two
426
+ things the plugins taught, held in the store: the secure store keeps JSON,
427
+ so a value is read with the call that parses; and mkdir refuses a folder
428
+ that exists, recursive or not.
429
+
390
430
  ## The link
391
431
 
392
432
  An invitation carries the ward pk of the world that minted it, and not where
package/harbor/seal.ts ADDED
@@ -0,0 +1,26 @@
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
+
10
+ const { hex, unhex } = arithmetic;
11
+ // Bytes as the platform's crypto wants them: over a plain ArrayBuffer.
12
+ const plain = (b: Uint8Array): Uint8Array<ArrayBuffer> => new Uint8Array(b);
13
+
14
+ export async function sealKey(secret: string): Promise<CryptoKey> {
15
+ if (!/^[0-9a-f]{64}$/.test(secret)) throw new Error('a seal key is 32 bytes as hex');
16
+ return crypto.subtle.importKey('raw', plain(unhex(secret)), 'AES-GCM', false, ['encrypt', 'decrypt']);
17
+ }
18
+ export async function seal(key: CryptoKey, bytes: Uint8Array): Promise<string> {
19
+ const iv = crypto.getRandomValues(new Uint8Array(12));
20
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain(bytes)));
21
+ return hex(iv) + hex(ct);
22
+ }
23
+ export async function open(key: CryptoKey, sealed: string): Promise<Uint8Array> {
24
+ const b = plain(unhex(sealed));
25
+ return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, key, b.subarray(12)));
26
+ }
package/human/html.ts CHANGED
@@ -230,5 +230,7 @@ export function page(m: Model): string {
230
230
  const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown as Json)}</aside>` : '';
231
231
  const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
232
232
  const mine = look(m.look);
233
- 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}`;
233
+ // a page carries its own title, so the header keeps only the notice; a being painted as forms is headed by her name
234
+ const head = m.tree ? '' : `${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1>`;
235
+ return `<header${mine.style}>${head}<p class="notice">${escape(m.notice)}</p></header>${notes}<main${mine.style}>${asks}</main>${pushes}`;
234
236
  }
package/human/local.ts ADDED
@@ -0,0 +1,26 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A being in the tab's own ward, as a subject the screen renders: a world's
3
+ // code running on the device, booted from the bundle that world serves on
4
+ // its origin. The side holds the object, as every side does, and speaks to
5
+ // her door in-process under the tab's own asker: no standing, no relation,
6
+ // since the local ward is the human's and she is on it. Her cells are in
7
+ // the tab's store and migrate with the ward; her asks are the forms; her
8
+ // page is computed from her cells at every digest, the same painter and the
9
+ // same side as a far being's, so one grammar has two sources and no second
10
+ // verb. A push never reaches her: nothing outside the tab holds her.
11
+ import type { Answer, Blueprint, JsonObject } from '@quo-systems/quo';
12
+ import type { Subject } from '../beings/side.ts';
13
+
14
+ // The asker the tab speaks under to its own beings.
15
+ export const TAB = 'tab';
16
+
17
+ type Door = { answer(asker: { id: string }, method?: string, args?: JsonObject): Promise<unknown> };
18
+
19
+ export function local(being: Door): Subject {
20
+ const me = { id: TAB };
21
+ return {
22
+ tools: async () => (await being.answer(me)) as Blueprint,
23
+ call: (name: string, args: JsonObject = {}): Promise<Answer> => being.answer(me, name, args) as Promise<Answer>,
24
+ ears: new Set(),
25
+ };
26
+ }
@@ -58,11 +58,51 @@ running there, from her cells, and painted by the same painter.
58
58
  - **The page is presentation.** `page` is asked once per digest, like
59
59
  `look`; it is never a form, never a tool, and a carrier never carries it.
60
60
  The user being answers one: her name as the title, who this device is as
61
- an answer, her standings as sections, and the one thing a human does here
62
- by hand, waking another device, as a form.
61
+ an answer, her standings as sections, and the two things a human does
62
+ here by hand, waking another device and calling her by a name, as forms.
63
+ A being does not know her own key, so her name is given: by the desk's
64
+ first hello at the root's setup, or by a device the root minted, through
65
+ her `name` ask, the trunk's rule.
63
66
  - **The door is the first page in the grammar**, painted with nobody behind
64
67
  it: no ask, no answer, no standing, only the mark, a word and a sentence.
65
68
 
69
+ ## The tab ward
70
+
71
+ A world's code runs in the tab, as beings. The second source of the one
72
+ grammar: a being booted in the tab's local ward for a world answers `page`
73
+ from her cells, and the same side paints her with the same painter. No
74
+ second verb, no framework, no script of the world's on the page: only
75
+ classes, and the ward runs them.
76
+
77
+ - **The world's code is a module the harbor folder holds**, `tab/index.ts`,
78
+ the twin of `classes/index.ts`: the classes meant for the tab, exported
79
+ by name. The web route bundles it as `beings.js` on the world's origin
80
+ and tells the page it exists; a folder with none has no such path. Code
81
+ crosses the origin, never a relation, and one origin is one world's code.
82
+ - **The boot at join.** The tab loads the world's classes before its harbor
83
+ boots, since a being kept from the last visit is booted by her class
84
+ name. When a relation goes on screen, the tab boots one being of each
85
+ class under that relation's key, `r1-Notes`, the first time, and finds
86
+ her there after: her cells are in the ward, kept in the tab's store after
87
+ every ask and migrating with the ward. Two relations in one world are two
88
+ of each, since what she keeps is one person's.
89
+ - **A subject with no standing.** The side holds her object and speaks to
90
+ her door in-process under the tab's own asker, `local.ts`, the way it
91
+ holds an avatar: no relation, no key rotation, since the local ward is
92
+ the human's and she is on it. Her describe is what she shows the tab, her
93
+ forms are her asks, and a push never reaches her, since nothing outside
94
+ the tab holds her.
95
+ - **Her page follows every ask.** `page` is asked again after each call
96
+ and the answers it shows are run again with it, so a cell moved is a page
97
+ moved whether or not her describe moved. The describe, the look and the
98
+ read-only asks off the page stay once per digest. A far being pays the
99
+ same: her page is never more than one ask behind her.
100
+ - **One section each.** Under the far being's page, each being of the world
101
+ has her own section and her own side; the shell's own heading is dropped
102
+ wherever a page carries its title. Slots that bind her page to a
103
+ standing she holds, and the shadow root that isolates a far page, are
104
+ the next part of the same design.
105
+
66
106
  ## How a screen is made
67
107
 
68
108
  Read with the trunk's "Carrying" and "The look", which this applies.
@@ -86,8 +126,9 @@ Read with the trunk's "Carrying" and "The look", which this applies.
86
126
  and acme sees the human, never the device. The same section appears on
87
127
  acme's own web, under the same look: one component, two relations.
88
128
  - **The page follows the digest.** The side asks the describe again after
89
- every call and asks `look` and `page` once per digest; a digest that moved
90
- is a page that moved. A read-only ask with nothing to type is run on open,
129
+ every call, asks `look` once per digest and `page` after every call; a
130
+ digest that moved is a page that moved, and so is a cell that moved
131
+ behind a page. A read-only ask with nothing to type is run on open,
91
132
  so a page can open with what it shows and not only with buttons. Every one
92
133
  of those asks rotates her keys, so the side saves after each, not only
93
134
  after a form: a relation brought back from a reload with a stale count is
@@ -143,7 +184,10 @@ Read with the trunk's "Carrying" and "The look", which this applies.
143
184
 
144
185
  ## The pieces
145
186
 
146
- Nine files under `packages/dock/human/`:
187
+ Ten files under `packages/dock/human/`:
188
+
189
+ - `local.ts` is a being of the world in the tab's own ward as a subject:
190
+ her door spoken to in-process under the tab's asker, no standing.
147
191
 
148
192
  - `tree.ts` is the page grammar and its painter, pure: `sanitiseTree`
149
193
  holds a value to the grammar or drops it, `answersIn` names what a tree
@@ -157,9 +201,10 @@ Nine files under `packages/dock/human/`:
157
201
  - `screen.ts` is the side: one avatar and one surface, where a surface can
158
202
  only show a page and hand back a submitted form. It keeps the model,
159
203
  speaks it as a page after every change, calls her when a form comes back,
160
- re-asks her describe after every call, asks her look and her page once
161
- per digest, and runs on open her read-only asks and the ones her page
162
- shows the answers of. A push is appended and shown as it lands. A
204
+ re-asks her describe after every call, asks her look once per digest and
205
+ her page after every call, and runs her read-only asks once per digest
206
+ and the ones her page shows the answers of after every call. A push is
207
+ appended and shown as it lands. A
163
208
  describe that fails leaves the last page standing and says so in the
164
209
  notice.
165
210
  - `dom.ts` is the surface on an element, the one file that touches one.
@@ -182,8 +227,9 @@ Nine files under `packages/dock/human/`:
182
227
 
183
228
  Proven in `packages/dock/test/human.test.ts` on the memory harbor with a fake
184
229
  surface and no browser: the grammar held and painted, the user being's page
185
- with what her gate hides painted as nothing, the user being carrying a shop
186
- with a look, a guest
230
+ with what her gate hides painted as nothing, a being of the world in the
231
+ tab's ward paged from her cells and kept with them, the user being
232
+ carrying a shop with a look, a guest
187
233
  at a door that is not a desk let in by a form, the link read, stripped and
188
234
  refused, and the worlds and relations on a harbor core over the memory
189
235
  store, two invitations into one world being two avatars; and in
package/human/screen.ts CHANGED
@@ -49,7 +49,8 @@ export async function screenSide(avatar: Subject, surface: Surface, options: Opt
49
49
  model.notice = `not in: ${bp.error}`;
50
50
  } else {
51
51
  const d = await digest(bp as Blueprint);
52
- if (d !== seen) {
52
+ const moved = d !== seen;
53
+ if (moved) {
53
54
  seen = d;
54
55
  model.blueprint = bp as Blueprint;
55
56
  for (const k of Object.keys(model.answers)) if (!(bp as Blueprint).asks.some((a) => a.name === k)) delete model.answers[k];
@@ -58,18 +59,23 @@ export async function screenSide(avatar: Subject, surface: Surface, options: Opt
58
59
  const l = await avatar.call('look');
59
60
  model.look = isSilence(l) || isWord(l) ? {} : sanitise(l as Json);
60
61
  } else model.look = {};
61
- // her page, once per digest: a tree of values, or nothing and her asks are forms
62
- if ((bp as Blueprint).asks.some((a) => a.name === 'page')) {
63
- const t = await avatar.call('page');
64
- model.tree = isSilence(t) || isWord(t) ? null : sanitiseTree(t as Json);
65
- } else model.tree = null;
66
- // a read-only ask that needs nothing typed, or one her page shows the answer of, is run on
67
- // her behalf, so the page opens with what it shows
68
- const wanted = new Set(model.tree ? answersIn(model.tree) : []);
69
- for (const a of (bp as Blueprint).asks) {
70
- const needs = ((a.input as { required?: string[] }).required ?? []).length > 0;
71
- if ((hintFor(bp as Blueprint, model.look, a.name).readOnly || wanted.has(a.name)) && !needs && !(a.name in model.answers)) model.answers[a.name] = word(await avatar.call(a.name, {}));
72
- }
62
+ }
63
+ // her page, after every ask: a tree of values from what she holds now, or nothing and her asks
64
+ // are forms. A cell moved is a page moved, and her describe need not have; so the answers the
65
+ // page shows are run again with it, and the page never stands more than one ask behind her
66
+ const shows = new Set(model.tree ? answersIn(model.tree) : []);
67
+ if ((bp as Blueprint).asks.some((a) => a.name === 'page')) {
68
+ const t = await avatar.call('page');
69
+ model.tree = isSilence(t) || isWord(t) ? null : sanitiseTree(t as Json);
70
+ } else model.tree = null;
71
+ const wanted = new Set(model.tree ? answersIn(model.tree) : []);
72
+ for (const n of shows) if (wanted.has(n)) delete model.answers[n];
73
+ // a read-only ask that needs nothing typed is run on her behalf once per digest, and one her page
74
+ // shows the answer of after every ask, so the page opens and stays with what it shows
75
+ for (const a of (bp as Blueprint).asks) {
76
+ const needs = ((a.input as { required?: string[] }).required ?? []).length > 0;
77
+ const run = wanted.has(a.name) || (moved && hintFor(bp as Blueprint, model.look, a.name).readOnly);
78
+ if (run && !needs && !(a.name in model.answers)) model.answers[a.name] = word(await avatar.call(a.name, {}));
73
79
  }
74
80
  }
75
81
  await after();
package/human/tab.ts CHANGED
@@ -33,11 +33,14 @@ import { domSurface } from './dom.ts';
33
33
  import { guest } from './guest.ts';
34
34
  import { door } from './door.ts';
35
35
  import { world, relations, fresh, type Relation } from './worlds.ts';
36
+ import { local } from './local.ts';
37
+ import type { BeingClass } from '@quo-systems/quo';
36
38
 
37
39
  // What the page is told by the daemon that served it: the world's routes,
38
40
  // every ward on that harbor by name, its pk and whether a public being is
39
- // at its door, and which of them this page is, if it is one's.
40
- export type Config = { quo: string; web: string; wards: Record<string, { pk: string; public: boolean }>; ward?: string };
41
+ // at its door, which of them this page is, if it is one's, and whether the
42
+ // world serves code for the tab.
43
+ export type Config = { quo: string; web: string; wards: Record<string, { pk: string; public: boolean }>; ward?: string; beings?: boolean };
41
44
 
42
45
  // What the tab remembers between pages, beside the harbor: the worlds it
43
46
  // has joined, by pk, where each lives and what it is called; which relation
@@ -85,7 +88,20 @@ export async function start(cfg: Config, root: HTMLElement = document.body): Pro
85
88
  const say = (s: string) => (status.textContent = s);
86
89
 
87
90
  // The harbor in the tab: one database per origin, one ward per world.
91
+ // The world's code first, when its origin serves any: the classes for
92
+ // the tab, handed to the harbor beside the built-in ones before any ward
93
+ // boots, since a being kept from the last visit is booted by class name.
94
+ // A world that serves none has no beings in the tab, and the page is the
95
+ // far being's alone.
88
96
  const harbor = new BrowserHarbor('quo');
97
+ const classes = cfg.beings
98
+ ? await import(`${cfg.web}/beings.js`).then((mod: Record<string, unknown>) => {
99
+ const out: Record<string, BeingClass> = {};
100
+ for (const [name, v] of Object.entries(mod)) if (typeof v === 'function' && 'prototype' in v) out[name] = v as BeingClass;
101
+ return out;
102
+ })
103
+ : {};
104
+ Object.assign(harbor.classes, classes);
89
105
  await harbor.boot();
90
106
  harbor.dial(cfg.quo);
91
107
 
@@ -138,16 +154,43 @@ export async function start(cfg: Config, root: HTMLElement = document.body): Pro
138
154
  }
139
155
  };
140
156
 
141
- // In, as one relation: her page. Every call rotates her keys and a
142
- // same-ward ask never crosses the harbor, so the side saves after each.
157
+ // The world's beings in the tab, for one relation: one of each class,
158
+ // booted under her key the first time and found there after, since their
159
+ // cells are in the ward and come back with it. Each is a subject of her
160
+ // own with her own side in her own section, so a note she keeps and a
161
+ // form she shows are hers, painted by the same painter as the far page.
162
+ const locals: { close(): Promise<void> }[] = [];
163
+ const boot = async (rel: Relation) => {
164
+ const beings = (ward.partition as { beings?: Record<string, unknown> }).beings ?? {};
165
+ for (const name of Object.keys(classes)) {
166
+ const key = `${rel.key}-${name}`;
167
+ if (!beings[key]) {
168
+ const out = (await ward.ask('boot', { key, class: name })) as { error?: string };
169
+ if (out.error) continue;
170
+ await ward.save();
171
+ }
172
+ const being = ward.being(key) as Parameters<typeof local>[0] | undefined;
173
+ if (!being) continue;
174
+ const section = el('section', '', { class: 'local', 'data-being': key });
175
+ screen.append(section);
176
+ locals.push(await screenSide(local(being), domSurface(section), { after: () => ward.save() }));
177
+ }
178
+ };
179
+
180
+ // In, as one relation: her page, then the world's beings for her. Every
181
+ // call rotates her keys and a same-ward ask never crosses the harbor, so
182
+ // the side saves after each.
143
183
  const inside = async (rel: Relation, notice: string, called: string) => {
144
184
  await side?.close();
185
+ for (const l of locals.splice(0)) await l.close();
145
186
  status.remove();
146
187
  root.querySelector('form.password')?.remove();
147
188
  screen.replaceChildren();
148
189
  current = rel;
149
190
  keep(AT(pk), rel.key);
150
- const s = await screenSide(rel.avatar, domSurface(screen), { after: () => ward.save(), notice });
191
+ const mine = el('div', '', { class: 'far' });
192
+ screen.append(mine);
193
+ const s = await screenSide(rel.avatar, domSurface(mine), { after: () => ward.save(), notice });
151
194
  side = s;
152
195
  const bp = s.model.blueprint;
153
196
  const notesName = typeof (bp?.notes as JsonObject | null)?.name === 'string' ? ((bp!.notes as JsonObject).name as string) : '';
@@ -155,6 +198,7 @@ export async function start(cfg: Config, root: HTMLElement = document.body): Pro
155
198
  if (!names()[rel.key]) keep(NAMES(pk), { ...names(), [rel.key]: called });
156
199
  switcher();
157
200
  people();
201
+ await boot(rel);
158
202
  };
159
203
 
160
204
  // The way in, from any of the three: a fresh avatar joins, the ward is
@@ -177,6 +221,7 @@ export async function start(cfg: Config, root: HTMLElement = document.body): Pro
177
221
  // there, the door page: a link is the only way in, and nothing to type.
178
222
  const atDoor = async () => {
179
223
  await side?.close();
224
+ for (const l of locals.splice(0)) await l.close();
180
225
  side = null;
181
226
  current = null;
182
227
  screen.replaceChildren();