@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.
- package/beings/carry.ts +7 -3
- package/beings/desk.ts +5 -1
- package/beings/look.ts +12 -3
- package/beings/quo-dock.md +22 -13
- package/beings/user.ts +20 -6
- package/dist/beings/carry.js +10 -4
- package/dist/beings/desk.d.ts +0 -1
- package/dist/beings/desk.js +5 -1
- package/dist/beings/look.d.ts +2 -0
- package/dist/beings/look.js +9 -0
- package/dist/beings/user.d.ts +24 -0
- package/dist/beings/user.js +20 -6
- package/dist/harbor/browser.d.ts +4 -4
- package/dist/harbor/browser.js +18 -9
- package/dist/harbor/capacitor.d.ts +19 -0
- package/dist/harbor/capacitor.js +151 -0
- package/dist/harbor/disk.js +8 -3
- package/dist/harbor/edge/edge.js +2 -1
- package/dist/harbor/edge/exercise.js +2 -1
- package/dist/harbor/edge/storage.d.ts +0 -3
- package/dist/harbor/edge/storage.js +6 -25
- package/dist/harbor/files.d.ts +2 -1
- package/dist/harbor/files.js +87 -22
- package/dist/harbor/seal.d.ts +3 -0
- package/dist/harbor/seal.js +25 -0
- package/dist/human/html.d.ts +1 -0
- package/dist/human/html.js +40 -19
- package/dist/human/local.d.ts +10 -0
- package/dist/human/local.js +10 -0
- package/dist/human/screen.js +32 -18
- package/dist/human/tab.d.ts +1 -0
- package/dist/human/tab.js +59 -3
- package/dist/human/web.d.ts +1 -0
- package/dist/human/web.js +34 -4
- package/dist/mcp/runner.js +5 -3
- package/dist/mcp/server.js +5 -3
- package/harbor/browser.ts +28 -17
- package/harbor/capacitor.ts +159 -0
- package/harbor/disk.ts +8 -3
- package/harbor/edge/edge.ts +2 -1
- package/harbor/edge/exercise.ts +2 -1
- package/harbor/edge/storage.ts +6 -25
- package/harbor/files.ts +79 -19
- package/harbor/quo-harbor.md +66 -14
- package/harbor/seal.ts +26 -0
- package/human/html.ts +39 -18
- package/human/local.ts +26 -0
- package/human/quo-human.md +70 -13
- package/human/screen.ts +27 -15
- package/human/tab.ts +50 -5
- package/human/web.ts +34 -5
- package/mcp/runner.ts +5 -3
- package/mcp/server.ts +5 -3
- package/package.json +14 -2
package/dist/harbor/disk.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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);
|
package/dist/harbor/edge/edge.js
CHANGED
|
@@ -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
|
|
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
|
|
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`,
|
|
6
|
-
//
|
|
7
|
-
// harbor document's table says the edge
|
|
8
|
-
// they do into a file, because the ward
|
|
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
|
-
|
|
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 {
|
package/dist/harbor/files.d.ts
CHANGED
|
@@ -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>;
|
package/dist/harbor/files.js
CHANGED
|
@@ -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
|
-
//
|
|
10
|
-
//
|
|
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
|
|
31
|
-
if (!
|
|
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
|
-
|
|
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
|
|
51
|
-
if (!
|
|
52
|
-
return
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
124
|
+
const form = this.#form(name);
|
|
125
|
+
if (!form)
|
|
63
126
|
return;
|
|
64
|
-
|
|
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,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
|
+
}
|
package/dist/human/html.d.ts
CHANGED
package/dist/human/html.js
CHANGED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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) && !
|
|
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) && !
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/human/screen.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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();
|
package/dist/human/tab.d.ts
CHANGED
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
|
-
//
|
|
104
|
-
//
|
|
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
|
|
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();
|