@quo-systems/dock 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/beings/quo-dock.md +6 -0
- package/beings/user.ts +20 -6
- package/dist/beings/user.d.ts +24 -0
- package/dist/beings/user.js +20 -6
- 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/html.js +3 -1
- package/dist/human/local.d.ts +10 -0
- package/dist/human/local.js +10 -0
- package/dist/human/screen.js +24 -16
- 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 +27 -3
- 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 +54 -14
- package/harbor/seal.ts +26 -0
- package/human/html.ts +3 -1
- package/human/local.ts +26 -0
- package/human/quo-human.md +56 -10
- package/human/screen.ts +19 -13
- package/human/tab.ts +50 -5
- package/human/web.ts +27 -4
- package/package.json +13 -2
|
@@ -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
|
@@ -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,28 @@ 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 shows = new Set(model.tree ? answersIn(model.tree) : []);
|
|
51
|
+
if (bp.asks.some((a) => a.name === 'page')) {
|
|
52
|
+
const t = await avatar.call('page');
|
|
53
|
+
model.tree = isSilence(t) || isWord(t) ? null : sanitiseTree(t);
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
model.tree = null;
|
|
57
|
+
const wanted = new Set(model.tree ? answersIn(model.tree) : []);
|
|
58
|
+
for (const n of shows)
|
|
59
|
+
if (wanted.has(n))
|
|
60
|
+
delete model.answers[n];
|
|
61
|
+
// a read-only ask that needs nothing typed is run on her behalf once per digest, and one her page
|
|
62
|
+
// shows the answer of after every ask, so the page opens and stays with what it shows
|
|
63
|
+
for (const a of bp.asks) {
|
|
64
|
+
const needs = (a.input.required ?? []).length > 0;
|
|
65
|
+
const run = wanted.has(a.name) || (moved && hintFor(bp, model.look, a.name).readOnly);
|
|
66
|
+
if (run && !needs && !(a.name in model.answers))
|
|
67
|
+
model.answers[a.name] = word(await avatar.call(a.name, {}));
|
|
60
68
|
}
|
|
61
69
|
}
|
|
62
70
|
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();
|
package/dist/human/web.d.ts
CHANGED
package/dist/human/web.js
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
3
5
|
import { build } from 'esbuild';
|
|
4
6
|
import { door } from './door.js';
|
|
5
7
|
// The paths under `/web` that are the exchange's, never a ward's name.
|
|
6
|
-
const RESERVED_PATHS = new Set(['allow', 'tab.js']);
|
|
8
|
+
const RESERVED_PATHS = new Set(['allow', 'tab.js', 'beings.js']);
|
|
9
|
+
// The world's code for the tab: a module in the harbor folder, the twin of
|
|
10
|
+
// `classes/index.ts`, exporting by name the classes the tab boots into its
|
|
11
|
+
// local ward for a world of this harbor. Served bundled as `beings.js` on
|
|
12
|
+
// this origin, and one origin is one world's code.
|
|
13
|
+
export const TAB_CODE = 'tab/index.ts';
|
|
7
14
|
export function webRoute(harbor, o) {
|
|
8
15
|
const tabEntry = () => {
|
|
9
16
|
const js = fileURLToPath(new URL('./tab.js', import.meta.url));
|
|
10
17
|
return existsSync(js) ? js : fileURLToPath(new URL('./tab.ts', import.meta.url));
|
|
11
18
|
};
|
|
19
|
+
const beingsEntry = join(harbor.dir, TAB_CODE);
|
|
20
|
+
const bundled = (entry) =>
|
|
21
|
+
// the entry may sit in a harbor folder with no node_modules of its own, as on a droplet whose
|
|
22
|
+
// estate folder is the working directory: what it imports is resolved from there too
|
|
23
|
+
build({ entryPoints: [entry], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false, nodePaths: [join(process.cwd(), 'node_modules')] }).then((out) => out.outputFiles[0].text);
|
|
12
24
|
let bundle;
|
|
13
|
-
const built = () => (bundle ??=
|
|
25
|
+
const built = () => (bundle ??= bundled(tabEntry()));
|
|
26
|
+
let beings;
|
|
27
|
+
const builtBeings = () => (beings ??= bundled(beingsEntry));
|
|
14
28
|
const quoOrigin = (() => {
|
|
15
29
|
try {
|
|
16
30
|
const u = new URL(o.at.quo);
|
|
@@ -41,7 +55,7 @@ export function webRoute(harbor, o) {
|
|
|
41
55
|
};
|
|
42
56
|
const wards = () => Object.fromEntries([...harbor.wards].map(([n, h]) => [n, { pk: h.pk, public: publicOf(h) !== null }]));
|
|
43
57
|
const tab = (ward) => {
|
|
44
|
-
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), ...(ward ? { ward } : {}) };
|
|
58
|
+
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), beings: existsSync(beingsEntry), ...(ward ? { ward } : {}) };
|
|
45
59
|
return `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
|
|
46
60
|
};
|
|
47
61
|
return async (req, res, rest) => {
|
|
@@ -58,6 +72,14 @@ export function webRoute(harbor, o) {
|
|
|
58
72
|
res.end(await built());
|
|
59
73
|
return true;
|
|
60
74
|
}
|
|
75
|
+
// the world's code for the tab, when the harbor folder holds any; a harbor with none has no such path
|
|
76
|
+
if (rest === '/beings.js' && req.method === 'GET') {
|
|
77
|
+
if (!existsSync(beingsEntry))
|
|
78
|
+
return false;
|
|
79
|
+
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
|
|
80
|
+
res.end(await builtBeings());
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
61
83
|
const wardName = parts[0] ?? '';
|
|
62
84
|
if (!wardName || RESERVED_PATHS.has(wardName))
|
|
63
85
|
return false;
|
|
@@ -82,6 +104,8 @@ const CSS = [
|
|
|
82
104
|
'header,main{background:var(--bg);color:var(--fg);font-family:var(--font)}header{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}header .notice{width:100%;margin:0}',
|
|
83
105
|
'input,select,textarea,button{font:inherit;padding:.4rem;margin:.2rem;border-radius:var(--radius)}button{background:var(--accent);color:#fff;border:0;padding:.4rem .9rem}',
|
|
84
106
|
'fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}',
|
|
107
|
+
// a being of the world running in the tab: her own section under the far page
|
|
108
|
+
'section.local{border-top:1px solid color-mix(in srgb,currentColor 15%,transparent);margin:1.5rem 0;padding-top:.5rem}section.local header:empty{display:none}',
|
|
85
109
|
'section.standing{background:var(--bg);color:var(--fg);font-family:var(--font);border-left:4px solid var(--accent);border-radius:var(--radius);padding:.25rem 1rem;margin:1.5rem 0}section.standing h2{display:flex;align-items:center;gap:.5rem;font-size:1.1rem}img.logo{height:1.6rem}',
|
|
86
110
|
'table{border-collapse:collapse}td,th{padding:.15rem .5rem;text-align:left}.answer{margin:.5rem 0 1rem;padding:.5rem;border-left:3px solid var(--accent)}.answer.error{border-color:#c33}.answer.silence,.answer.word,.answer.unreached{border-color:#c93}pre{padding:.75rem;overflow:auto}',
|
|
87
111
|
// a page in the grammar: regions, roles, cards
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The store on a phone: the wrapped form over Capacitor's Filesystem and
|
|
3
|
+
// a secure-storage plugin. One key per harbor in the Keychain, this device
|
|
4
|
+
// only, so it never travels in a backup; one sealed file per ward, seed,
|
|
5
|
+
// partition and record as one blob, in the folder iCloud does not copy.
|
|
6
|
+
//
|
|
7
|
+
// <Library/NoCloud>/quo/<harbor>/wards/<name>.sealed
|
|
8
|
+
// <Library/NoCloud>/quo/<harbor>/reach.json
|
|
9
|
+
// Keychain: quo-<harbor> 32 bytes as hex
|
|
10
|
+
//
|
|
11
|
+
// Custody is the backup question, and opening the store answers it: a key
|
|
12
|
+
// with no folder is a reinstall, and the stale key is deleted; a folder
|
|
13
|
+
// with no key is a restore to another device, and the unreadable files are
|
|
14
|
+
// deleted. Either way the harbor starts fresh and there is never a twin.
|
|
15
|
+
import { Capacitor } from '@capacitor/core';
|
|
16
|
+
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
|
17
|
+
import { SecureStorage, KeychainAccess } from '@aparajita/capacitor-secure-storage';
|
|
18
|
+
import { arithmetic } from '@quo-systems/quo/ward';
|
|
19
|
+
import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
|
|
20
|
+
import { sealKey, seal, open } from './seal.ts';
|
|
21
|
+
|
|
22
|
+
const { hex, unhex } = arithmetic;
|
|
23
|
+
type Blob = { seed: string; partition: Record<string, unknown>; record: WardRecord };
|
|
24
|
+
|
|
25
|
+
// Where the files live: the folder iCloud does not copy on iOS, the app's
|
|
26
|
+
// own files on Android, whose manifest says no backup.
|
|
27
|
+
const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
|
|
28
|
+
|
|
29
|
+
async function exists(path: string): Promise<boolean> {
|
|
30
|
+
try {
|
|
31
|
+
await Filesystem.stat({ path, directory });
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class Native implements Store {
|
|
39
|
+
readonly harbor: string;
|
|
40
|
+
readonly #key: CryptoKey;
|
|
41
|
+
readonly #queues = new Map<string, Promise<void>>();
|
|
42
|
+
private constructor(harbor: string, key: CryptoKey) {
|
|
43
|
+
this.harbor = harbor;
|
|
44
|
+
this.#key = key;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Open one harbor's store, applying the custody rule, minting the key and
|
|
48
|
+
// the folder when this is a fresh install.
|
|
49
|
+
static async open(harbor: string): Promise<Native> {
|
|
50
|
+
const item = `quo-${harbor}`;
|
|
51
|
+
const root = `quo/${harbor}`;
|
|
52
|
+
// `set` keeps JSON, so `get` parses it back; `getItem` would hand back the quotes.
|
|
53
|
+
const got = await SecureStorage.get(item, false, false);
|
|
54
|
+
let secret = typeof got === 'string' ? got : undefined;
|
|
55
|
+
const folder = await exists(root);
|
|
56
|
+
if (secret && !folder) {
|
|
57
|
+
await SecureStorage.remove(item);
|
|
58
|
+
secret = undefined;
|
|
59
|
+
}
|
|
60
|
+
if (!secret && folder) await Filesystem.rmdir({ path: root, directory, recursive: true });
|
|
61
|
+
if (!secret) {
|
|
62
|
+
secret = hex(crypto.getRandomValues(new Uint8Array(32)));
|
|
63
|
+
await SecureStorage.set(item, secret, false, false, KeychainAccess.afterFirstUnlockThisDeviceOnly);
|
|
64
|
+
}
|
|
65
|
+
// mkdir refuses a folder that exists, recursive or not.
|
|
66
|
+
if (!(await exists(`${root}/wards`))) await Filesystem.mkdir({ path: `${root}/wards`, directory, recursive: true });
|
|
67
|
+
return new Native(harbor, await sealKey(secret));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Everything this harbor has, key and files: what a person does by hand
|
|
71
|
+
// to leave a device, and what a test does between two openings.
|
|
72
|
+
static async wipe(harbor: string): Promise<void> {
|
|
73
|
+
await SecureStorage.remove(`quo-${harbor}`);
|
|
74
|
+
if (await exists(`quo/${harbor}`)) await Filesystem.rmdir({ path: `quo/${harbor}`, directory, recursive: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#path(name: string) {
|
|
78
|
+
return `quo/${this.harbor}/wards/${name}.sealed`;
|
|
79
|
+
}
|
|
80
|
+
async #read(name: string): Promise<Blob> {
|
|
81
|
+
const { data } = await Filesystem.readFile({ path: this.#path(name), directory, encoding: Encoding.UTF8 });
|
|
82
|
+
return JSON.parse(new TextDecoder().decode(await open(this.#key, (data as string).trim()))) as Blob;
|
|
83
|
+
}
|
|
84
|
+
// A sealed ward is rewritten whole, so the read sits inside the queue
|
|
85
|
+
// with the write: two changes to one ward never lose each other's part.
|
|
86
|
+
#keep(name: string, change: (b: Blob | undefined) => Blob): Promise<void> {
|
|
87
|
+
const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
|
|
88
|
+
const blob = change((await exists(this.#path(name))) ? await this.#read(name) : undefined);
|
|
89
|
+
const sealed = await seal(this.#key, new TextEncoder().encode(JSON.stringify(blob)));
|
|
90
|
+
await Filesystem.writeFile({ path: this.#path(name), directory, data: sealed + '\n', encoding: Encoding.UTF8 });
|
|
91
|
+
});
|
|
92
|
+
this.#queues.set(name, next.catch(() => {}));
|
|
93
|
+
return next;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async list(): Promise<string[]> {
|
|
97
|
+
const { files } = await Filesystem.readdir({ path: `quo/${this.harbor}/wards`, directory });
|
|
98
|
+
return files.filter((f) => f.name.endsWith('.sealed')).map((f) => f.name.slice(0, -'.sealed'.length));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async load(name: string): Promise<Kept | undefined> {
|
|
102
|
+
if (!(await exists(this.#path(name)))) return undefined;
|
|
103
|
+
const b = await this.#read(name);
|
|
104
|
+
return { seed: unhex(b.seed), partition: b.partition, record: b.record };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async put(name: string, kept: Kept): Promise<void> {
|
|
108
|
+
if (await exists(this.#path(name))) throw new Error(`ward ${name} already exists on this device`);
|
|
109
|
+
return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async save(name: string, partition: Record<string, unknown>): Promise<void> {
|
|
113
|
+
if (!(await exists(this.#path(name)))) return; // a name not kept is nothing
|
|
114
|
+
return this.#keep(name, (b) => ({ ...b!, partition }));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async record(name: string, record: WardRecord): Promise<void> {
|
|
118
|
+
if (!(await exists(this.#path(name)))) return;
|
|
119
|
+
return this.#keep(name, (b) => ({ ...b!, record }));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async take(name: string): Promise<Kept | undefined> {
|
|
123
|
+
const kept = await this.load(name);
|
|
124
|
+
if (!kept) return undefined;
|
|
125
|
+
await this.#queues.get(name);
|
|
126
|
+
await Filesystem.deleteFile({ path: this.#path(name), directory });
|
|
127
|
+
return kept;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async hints(): Promise<Record<string, string>> {
|
|
131
|
+
const p = `quo/${this.harbor}/reach.json`;
|
|
132
|
+
if (!(await exists(p))) return {};
|
|
133
|
+
const { data } = await Filesystem.readFile({ path: p, directory, encoding: Encoding.UTF8 });
|
|
134
|
+
return JSON.parse(data as string) as Record<string, string>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async hint(pk: string, url: string): Promise<void> {
|
|
138
|
+
const all = await this.hints();
|
|
139
|
+
all[pk] = url;
|
|
140
|
+
await Filesystem.writeFile({ path: `quo/${this.harbor}/reach.json`, directory, data: JSON.stringify(all) + '\n', encoding: Encoding.UTF8 });
|
|
141
|
+
}
|
|
142
|
+
}
|
package/harbor/disk.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// <dir>/
|
|
10
10
|
// lease pid of the one process that holds this harbor
|
|
11
11
|
// classes/index.ts the default class source, a module exporting classes
|
|
12
|
-
//
|
|
12
|
+
// tab/index.ts the classes for the tab, served bundled by the web route: human/web.ts
|
|
13
|
+
// wards/<name>/ seed, partition.json, ward.json, or one sealed
|
|
14
|
+
// blob when the daemon holds QUO_SEED_KEY: see files.ts
|
|
13
15
|
// reach.json the directory's hints
|
|
14
16
|
import { mkdir, readFile, writeFile, unlink, stat } from 'node:fs/promises';
|
|
15
17
|
import { existsSync } from 'node:fs';
|
|
@@ -51,7 +53,10 @@ export class DiskHarbor extends Harbor {
|
|
|
51
53
|
|
|
52
54
|
constructor(dir: string) {
|
|
53
55
|
const abs = resolve(dir);
|
|
54
|
-
|
|
56
|
+
// The key the environment gives, the edge's name for it: from the
|
|
57
|
+
// Keychain through the app that spawned this daemon, or nothing on a
|
|
58
|
+
// droplet, whose folder stays plain under its file modes.
|
|
59
|
+
super(new Files(abs, process.env.QUO_SEED_KEY), async (rec) => ({ ...BUILT_IN, ...(await loadClasses(isAbsolute(rec.code) ? rec.code : join(abs, rec.code))) }));
|
|
55
60
|
this.dir = abs;
|
|
56
61
|
}
|
|
57
62
|
|
|
@@ -62,7 +67,7 @@ export class DiskHarbor extends Harbor {
|
|
|
62
67
|
// has one.
|
|
63
68
|
static async init(dir: string, name = 'main', user = 'me'): Promise<{ dir: string; name: string; pk: string; user: string }> {
|
|
64
69
|
const h = new DiskHarbor(dir);
|
|
65
|
-
if (
|
|
70
|
+
if ((await h.store.list()).includes(name)) throw new Error(`ward ${name} already exists in ${h.dir}`);
|
|
66
71
|
await mkdir(join(h.dir, 'classes'), { recursive: true });
|
|
67
72
|
const classes = join(h.dir, DEFAULT_CODE);
|
|
68
73
|
if (!existsSync(classes)) await writeFile(classes, '// The classes this harbor holds beside the built-in ones. Export each one by name.\nexport {};\n');
|
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.
|