@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/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
|
|
@@ -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
|
|
318
|
-
|
|
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,
|
|
356
|
-
storage holds ciphertext and the secret store holds
|
|
357
|
-
table above says. The deployed worker is the code, the
|
|
358
|
-
whatever it hands in. It is a listener and never a
|
|
359
|
-
request at `<origin>/h/<name>/quo`, holding the sockets
|
|
360
|
-
on the platform's own socket pair, the rendezvous for
|
|
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,42 @@ 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 harbor on a phone is the browser harbor, `browser.ts`, over that
|
|
421
|
+
store: the browser harbor takes a name, which is an IndexedDB database
|
|
422
|
+
under a web lock, or a store handed in, whose lease is the process that
|
|
423
|
+
holds it, since an app has one harbor and nothing else can open its
|
|
424
|
+
files. `nativeHarbor` in `capacitor.ts` is that harbor booted and armed:
|
|
425
|
+
the App plugin's foreground event tells it `wake`, which tells every
|
|
426
|
+
dialer, because a phone asleep loses its sockets silently and the wake is
|
|
427
|
+
what dials them back.
|
|
428
|
+
|
|
429
|
+
The proof is `packages/dock/test/terrain/ios.test.ts`, behind
|
|
430
|
+
`npm run check:terrain`, inside the real app in the iOS Simulator: the
|
|
431
|
+
store suite, untouched, and the custody rule against a real Keychain and
|
|
432
|
+
a real folder; the whole conformance suite over two harbors on native
|
|
433
|
+
stores, both dialing a daemon on the Mac's loopback through the tab's own
|
|
434
|
+
probe; and the wake, the app sent behind another and brought back, its
|
|
435
|
+
dialer told and its socket held again. The app is synced with the test's
|
|
436
|
+
origin as its page, built with xcodebuild, installed fresh and launched
|
|
437
|
+
with simctl; the page loads the bundled exercise, `native.ts`, runs it and
|
|
438
|
+
posts the list back. Two things the plugins taught, held in the store:
|
|
439
|
+
the secure store keeps JSON, so a value is read with the call that
|
|
440
|
+
parses; and mkdir refuses a folder that exists, recursive or not.
|
|
441
|
+
|
|
390
442
|
## The link
|
|
391
443
|
|
|
392
444
|
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
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// page(model) the whole page: title, notice, forms, views, pushes
|
|
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
|
-
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';
|
|
14
|
+
import { hint, hintFor, sanitise, groups as grouped, presentation, type Look, type Hint } from '../beings/look.ts';
|
|
15
|
+
import { paint, type As, type Node, type Painter } from './tree.ts';
|
|
16
16
|
|
|
17
17
|
// One property of an input schema, as the form needs it.
|
|
18
18
|
export type Property = { type?: string; description?: string; enum?: Json[]; format?: string; default?: Json };
|
|
@@ -142,6 +142,7 @@ export type Model = {
|
|
|
142
142
|
blueprint: Blueprint | null; // her describe for this human, or nothing yet
|
|
143
143
|
look: Look; // her own look, from her `look` ask, or nothing
|
|
144
144
|
tree: Node | null; // her page, from her `page` ask, or nothing: then her asks are painted as forms
|
|
145
|
+
pages: Record<string, Node | null>; // the page of each standing she carries, by id, from its carried `page` ask
|
|
145
146
|
notice: string; // one line about where the human stands: in, not in, an error before an ask
|
|
146
147
|
answers: Record<string, Word>; // the last answer per ask, shown under its form
|
|
147
148
|
pushes: JsonObject[]; // every push from the world, newest last
|
|
@@ -181,18 +182,31 @@ export function ordered(asks: Ask[], l: Look | undefined): Ask[] {
|
|
|
181
182
|
|
|
182
183
|
export { hintFor };
|
|
183
184
|
|
|
184
|
-
// The asks that are presentation and never a form: her look and her page.
|
|
185
|
-
const PRESENTATION = new Set(['look', 'page']);
|
|
186
|
-
|
|
187
185
|
export function page(m: Model): string {
|
|
188
186
|
const bp = m.blueprint;
|
|
189
|
-
const
|
|
187
|
+
const pres = presentation(bp);
|
|
188
|
+
const one =(l: Look | undefined, prefix = '') => (a: Ask) => {
|
|
190
189
|
const w = m.answers[a.name];
|
|
191
190
|
const h = hint(l, a.name.startsWith(prefix) ? a.name.slice(prefix.length) : a.name);
|
|
192
191
|
return `<section>${form(a, h)}${w ? face(w, a.output) : ''}</section>`;
|
|
193
192
|
};
|
|
193
|
+
// an answer shown as the page asks: as text, as cards, as a list, or by its shape
|
|
194
|
+
const answer = (name: string, as: As): string => {
|
|
195
|
+
const a = bp?.asks.find((x) => x.name === name);
|
|
196
|
+
const w = a ? m.answers[name] : undefined;
|
|
197
|
+
if (!a || !w) return '';
|
|
198
|
+
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>`;
|
|
199
|
+
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>`;
|
|
200
|
+
if (w.word === 'object' && as === 'list' && Array.isArray(w.value)) return `<ul class="answer">${w.value.map((v) => `<li>${view(v)}</li>`).join('')}</ul>`;
|
|
201
|
+
return face(w, a.output);
|
|
202
|
+
};
|
|
194
203
|
const groups = grouped(bp);
|
|
195
204
|
const taken = new Set(Object.values(groups).flatMap((g) => g.asks ?? []));
|
|
205
|
+
// A standing's section. With her page, the far tree is painted inside it
|
|
206
|
+
// through a painter of her own: a name it uses is one of her asks, carried,
|
|
207
|
+
// and never one of the carrier's or another standing's, so a far page
|
|
208
|
+
// reaches nothing outside its section; her title is the page's, and the
|
|
209
|
+
// section keeps her look. Without one, her asks are forms under her name.
|
|
196
210
|
const section = (id: string): string => {
|
|
197
211
|
const g = groups[id];
|
|
198
212
|
const asks = bp && g ? bp.asks.filter((a) => g.asks?.includes(a.name)) : [];
|
|
@@ -200,6 +214,19 @@ export function page(m: Model): string {
|
|
|
200
214
|
const kept = sanitise(g!.look);
|
|
201
215
|
const l = look(kept);
|
|
202
216
|
const prefix = `${id}-`;
|
|
217
|
+
const tree = m.pages[id];
|
|
218
|
+
if (tree) {
|
|
219
|
+
const hers: Painter = {
|
|
220
|
+
form: (name) => {
|
|
221
|
+
const a = asks.find((x) => x.name === prefix + name);
|
|
222
|
+
return a ? one(kept, prefix)(a) : '';
|
|
223
|
+
},
|
|
224
|
+
answer: (name, as) => (asks.some((x) => x.name === prefix + name) ? answer(prefix + name, as) : ''),
|
|
225
|
+
standing: () => '',
|
|
226
|
+
standings: () => '',
|
|
227
|
+
};
|
|
228
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div></section>`;
|
|
229
|
+
}
|
|
203
230
|
const want = (kept.order ?? []).map((n) => prefix + n);
|
|
204
231
|
const inOrder = ordered(asks, { order: want });
|
|
205
232
|
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
|
|
@@ -209,26 +236,20 @@ export function page(m: Model): string {
|
|
|
209
236
|
// uses looked up in her describe, so a page shows nothing her gate hid.
|
|
210
237
|
const painter: Painter = {
|
|
211
238
|
form: (name) => {
|
|
212
|
-
const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !
|
|
239
|
+
const a = bp?.asks.find((x) => x.name === name && !taken.has(x.name) && !pres.has(x.name));
|
|
213
240
|
return a ? one(m.look)(a) : '';
|
|
214
241
|
},
|
|
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
|
-
},
|
|
242
|
+
answer: (name, as) => (pres.has(name) ? '' : answer(name, as)),
|
|
224
243
|
standing: section,
|
|
225
244
|
standings: () => far,
|
|
226
245
|
};
|
|
227
|
-
const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !
|
|
246
|
+
const own = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !pres.has(a.name)), m.look).map(one(m.look)).join('') : '';
|
|
228
247
|
const asks = m.tree ? `<div class="page">${paint(m.tree, painter)}</div>` : own + far;
|
|
229
248
|
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;
|
|
230
249
|
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
250
|
const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
|
|
232
251
|
const mine = look(m.look);
|
|
233
|
-
|
|
252
|
+
// a page carries its own title, so the header keeps only the notice; a being painted as forms is headed by her name
|
|
253
|
+
const head = m.tree ? '' : `${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1>`;
|
|
254
|
+
return `<header${mine.style}>${head}<p class="notice">${escape(m.notice)}</p></header>${notes}<main${mine.style}>${asks}</main>${pushes}`;
|
|
234
255
|
}
|
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
|
+
}
|
package/human/quo-human.md
CHANGED
|
@@ -55,14 +55,65 @@ running there, from her cells, and painted by the same painter.
|
|
|
55
55
|
- **What opens with the page.** The answers a tree shows are run on open
|
|
56
56
|
when the ask needs nothing typed, beside the read-only asks her look
|
|
57
57
|
marks, so the page opens with what it shows.
|
|
58
|
-
- **The page is presentation.** `page` is asked
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
an answer, her standings as sections, and the
|
|
62
|
-
by hand, waking another device, as
|
|
58
|
+
- **The page is presentation.** `page` is asked after every call, and it is
|
|
59
|
+
never a form and never a tool, hers or a carried one. The user being
|
|
60
|
+
answers one: her name as the title, who this device is as
|
|
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.
|
|
103
|
+
- **A carried page is painted in her section.** The third source of the one
|
|
104
|
+
grammar, and no new grammar: a standing the user being carries answers
|
|
105
|
+
`page` like anyone, the carrier carries it as `<id>-page` and names it in
|
|
106
|
+
her notes, and the side asks it after every call like her own. The
|
|
107
|
+
section that was her forms under her name becomes her page under her
|
|
108
|
+
look, painted through a painter of her own: a form or an answer it names
|
|
109
|
+
is one of her asks by its bare name, carried, and a name that is not hers,
|
|
110
|
+
or a `standing` inside a page, paints as nothing. So a far page reaches
|
|
111
|
+
nothing outside its section, by construction and not by a shadow root:
|
|
112
|
+
the grammar carries no selector, no script and no stylesheet, the one
|
|
113
|
+
stylesheet is the shell's, and a root of her own would keep the shell's
|
|
114
|
+
stylesheet out of her section for no gain. The section is the root a page
|
|
115
|
+
owns.
|
|
116
|
+
|
|
66
117
|
## How a screen is made
|
|
67
118
|
|
|
68
119
|
Read with the trunk's "Carrying" and "The look", which this applies.
|
|
@@ -86,8 +137,9 @@ Read with the trunk's "Carrying" and "The look", which this applies.
|
|
|
86
137
|
and acme sees the human, never the device. The same section appears on
|
|
87
138
|
acme's own web, under the same look: one component, two relations.
|
|
88
139
|
- **The page follows the digest.** The side asks the describe again after
|
|
89
|
-
every call
|
|
90
|
-
is a page that moved
|
|
140
|
+
every call, asks `look` once per digest and `page` after every call; a
|
|
141
|
+
digest that moved is a page that moved, and so is a cell that moved
|
|
142
|
+
behind a page. A read-only ask with nothing to type is run on open,
|
|
91
143
|
so a page can open with what it shows and not only with buttons. Every one
|
|
92
144
|
of those asks rotates her keys, so the side saves after each, not only
|
|
93
145
|
after a form: a relation brought back from a reload with a stale count is
|
|
@@ -143,7 +195,10 @@ Read with the trunk's "Carrying" and "The look", which this applies.
|
|
|
143
195
|
|
|
144
196
|
## The pieces
|
|
145
197
|
|
|
146
|
-
|
|
198
|
+
Ten files under `packages/dock/human/`:
|
|
199
|
+
|
|
200
|
+
- `local.ts` is a being of the world in the tab's own ward as a subject:
|
|
201
|
+
her door spoken to in-process under the tab's asker, no standing.
|
|
147
202
|
|
|
148
203
|
- `tree.ts` is the page grammar and its painter, pure: `sanitiseTree`
|
|
149
204
|
holds a value to the grammar or drops it, `answersIn` names what a tree
|
|
@@ -157,9 +212,10 @@ Nine files under `packages/dock/human/`:
|
|
|
157
212
|
- `screen.ts` is the side: one avatar and one surface, where a surface can
|
|
158
213
|
only show a page and hand back a submitted form. It keeps the model,
|
|
159
214
|
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
|
|
161
|
-
|
|
162
|
-
|
|
215
|
+
re-asks her describe after every call, asks her look once per digest and
|
|
216
|
+
her page after every call, and runs her read-only asks once per digest
|
|
217
|
+
and the ones her page shows the answers of after every call. A push is
|
|
218
|
+
appended and shown as it lands. A
|
|
163
219
|
describe that fails leaves the last page standing and says so in the
|
|
164
220
|
notice.
|
|
165
221
|
- `dom.ts` is the surface on an element, the one file that touches one.
|
|
@@ -182,8 +238,9 @@ Nine files under `packages/dock/human/`:
|
|
|
182
238
|
|
|
183
239
|
Proven in `packages/dock/test/human.test.ts` on the memory harbor with a fake
|
|
184
240
|
surface and no browser: the grammar held and painted, the user being's page
|
|
185
|
-
with what her gate hides painted as nothing,
|
|
186
|
-
with
|
|
241
|
+
with what her gate hides painted as nothing, a being of the world in the
|
|
242
|
+
tab's ward paged from her cells and kept with them, the user being
|
|
243
|
+
carrying a shop with a look, a guest
|
|
187
244
|
at a door that is not a desk let in by a form, the link read, stripped and
|
|
188
245
|
refused, and the worlds and relations on a harbor core over the memory
|
|
189
246
|
store, two invitations into one world being two avatars; and in
|
package/human/screen.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { isInvitation } from '../beings/link.ts';
|
|
|
13
13
|
import type { Invitation } from '@quo-systems/quo';
|
|
14
14
|
import { page, values, hintFor, type Model, type Raw } from './html.ts';
|
|
15
15
|
import { sanitiseTree, answersIn } from './tree.ts';
|
|
16
|
-
import { sanitise } from '../beings/look.ts';
|
|
16
|
+
import { groups, sanitise } from '../beings/look.ts';
|
|
17
17
|
import { isSilence, isWord } from '@quo-systems/quo';
|
|
18
18
|
import type { Json } from '@quo-systems/quo';
|
|
19
19
|
|
|
@@ -36,7 +36,7 @@ export type Options = { after?: () => Promise<void>; notice?: string; admit?: (i
|
|
|
36
36
|
export async function screenSide(avatar: Subject, surface: Surface, options: Options = {}): Promise<Serving & { model: Model; refresh(): Promise<void> }> {
|
|
37
37
|
const after = options.after ?? (async () => {});
|
|
38
38
|
const notice = options.notice ?? '';
|
|
39
|
-
const model: Model = { blueprint: null, look: {}, tree: null, notice, answers: {}, pushes: [] };
|
|
39
|
+
const model: Model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [] };
|
|
40
40
|
let seen: string | null = null;
|
|
41
41
|
const show = () => surface.show(page(model));
|
|
42
42
|
|
|
@@ -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
|
-
|
|
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,29 @@ 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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 trees = () => [model.tree ? answersIn(model.tree) : [], ...Object.entries(model.pages).map(([id, t]) => (t ? answersIn(t).map((n) => `${id}-${n}`) : []))].flat();
|
|
67
|
+
const shows = new Set(trees());
|
|
68
|
+
const tree = async (name: string) => {
|
|
69
|
+
const t = await avatar.call(name);
|
|
70
|
+
return isSilence(t) || isWord(t) ? null : sanitiseTree(t as Json);
|
|
71
|
+
};
|
|
72
|
+
model.tree = (bp as Blueprint).asks.some((a) => a.name === 'page') ? await tree('page') : null;
|
|
73
|
+
// and the page of every standing she carries, the same way, painted in that standing's section;
|
|
74
|
+
// a name a carried page uses is one of that standing's asks, carried, and nothing else
|
|
75
|
+
model.pages = {};
|
|
76
|
+
for (const [id, g] of Object.entries(groups(bp as Blueprint))) if (typeof g.page === 'string' && (bp as Blueprint).asks.some((a) => a.name === g.page)) model.pages[id] = await tree(g.page);
|
|
77
|
+
const wanted = new Set(trees());
|
|
78
|
+
for (const n of shows) if (wanted.has(n)) delete model.answers[n];
|
|
79
|
+
// a read-only ask that needs nothing typed is run on her behalf once per digest, and one her page
|
|
80
|
+
// shows the answer of after every ask, so the page opens and stays with what it shows
|
|
81
|
+
for (const a of (bp as Blueprint).asks) {
|
|
82
|
+
const needs = ((a.input as { required?: string[] }).required ?? []).length > 0;
|
|
83
|
+
const run = wanted.has(a.name) || (moved && hintFor(bp as Blueprint, model.look, a.name).readOnly);
|
|
84
|
+
if (run && !needs && !(a.name in model.answers)) model.answers[a.name] = word(await avatar.call(a.name, {}));
|
|
73
85
|
}
|
|
74
86
|
}
|
|
75
87
|
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,
|
|
40
|
-
|
|
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
|
-
//
|
|
142
|
-
//
|
|
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
|
|
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();
|