@quo-systems/dock 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/beings/carry.ts +7 -3
- package/beings/desk.ts +5 -1
- package/beings/look.ts +12 -3
- package/beings/quo-dock.md +22 -13
- package/beings/user.ts +20 -6
- package/dist/beings/carry.js +10 -4
- package/dist/beings/desk.d.ts +0 -1
- package/dist/beings/desk.js +5 -1
- package/dist/beings/look.d.ts +2 -0
- package/dist/beings/look.js +9 -0
- package/dist/beings/user.d.ts +24 -0
- package/dist/beings/user.js +20 -6
- package/dist/harbor/browser.d.ts +4 -4
- package/dist/harbor/browser.js +18 -9
- package/dist/harbor/capacitor.d.ts +19 -0
- package/dist/harbor/capacitor.js +151 -0
- package/dist/harbor/disk.js +8 -3
- package/dist/harbor/edge/edge.js +2 -1
- package/dist/harbor/edge/exercise.js +2 -1
- package/dist/harbor/edge/storage.d.ts +0 -3
- package/dist/harbor/edge/storage.js +6 -25
- package/dist/harbor/files.d.ts +2 -1
- package/dist/harbor/files.js +87 -22
- package/dist/harbor/seal.d.ts +3 -0
- package/dist/harbor/seal.js +25 -0
- package/dist/human/html.d.ts +1 -0
- package/dist/human/html.js +40 -19
- package/dist/human/local.d.ts +10 -0
- package/dist/human/local.js +10 -0
- package/dist/human/screen.js +32 -18
- package/dist/human/tab.d.ts +1 -0
- package/dist/human/tab.js +59 -3
- package/dist/human/web.d.ts +1 -0
- package/dist/human/web.js +34 -4
- package/dist/mcp/runner.js +5 -3
- package/dist/mcp/server.js +5 -3
- package/harbor/browser.ts +28 -17
- package/harbor/capacitor.ts +159 -0
- package/harbor/disk.ts +8 -3
- package/harbor/edge/edge.ts +2 -1
- package/harbor/edge/exercise.ts +2 -1
- package/harbor/edge/storage.ts +6 -25
- package/harbor/files.ts +79 -19
- package/harbor/quo-harbor.md +66 -14
- package/harbor/seal.ts +26 -0
- package/human/html.ts +39 -18
- package/human/local.ts +26 -0
- package/human/quo-human.md +70 -13
- package/human/screen.ts +27 -15
- package/human/tab.ts +50 -5
- package/human/web.ts +34 -5
- package/mcp/runner.ts +5 -3
- package/mcp/server.ts +5 -3
- package/package.json +14 -2
package/dist/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;
|
|
@@ -76,18 +98,26 @@ const CSS = [
|
|
|
76
98
|
':root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px;--ink:#1c1b22;--paper:#fbfaf7;--mute:#6b6a73}',
|
|
77
99
|
'@media(prefers-color-scheme:dark){:root{--ink:#ecebe6;--paper:#141318;--mute:#9a99a2}}',
|
|
78
100
|
'html{background:var(--paper);color:var(--ink)}',
|
|
79
|
-
|
|
101
|
+
// one column that is a phone first and a desk after: the same page, more air
|
|
102
|
+
'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:1rem auto;padding:0 1rem}@media(min-width:40rem){body{margin:2rem auto}}',
|
|
103
|
+
'input:not([type=checkbox]),select,textarea{width:100%;max-width:24rem;box-sizing:border-box}',
|
|
80
104
|
'nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}',
|
|
81
105
|
'nav.relations{display:flex;flex-wrap:wrap;gap:.25rem;margin:.5rem 0}nav.relations button{background:transparent;color:inherit;border:1px solid color-mix(in srgb,currentColor 30%,transparent)}nav.relations button[aria-current]{border-color:var(--accent);font-weight:600}',
|
|
82
106
|
'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
107
|
'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
108
|
'fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}',
|
|
109
|
+
// a being of the world running in the tab: her own section under the far page
|
|
110
|
+
'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
111
|
'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
112
|
'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
113
|
// a page in the grammar: regions, roles, cards
|
|
88
114
|
'.page .stack{display:flex;flex-direction:column;gap:.5rem}.page .row{display:flex;flex-wrap:wrap;gap:.5rem 1rem;align-items:baseline}',
|
|
89
115
|
'.page .t-title{font-size:2rem;font-weight:600;letter-spacing:-.02em;margin:.5rem 0}.page .t-lead{font-size:1.25rem;margin:.25rem 0}.page .t-label{font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;color:var(--mute);margin:0}.page .t-quiet{color:var(--mute);font-size:.9rem}.page .t-body{margin:.25rem 0}',
|
|
90
116
|
'.page .picture{max-height:6rem}.cards{display:flex;flex-wrap:wrap;gap:.75rem}.card{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);padding:.5rem .75rem}',
|
|
117
|
+
// a row with a picture is a line about someone or something: the picture small and the words beside it, centred
|
|
118
|
+
'.page .row:has(>.picture){align-items:center;flex-wrap:nowrap}.page .row>.picture{max-height:2.75rem;flex:none}.page .row>.stack{gap:0;min-width:0}.page .row>.stack>p{margin:0}',
|
|
119
|
+
// a far page inside a standing's section: her title is a section's, not the page's
|
|
120
|
+
'section.standing .page{padding:.5rem 0}section.standing .page .t-title{font-size:1.3rem;margin:.25rem 0}',
|
|
91
121
|
// the door page: one column, generous air, the mark, a word, a sentence
|
|
92
122
|
'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
|
|
93
123
|
'main.door .picture{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
|
package/dist/mcp/runner.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
|
|
2
|
+
import { presentation } from '../beings/look.js';
|
|
2
3
|
export const TURNS = 10;
|
|
3
4
|
// Her describe, spoken as a tools array. Name, description and input are
|
|
4
5
|
// verbatim, with the one narrowing an endpoint has asked for: an ask that
|
|
5
6
|
// declares no properties is sent with an empty `properties`, because
|
|
6
|
-
// LM Studio refuses a parameters schema without one.
|
|
7
|
-
// presentation, for a screen, and are not functions.
|
|
7
|
+
// LM Studio refuses a parameters schema without one. Her look, her page and
|
|
8
|
+
// every carried page are presentation, for a screen, and are not functions.
|
|
8
9
|
export function tools(bp) {
|
|
10
|
+
const shown = presentation(bp);
|
|
9
11
|
return bp.asks
|
|
10
|
-
.filter((a) =>
|
|
12
|
+
.filter((a) => !shown.has(a.name))
|
|
11
13
|
.map((a) => {
|
|
12
14
|
const t = { type: 'function', function: { name: a.name, parameters: { properties: {}, ...a.input, type: 'object' } } };
|
|
13
15
|
if (a.description !== undefined)
|
package/dist/mcp/server.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
12
12
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
13
13
|
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
|
|
14
|
-
import { hintFor, sanitise } from '../beings/look.js';
|
|
14
|
+
import { hintFor, presentation, sanitise } from '../beings/look.js';
|
|
15
15
|
import { isSilence, isWord, digest } from '@quo-systems/quo';
|
|
16
16
|
export const NAME = 'quo';
|
|
17
17
|
export const VERSION = '0.0.0';
|
|
@@ -20,10 +20,12 @@ export const DESCRIBE = { name: 'describe', description: 'the empty ask: her des
|
|
|
20
20
|
// Her describe, spoken as tools, the empty ask first. Name, description and
|
|
21
21
|
// input are verbatim; an output schema crosses when she declared one. Her
|
|
22
22
|
// look, when she has one, is the hints: a title, and the annotations a host
|
|
23
|
-
// reads.
|
|
23
|
+
// reads. Her look, her page and every carried page are presentation, for a
|
|
24
|
+
// screen, and are not tools.
|
|
24
25
|
export function tools(bp, look = {}) {
|
|
26
|
+
const shown = presentation(bp);
|
|
25
27
|
const asks = bp.asks
|
|
26
|
-
.filter((a) =>
|
|
28
|
+
.filter((a) => !shown.has(a.name))
|
|
27
29
|
.map((a) => {
|
|
28
30
|
const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
|
|
29
31
|
if (a.description !== undefined)
|
package/harbor/browser.ts
CHANGED
|
@@ -1,33 +1,38 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The browser harbor: the core over
|
|
3
|
-
// a listener: it holds one socket per world it is connected to, and
|
|
4
|
-
// ask to a pk it does not hold goes down a socket to the listener,
|
|
5
|
-
// the rendezvous.
|
|
6
|
-
// the
|
|
7
|
-
//
|
|
8
|
-
//
|
|
2
|
+
// The browser harbor: the core over a store, in a webview. A dialer and
|
|
3
|
+
// never a listener: it holds one socket per world it is connected to, and
|
|
4
|
+
// every ask to a pk it does not hold goes down a socket to the listener,
|
|
5
|
+
// which is the rendezvous. In a tab the store is IndexedDB under a name,
|
|
6
|
+
// and the lease is a web lock on that name, held for the life of the tab:
|
|
7
|
+
// a second tab on one origin meets the lock and is a screen, not a harbor.
|
|
8
|
+
// In an app the store is handed in, and the process that holds it is the
|
|
9
|
+
// lease: an app has one harbor and nothing else can open its files. The
|
|
10
|
+
// classes are the estate's beings, handed in with the bundle, plus whatever
|
|
11
|
+
// a page hands in beside them.
|
|
9
12
|
import type { BeingClass } from '@quo-systems/quo';
|
|
10
13
|
import { User, Desk, Avatar } from '../beings/index.ts';
|
|
11
|
-
import { Harbor, dial, type Dialer } from '@quo-systems/quo/harbor';
|
|
14
|
+
import { Harbor, dial, type Dialer, type Store } from '@quo-systems/quo/harbor';
|
|
12
15
|
import { Idb } from './idb.ts';
|
|
13
16
|
|
|
14
17
|
export const BUILT_IN: Record<string, BeingClass> = { User, Desk, Avatar };
|
|
15
18
|
|
|
16
19
|
export class BrowserHarbor extends Harbor {
|
|
17
|
-
readonly
|
|
20
|
+
readonly name: string;
|
|
18
21
|
readonly dialers: Dialer[] = [];
|
|
22
|
+
readonly #locked: boolean;
|
|
19
23
|
#release: (() => void) | undefined;
|
|
20
24
|
|
|
21
|
-
constructor(
|
|
22
|
-
const
|
|
23
|
-
super(
|
|
24
|
-
this.
|
|
25
|
+
constructor(store: string | Store = 'quo', classes: Record<string, BeingClass> = {}) {
|
|
26
|
+
const s = typeof store === 'string' ? new Idb(store) : store;
|
|
27
|
+
super(s, async () => ({ ...BUILT_IN, ...classes }));
|
|
28
|
+
this.name = typeof store === 'string' ? store : 'quo';
|
|
29
|
+
this.#locked = typeof store === 'string';
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
// Take the lock and boot every ward kept here. A lock already held is
|
|
28
33
|
// another tab's harbor over this seed, and this one refuses to boot.
|
|
29
34
|
override async boot(): Promise<void> {
|
|
30
|
-
await this.#lease();
|
|
35
|
+
if (this.#locked) await this.#lease();
|
|
31
36
|
await super.boot();
|
|
32
37
|
}
|
|
33
38
|
|
|
@@ -38,9 +43,15 @@ export class BrowserHarbor extends Harbor {
|
|
|
38
43
|
return d;
|
|
39
44
|
}
|
|
40
45
|
|
|
46
|
+
// The device is back: a phone out of a pocket, an app to the foreground.
|
|
47
|
+
// Every socket is told, and one that died silently is dialed again.
|
|
48
|
+
wake(): void {
|
|
49
|
+
for (const d of this.dialers) d.wake();
|
|
50
|
+
}
|
|
51
|
+
|
|
41
52
|
async close(): Promise<void> {
|
|
42
53
|
for (const d of this.dialers.splice(0)) d.close();
|
|
43
|
-
this.
|
|
54
|
+
(this.store as { close?(): void }).close?.();
|
|
44
55
|
this.#release?.();
|
|
45
56
|
this.#release = undefined;
|
|
46
57
|
}
|
|
@@ -49,7 +60,7 @@ export class BrowserHarbor extends Harbor {
|
|
|
49
60
|
const locks = (globalThis.navigator as Navigator | undefined)?.locks;
|
|
50
61
|
if (!locks) return; // no web locks on this terrain: nothing vouches, as in the memory harbor
|
|
51
62
|
const granted = await new Promise<boolean>((ok) => {
|
|
52
|
-
void locks.request(`quo:${this.
|
|
63
|
+
void locks.request(`quo:${this.name}`, { ifAvailable: true }, (lock) => {
|
|
53
64
|
if (!lock) {
|
|
54
65
|
ok(false);
|
|
55
66
|
return;
|
|
@@ -58,6 +69,6 @@ export class BrowserHarbor extends Harbor {
|
|
|
58
69
|
return new Promise<void>((release) => (this.#release = release)); // held until close
|
|
59
70
|
});
|
|
60
71
|
});
|
|
61
|
-
if (!granted) throw new Error(`harbor ${this.
|
|
72
|
+
if (!granted) throw new Error(`harbor ${this.name} is held by another tab`);
|
|
62
73
|
}
|
|
63
74
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
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
|
+
//
|
|
16
|
+
// The harbor on a phone is the browser harbor over this store: the app
|
|
17
|
+
// process is its lease, and the App plugin's foreground is its wake.
|
|
18
|
+
import { Capacitor } from '@capacitor/core';
|
|
19
|
+
import { App } from '@capacitor/app';
|
|
20
|
+
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
|
21
|
+
import { SecureStorage, KeychainAccess } from '@aparajita/capacitor-secure-storage';
|
|
22
|
+
import type { BeingClass } from '@quo-systems/quo';
|
|
23
|
+
import { arithmetic } from '@quo-systems/quo/ward';
|
|
24
|
+
import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
|
|
25
|
+
import { BrowserHarbor } from './browser.ts';
|
|
26
|
+
import { sealKey, seal, open } from './seal.ts';
|
|
27
|
+
|
|
28
|
+
// The app's one harbor, booted, told `wake` every time the app comes to
|
|
29
|
+
// the foreground, since a phone asleep loses its sockets silently.
|
|
30
|
+
export async function nativeHarbor(name = 'quo', classes: Record<string, BeingClass> = {}): Promise<BrowserHarbor> {
|
|
31
|
+
const h = new BrowserHarbor(await Native.open(name), classes);
|
|
32
|
+
await h.boot();
|
|
33
|
+
await App.addListener('appStateChange', ({ isActive }) => {
|
|
34
|
+
if (isActive) h.wake();
|
|
35
|
+
});
|
|
36
|
+
return h;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { hex, unhex } = arithmetic;
|
|
40
|
+
type Blob = { seed: string; partition: Record<string, unknown>; record: WardRecord };
|
|
41
|
+
|
|
42
|
+
// Where the files live: the folder iCloud does not copy on iOS, the app's
|
|
43
|
+
// own files on Android, whose manifest says no backup.
|
|
44
|
+
const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
|
|
45
|
+
|
|
46
|
+
async function exists(path: string): Promise<boolean> {
|
|
47
|
+
try {
|
|
48
|
+
await Filesystem.stat({ path, directory });
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class Native implements Store {
|
|
56
|
+
readonly harbor: string;
|
|
57
|
+
readonly #key: CryptoKey;
|
|
58
|
+
readonly #queues = new Map<string, Promise<void>>();
|
|
59
|
+
private constructor(harbor: string, key: CryptoKey) {
|
|
60
|
+
this.harbor = harbor;
|
|
61
|
+
this.#key = key;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Open one harbor's store, applying the custody rule, minting the key and
|
|
65
|
+
// the folder when this is a fresh install.
|
|
66
|
+
static async open(harbor: string): Promise<Native> {
|
|
67
|
+
const item = `quo-${harbor}`;
|
|
68
|
+
const root = `quo/${harbor}`;
|
|
69
|
+
// `set` keeps JSON, so `get` parses it back; `getItem` would hand back the quotes.
|
|
70
|
+
const got = await SecureStorage.get(item, false, false);
|
|
71
|
+
let secret = typeof got === 'string' ? got : undefined;
|
|
72
|
+
const folder = await exists(root);
|
|
73
|
+
if (secret && !folder) {
|
|
74
|
+
await SecureStorage.remove(item);
|
|
75
|
+
secret = undefined;
|
|
76
|
+
}
|
|
77
|
+
if (!secret && folder) await Filesystem.rmdir({ path: root, directory, recursive: true });
|
|
78
|
+
if (!secret) {
|
|
79
|
+
secret = hex(crypto.getRandomValues(new Uint8Array(32)));
|
|
80
|
+
await SecureStorage.set(item, secret, false, false, KeychainAccess.afterFirstUnlockThisDeviceOnly);
|
|
81
|
+
}
|
|
82
|
+
// mkdir refuses a folder that exists, recursive or not.
|
|
83
|
+
if (!(await exists(`${root}/wards`))) await Filesystem.mkdir({ path: `${root}/wards`, directory, recursive: true });
|
|
84
|
+
return new Native(harbor, await sealKey(secret));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Everything this harbor has, key and files: what a person does by hand
|
|
88
|
+
// to leave a device, and what a test does between two openings.
|
|
89
|
+
static async wipe(harbor: string): Promise<void> {
|
|
90
|
+
await SecureStorage.remove(`quo-${harbor}`);
|
|
91
|
+
if (await exists(`quo/${harbor}`)) await Filesystem.rmdir({ path: `quo/${harbor}`, directory, recursive: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#path(name: string) {
|
|
95
|
+
return `quo/${this.harbor}/wards/${name}.sealed`;
|
|
96
|
+
}
|
|
97
|
+
async #read(name: string): Promise<Blob> {
|
|
98
|
+
const { data } = await Filesystem.readFile({ path: this.#path(name), directory, encoding: Encoding.UTF8 });
|
|
99
|
+
return JSON.parse(new TextDecoder().decode(await open(this.#key, (data as string).trim()))) as Blob;
|
|
100
|
+
}
|
|
101
|
+
// A sealed ward is rewritten whole, so the read sits inside the queue
|
|
102
|
+
// with the write: two changes to one ward never lose each other's part.
|
|
103
|
+
#keep(name: string, change: (b: Blob | undefined) => Blob): Promise<void> {
|
|
104
|
+
const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
|
|
105
|
+
const blob = change((await exists(this.#path(name))) ? await this.#read(name) : undefined);
|
|
106
|
+
const sealed = await seal(this.#key, new TextEncoder().encode(JSON.stringify(blob)));
|
|
107
|
+
await Filesystem.writeFile({ path: this.#path(name), directory, data: sealed + '\n', encoding: Encoding.UTF8 });
|
|
108
|
+
});
|
|
109
|
+
this.#queues.set(name, next.catch(() => {}));
|
|
110
|
+
return next;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async list(): Promise<string[]> {
|
|
114
|
+
const { files } = await Filesystem.readdir({ path: `quo/${this.harbor}/wards`, directory });
|
|
115
|
+
return files.filter((f) => f.name.endsWith('.sealed')).map((f) => f.name.slice(0, -'.sealed'.length));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async load(name: string): Promise<Kept | undefined> {
|
|
119
|
+
if (!(await exists(this.#path(name)))) return undefined;
|
|
120
|
+
const b = await this.#read(name);
|
|
121
|
+
return { seed: unhex(b.seed), partition: b.partition, record: b.record };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async put(name: string, kept: Kept): Promise<void> {
|
|
125
|
+
if (await exists(this.#path(name))) throw new Error(`ward ${name} already exists on this device`);
|
|
126
|
+
return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async save(name: string, partition: Record<string, unknown>): Promise<void> {
|
|
130
|
+
if (!(await exists(this.#path(name)))) return; // a name not kept is nothing
|
|
131
|
+
return this.#keep(name, (b) => ({ ...b!, partition }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async record(name: string, record: WardRecord): Promise<void> {
|
|
135
|
+
if (!(await exists(this.#path(name)))) return;
|
|
136
|
+
return this.#keep(name, (b) => ({ ...b!, record }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async take(name: string): Promise<Kept | undefined> {
|
|
140
|
+
const kept = await this.load(name);
|
|
141
|
+
if (!kept) return undefined;
|
|
142
|
+
await this.#queues.get(name);
|
|
143
|
+
await Filesystem.deleteFile({ path: this.#path(name), directory });
|
|
144
|
+
return kept;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async hints(): Promise<Record<string, string>> {
|
|
148
|
+
const p = `quo/${this.harbor}/reach.json`;
|
|
149
|
+
if (!(await exists(p))) return {};
|
|
150
|
+
const { data } = await Filesystem.readFile({ path: p, directory, encoding: Encoding.UTF8 });
|
|
151
|
+
return JSON.parse(data as string) as Record<string, string>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async hint(pk: string, url: string): Promise<void> {
|
|
155
|
+
const all = await this.hints();
|
|
156
|
+
all[pk] = url;
|
|
157
|
+
await Filesystem.writeFile({ path: `quo/${this.harbor}/reach.json`, directory, data: JSON.stringify(all) + '\n', encoding: Encoding.UTF8 });
|
|
158
|
+
}
|
|
159
|
+
}
|
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.
|
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> {
|