@quo-systems/dock 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/beings/avatar.ts +8 -1
- package/beings/carry.ts +1 -1
- package/beings/quo-dock.md +53 -39
- package/beings/setup.ts +2 -1
- package/beings/user.ts +48 -1
- package/cli/daemon.ts +51 -25
- package/cli/estate/quo.service +0 -1
- package/dist/beings/avatar.js +9 -2
- package/dist/beings/carry.js +2 -2
- package/dist/beings/setup.js +2 -1
- package/dist/beings/user.d.ts +89 -0
- package/dist/beings/user.js +47 -1
- package/dist/cli/daemon.d.ts +10 -2
- package/dist/cli/daemon.js +51 -23
- package/dist/cli/estate/quo.service +0 -1
- package/dist/harbor/capacitor.d.ts +16 -0
- package/dist/harbor/capacitor.js +135 -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/door.d.ts +2 -0
- package/dist/human/door.js +19 -7
- package/dist/human/html.d.ts +2 -0
- package/dist/human/html.js +43 -17
- package/dist/human/local.d.ts +10 -0
- package/dist/human/local.js +10 -0
- package/dist/human/screen.js +26 -8
- package/dist/human/tab.d.ts +1 -0
- package/dist/human/tab.js +59 -3
- package/dist/human/tree.d.ts +40 -0
- package/dist/human/tree.js +89 -0
- package/dist/human/web.d.ts +1 -0
- package/dist/human/web.js +36 -8
- package/dist/mcp/oauth.js +2 -2
- package/dist/mcp/route.js +1 -1
- package/dist/mcp/runner.js +5 -2
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/web/exchange.d.ts +5 -9
- package/dist/mcp/web/exchange.js +47 -98
- package/harbor/capacitor.ts +142 -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 +58 -15
- package/harbor/seal.ts +26 -0
- package/human/door.ts +20 -7
- package/human/html.ts +41 -17
- package/human/local.ts +26 -0
- package/human/quo-human.md +114 -19
- package/human/screen.ts +21 -7
- package/human/tab.ts +50 -5
- package/human/tree.ts +129 -0
- package/human/web.ts +36 -9
- package/mcp/oauth.ts +2 -2
- package/mcp/quo-mcp.md +27 -20
- package/mcp/route.ts +1 -1
- package/mcp/runner.ts +9 -6
- package/mcp/server.ts +1 -1
- package/mcp/web/exchange.ts +62 -96
- package/package.json +13 -2
package/harbor/edge/edge.ts
CHANGED
|
@@ -24,7 +24,8 @@ import { isSilence } from '@quo-systems/quo';
|
|
|
24
24
|
import { User, Desk, Avatar } from '../../beings/index.ts';
|
|
25
25
|
import { setup } from '../../beings/setup.ts';
|
|
26
26
|
import { Harbor, Socket as Held, SUITE, type Line } from '@quo-systems/quo/harbor';
|
|
27
|
-
import { DurableStorage
|
|
27
|
+
import { DurableStorage } from './storage.ts';
|
|
28
|
+
import { sealKey } from '../seal.ts';
|
|
28
29
|
import type { State, Env } from './platform.d.ts';
|
|
29
30
|
|
|
30
31
|
export const BUILT_IN: Record<string, BeingClass> = { User, Desk, Avatar };
|
package/harbor/edge/exercise.ts
CHANGED
|
@@ -13,7 +13,8 @@ import type { World, Handle, Census } from '@quo-systems/quo/conformance';
|
|
|
13
13
|
import { Printer, Shop, Customer, Echo, Member } from '@quo-systems/quo/conformance';
|
|
14
14
|
import type { BeingClass, Cells, Invitation, JsonObject } from '@quo-systems/quo';
|
|
15
15
|
import { EdgeHarbor } from './edge.ts';
|
|
16
|
-
import { DurableStorage
|
|
16
|
+
import { DurableStorage } from './storage.ts';
|
|
17
|
+
import { sealKey } from '../seal.ts';
|
|
17
18
|
import type { Hosted } from '@quo-systems/quo/harbor';
|
|
18
19
|
import type { State, Env } from './platform.d.ts';
|
|
19
20
|
|
package/harbor/edge/storage.ts
CHANGED
|
@@ -2,35 +2,16 @@
|
|
|
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, type Kept, type Store, type WardRecord } from '@quo-systems/quo/harbor';
|
|
11
|
+
import { seal, open } from '../seal.ts';
|
|
12
12
|
import type { Storage } from './platform.d.ts';
|
|
13
13
|
|
|
14
14
|
type Row = { seed: string; partition: Record<string, unknown>; record: WardRecord };
|
|
15
|
-
// Bytes as the platform's crypto wants them: over a plain ArrayBuffer.
|
|
16
|
-
const plain = (b: Uint8Array): Uint8Array<ArrayBuffer> => new Uint8Array(b);
|
|
17
|
-
const { hex, unhex } = arithmetic;
|
|
18
|
-
|
|
19
|
-
// The seal on a seed: AES-GCM under the platform key, a fresh nonce each
|
|
20
|
-
// time, nonce and ciphertext together as hex.
|
|
21
|
-
export async function sealKey(secret: string): Promise<CryptoKey> {
|
|
22
|
-
if (!/^[0-9a-f]{64}$/.test(secret)) throw new Error('QUO_SEED_KEY is 32 bytes as hex');
|
|
23
|
-
return crypto.subtle.importKey('raw', plain(unhex(secret)), 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
24
|
-
}
|
|
25
|
-
export async function seal(key: CryptoKey, seed: Uint8Array): Promise<string> {
|
|
26
|
-
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
27
|
-
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain(seed)));
|
|
28
|
-
return hex(iv) + hex(ct);
|
|
29
|
-
}
|
|
30
|
-
export async function open(key: CryptoKey, sealed: string): Promise<Uint8Array> {
|
|
31
|
-
const b = plain(unhex(sealed));
|
|
32
|
-
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, key, b.subarray(12)));
|
|
33
|
-
}
|
|
34
15
|
|
|
35
16
|
// `prefix` keeps more than one harbor apart in one object's storage: the
|
|
36
17
|
// exercise does that, a deployment never does.
|
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
|
-
//
|
|
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';
|
|
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
|
|
56
|
-
if (!
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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> {
|
package/harbor/quo-harbor.md
CHANGED
|
@@ -38,8 +38,9 @@ needs:
|
|
|
38
38
|
|
|
39
39
|
| a ward is | Mac, iPhone | droplet | edge | browser tab |
|
|
40
40
|
| ----------- | --------------------- | -------------------------- | ----------------------- | ---------------------- |
|
|
41
|
-
| seed |
|
|
42
|
-
| partition |
|
|
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
|
|
64
|
-
|
|
65
|
-
and edge answer, and it
|
|
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
|
|
@@ -168,7 +182,10 @@ Two kinds of harbor exist in the world, and every pair reduces to them:
|
|
|
168
182
|
| browser tab | no | yes | while open | itself |
|
|
169
183
|
|
|
170
184
|
A ward on a dialer is reached only if its harbor dialed first and something
|
|
171
|
-
holds that line open. A phone's ask must survive a push wake-up
|
|
185
|
+
holds that line open. A phone's ask must survive a push wake-up, and a
|
|
186
|
+
device that comes back from sleep tells its dialer so, `wake`, since the
|
|
187
|
+
system closes a sleeping socket without a word; `packages/quo/SPEC.md` says
|
|
188
|
+
what the dialer does with it. A tab is a
|
|
172
189
|
device, and one seed never lives in two tabs.
|
|
173
190
|
|
|
174
191
|
## Transport by necessity
|
|
@@ -311,8 +328,10 @@ root as `/quo/`, with the trailing slash, which the door takes.
|
|
|
311
328
|
The harbor core is the library's, `packages/quo/src/harbor/core.ts`, and so is
|
|
312
329
|
the dialer, `packages/quo/src/harbor/dial.ts`; `packages/quo/SPEC.md` says what
|
|
313
330
|
they are. Every harbor here extends the core and hands it three things: a
|
|
314
|
-
**store**, the library's interface, with `files.ts` on a disk
|
|
315
|
-
|
|
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
|
|
316
335
|
module from a folder on a daemon and the bundle in a tab; and a **lease**, a pid
|
|
317
336
|
file on disk and a web lock on the database name in a tab, so a second tab on
|
|
318
337
|
one origin meets the lock and is a screen. The disk harbor, `disk.ts`, is the
|
|
@@ -349,13 +368,13 @@ second harbor over one database does not boot.
|
|
|
349
368
|
is the harbor. The platform runs one instance of it at a time, which is
|
|
350
369
|
the lease and the single writer every ward needs. Its storage is the
|
|
351
370
|
store, `storage.ts`: one row per ward, seed, partition and record, with the
|
|
352
|
-
seed sealed under a key from the platform's secrets,
|
|
353
|
-
storage holds ciphertext and the secret store holds
|
|
354
|
-
table above says. The deployed worker is the code, the
|
|
355
|
-
whatever it hands in. It is a listener and never a
|
|
356
|
-
request at `<origin>/h/<name>/quo`, holding the sockets
|
|
357
|
-
on the platform's own socket pair, the rendezvous for
|
|
358
|
-
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.
|
|
359
378
|
|
|
360
379
|
Its owner door is a route, not a socket, because the platform has no local
|
|
361
380
|
process: the root is whoever holds `QUO_ROOT`, a platform secret, and the
|
|
@@ -384,6 +403,30 @@ hold on every terrain; the listener half is the terrain's own, `ws` on
|
|
|
384
403
|
Node and the socket pair on the edge, and stays where the terrain is. What
|
|
385
404
|
the library takes, when it takes the reach, is the framing and the dialer.
|
|
386
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
|
+
|
|
387
430
|
## The link
|
|
388
431
|
|
|
389
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/door.ts
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
// Quo's page and not the estate's: it says what this is and that a world
|
|
6
6
|
// here is entered by a link someone sends you, and it asks for nothing,
|
|
7
7
|
// since there is nothing a stranger could type that would let them in. A
|
|
8
|
-
// world that wants a face of its own has a public being with a
|
|
9
|
-
// its own page on its own origin; this is the default and nothing
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
import {
|
|
8
|
+
// world that wants a face of its own has a public being with a page and a
|
|
9
|
+
// look, or its own page on its own origin; this is the default and nothing
|
|
10
|
+
// more. It is the first page written in the grammar, `tree.ts`, painted
|
|
11
|
+
// with nobody behind it: no ask, no answer, no standing.
|
|
12
|
+
import { paint, nobody, type Node } from './tree.ts';
|
|
13
13
|
|
|
14
14
|
// The mark: a ring with a gap, one being's voice reaching another's door.
|
|
15
15
|
const MARK =
|
|
@@ -20,7 +20,20 @@ const MARK =
|
|
|
20
20
|
|
|
21
21
|
export type Door = { world?: string; host: string };
|
|
22
22
|
|
|
23
|
+
export function doorTree(at: Door): Node {
|
|
24
|
+
return {
|
|
25
|
+
kind: 'stack',
|
|
26
|
+
of: [
|
|
27
|
+
{ kind: 'image', src: MARK, alt: '' },
|
|
28
|
+
{ kind: 'text', text: 'quo', role: 'title' },
|
|
29
|
+
{ kind: 'text', text: at.world ? `${at.world} at ${at.host}` : at.host, role: 'label' },
|
|
30
|
+
{ kind: 'text', text: at.world ? 'This world is entered by invitation.' : 'The worlds here are entered by invitation.', role: 'lead' },
|
|
31
|
+
{ kind: 'text', text: 'An invitation is a link someone sends you. Open it here, and you are in: no account, no password, nothing to type. What you can do inside is what the world shows you, and it is yours to keep on this device.' },
|
|
32
|
+
{ kind: 'text', text: 'Nothing on this page asks anything of you. If you were sent here without a link, ask the person who sent you for one.', role: 'quiet' },
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
23
37
|
export function door(at: Door): string {
|
|
24
|
-
|
|
25
|
-
return `<main class="door"><img class="mark" alt="" src="${MARK}"><h1>quo</h1>${where}<p class="lead">${at.world ? 'This world is entered by invitation.' : 'The worlds here are entered by invitation.'}</p><p>An invitation is a link someone sends you. Open it here, and you are in: no account, no password, nothing to type. What you can do inside is what the world shows you, and it is yours to keep on this device.</p><p class="quiet">Nothing on this page asks anything of you. If you were sent here without a link, ask the person who sent you for one.</p></main>`;
|
|
38
|
+
return `<main class="door">${paint(doorTree(at), nobody)}</main>`;
|
|
26
39
|
}
|
package/human/html.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type { Ask, Blueprint, Json, JsonObject } from '@quo-systems/quo';
|
|
13
13
|
import { SILENCE_TEXT, UNREACHED_TEXT, wordText, type Word } from '../beings/side.ts';
|
|
14
14
|
import { hint, hintFor, sanitise, groups as grouped, type Look, type Hint } from '../beings/look.ts';
|
|
15
|
+
import { paint, type Node, type Painter } from './tree.ts';
|
|
15
16
|
|
|
16
17
|
// One property of an input schema, as the form needs it.
|
|
17
18
|
export type Property = { type?: string; description?: string; enum?: Json[]; format?: string; default?: Json };
|
|
@@ -140,6 +141,7 @@ export function face(w: Word, schema?: JsonObject): string {
|
|
|
140
141
|
export type Model = {
|
|
141
142
|
blueprint: Blueprint | null; // her describe for this human, or nothing yet
|
|
142
143
|
look: Look; // her own look, from her `look` ask, or nothing
|
|
144
|
+
tree: Node | null; // her page, from her `page` ask, or nothing: then her asks are painted as forms
|
|
143
145
|
notice: string; // one line about where the human stands: in, not in, an error before an ask
|
|
144
146
|
answers: Record<string, Word>; // the last answer per ask, shown under its form
|
|
145
147
|
pushes: JsonObject[]; // every push from the world, newest last
|
|
@@ -179,6 +181,9 @@ export function ordered(asks: Ask[], l: Look | undefined): Ask[] {
|
|
|
179
181
|
|
|
180
182
|
export { hintFor };
|
|
181
183
|
|
|
184
|
+
// The asks that are presentation and never a form: her look and her page.
|
|
185
|
+
const PRESENTATION = new Set(['look', 'page']);
|
|
186
|
+
|
|
182
187
|
export function page(m: Model): string {
|
|
183
188
|
const bp = m.blueprint;
|
|
184
189
|
const one = (l: Look | undefined, prefix = '') => (a: Ask) => {
|
|
@@ -188,25 +193,44 @@ export function page(m: Model): string {
|
|
|
188
193
|
};
|
|
189
194
|
const groups = grouped(bp);
|
|
190
195
|
const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
?
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
196
|
+
const section = (id: string): string => {
|
|
197
|
+
const g = groups[id];
|
|
198
|
+
const asks = bp && g ? bp.asks.filter((a) => g.asks?.includes(a.name)) : [];
|
|
199
|
+
if (!asks.length) return '';
|
|
200
|
+
const kept = sanitise(g!.look);
|
|
201
|
+
const l = look(kept);
|
|
202
|
+
const prefix = `${id}-`;
|
|
203
|
+
const want = (kept.order ?? []).map((n) => prefix + n);
|
|
204
|
+
const inOrder = ordered(asks, { order: want });
|
|
205
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
|
|
206
|
+
};
|
|
207
|
+
const far = Object.keys(groups).map(section).join('');
|
|
208
|
+
// Her page, when she answered one: painted from her tree, each name it
|
|
209
|
+
// uses looked up in her describe, so a page shows nothing her gate hid.
|
|
210
|
+
const painter: Painter = {
|
|
211
|
+
form: (name) => {
|
|
212
|
+
const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !PRESENTATION.has(x.name));
|
|
213
|
+
return a ? one(m.look)(a) : '';
|
|
214
|
+
},
|
|
215
|
+
answer: (name, as) => {
|
|
216
|
+
const a = bp?.asks.find((x) => x.name === name);
|
|
217
|
+
const w = a ? m.answers[name] : undefined;
|
|
218
|
+
if (!a || !w) return '';
|
|
219
|
+
if (w.word === 'object' && as === 'text') return `<div class="answer">${view(w.value === null || typeof w.value !== 'object' ? w.value : JSON.stringify(w.value))}</div>`;
|
|
220
|
+
if (w.word === 'object' && as === 'cards' && Array.isArray(w.value)) return `<div class="cards">${w.value.map((v) => `<div class="card">${view(v)}</div>`).join('')}</div>`;
|
|
221
|
+
if (w.word === 'object' && as === 'list' && Array.isArray(w.value)) return `<ul class="answer">${w.value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>`;
|
|
222
|
+
return face(w, a.output);
|
|
223
|
+
},
|
|
224
|
+
standing: section,
|
|
225
|
+
standings: () => far,
|
|
226
|
+
};
|
|
227
|
+
const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !PRESENTATION.has(a.name)), m.look).map(one(m.look)).join('') : '';
|
|
228
|
+
const asks = m.tree ? `<div class="page">${paint(m.tree, painter)}</div>` : own + far;
|
|
207
229
|
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;
|
|
208
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>` : '';
|
|
209
231
|
const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
|
|
210
232
|
const mine = look(m.look);
|
|
211
|
-
|
|
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}`;
|
|
212
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
|
+
}
|