@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
package/beings/quo-dock.md
CHANGED
|
@@ -151,6 +151,12 @@ taken when it can be: a knock back that is unreached, a tab whose pk the
|
|
|
151
151
|
far harbor has not bound yet or a phone in a tunnel, spends nothing, so she
|
|
152
152
|
keeps the invitation and takes it at the next push. A refusal is final.
|
|
153
153
|
|
|
154
|
+
A device the root minted may also call her: her `name` ask, gated to
|
|
155
|
+
root-minted devices alone, since the root already trusts that device with
|
|
156
|
+
its id and its reach. A being does not know her own key, so a name is
|
|
157
|
+
always given, by the desk's first hello at setup or by such a device, and
|
|
158
|
+
she keeps the last one. A name is a word, held to the same shape as a key.
|
|
159
|
+
|
|
154
160
|
## Architecture
|
|
155
161
|
|
|
156
162
|
One droplet, one harbor, three routes. Every other placement is a subset.
|
package/beings/user.ts
CHANGED
|
@@ -43,6 +43,7 @@ export class User extends Carrier {
|
|
|
43
43
|
chores: { description: 'what the agent may run', input: { type: 'object' }, for: (occ: OccupantRecord | undefined) => client(occ) === 'agent' },
|
|
44
44
|
look: { description: 'how she is shown', input: { type: 'object' }, for: isDevice },
|
|
45
45
|
page: { description: 'her page, as a tree of values', input: { type: 'object' }, for: isDevice },
|
|
46
|
+
name: { description: 'what she is called', input: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, for: rootMinted },
|
|
46
47
|
forget: { description: 'revoke a device: drop its way in and her way back to it, in one act', input: { type: 'object', properties: { client: { type: 'string' } }, required: ['client'] }, for: isDesk },
|
|
47
48
|
report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
|
|
48
49
|
};
|
|
@@ -134,16 +135,18 @@ export class User extends Carrier {
|
|
|
134
135
|
look() {
|
|
135
136
|
return {
|
|
136
137
|
name: (this.cells.name as string) || 'you',
|
|
137
|
-
order: ['whoami', 'push'],
|
|
138
|
-
asks: { whoami: { title: 'who am I', readOnly: true }, push: { title: 'wake a device' } },
|
|
138
|
+
order: ['whoami', 'push', 'name'],
|
|
139
|
+
asks: { whoami: { title: 'who am I', readOnly: true }, push: { title: 'wake a device' }, name: { title: 'call her' } },
|
|
139
140
|
};
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
// Her page for a device: her name, who this device is, the worlds she
|
|
143
|
-
// holds as sections, and the
|
|
144
|
-
// another device. `hello` and `report` are wiring, a
|
|
145
|
-
// and an agent's callback; they stay in her describe
|
|
146
|
-
// off her page, since a page is presentation and the
|
|
144
|
+
// holds as sections, and the two things a human does here by hand, waking
|
|
145
|
+
// another device and naming her. `hello` and `report` are wiring, a
|
|
146
|
+
// device's first word and an agent's callback; they stay in her describe
|
|
147
|
+
// under the gate and off her page, since a page is presentation and the
|
|
148
|
+
// gate is permission. A device that may not do a thing sees no form for
|
|
149
|
+
// it, however the page names it.
|
|
147
150
|
page() {
|
|
148
151
|
return {
|
|
149
152
|
kind: 'stack',
|
|
@@ -152,10 +155,21 @@ export class User extends Carrier {
|
|
|
152
155
|
{ kind: 'row', of: [{ kind: 'text', text: 'this device', role: 'label' }, { kind: 'answer', ask: 'whoami' }] },
|
|
153
156
|
{ kind: 'standings' },
|
|
154
157
|
{ kind: 'form', ask: 'push' },
|
|
158
|
+
{ kind: 'form', ask: 'name' },
|
|
155
159
|
],
|
|
156
160
|
};
|
|
157
161
|
}
|
|
158
162
|
|
|
163
|
+
// A device the root minted may say what she is called: the root trusts
|
|
164
|
+
// it with its id and its reach already, and a being does not know her own
|
|
165
|
+
// key. A name is a word; she keeps the last one given.
|
|
166
|
+
name(args: JsonObject) {
|
|
167
|
+
const name = typeof args.name === 'string' ? args.name.trim() : '';
|
|
168
|
+
if (!/^[\w.-]{1,80}$/.test(name)) return { error: 'a name is a word' };
|
|
169
|
+
this.cells.name = name;
|
|
170
|
+
return { named: name };
|
|
171
|
+
}
|
|
172
|
+
|
|
159
173
|
// A device that ran something for her says what it found. Kept, so that
|
|
160
174
|
// whoever renders her can show it; the ask itself is the callback.
|
|
161
175
|
report(args: JsonObject, asker: Asker) {
|
package/dist/beings/user.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Carrier } from './carry.ts';
|
|
|
3
3
|
export declare const DESK = "desk";
|
|
4
4
|
declare const isDesk: (occ: OccupantRecord | undefined) => boolean;
|
|
5
5
|
declare const isDevice: (occ: OccupantRecord | undefined) => boolean;
|
|
6
|
+
declare const rootMinted: (occ: OccupantRecord | undefined) => boolean;
|
|
6
7
|
declare const mayWake: (occ: OccupantRecord | undefined) => boolean;
|
|
7
8
|
export declare class User extends Carrier {
|
|
8
9
|
static carries(occ: OccupantRecord | undefined): boolean;
|
|
@@ -86,6 +87,19 @@ export declare class User extends Carrier {
|
|
|
86
87
|
};
|
|
87
88
|
for: typeof isDevice;
|
|
88
89
|
};
|
|
90
|
+
name: {
|
|
91
|
+
description: string;
|
|
92
|
+
input: {
|
|
93
|
+
type: string;
|
|
94
|
+
properties: {
|
|
95
|
+
name: {
|
|
96
|
+
type: string;
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
required: string[];
|
|
100
|
+
};
|
|
101
|
+
for: typeof rootMinted;
|
|
102
|
+
};
|
|
89
103
|
forget: {
|
|
90
104
|
description: string;
|
|
91
105
|
input: {
|
|
@@ -152,6 +166,9 @@ export declare class User extends Carrier {
|
|
|
152
166
|
push: {
|
|
153
167
|
title: string;
|
|
154
168
|
};
|
|
169
|
+
name: {
|
|
170
|
+
title: string;
|
|
171
|
+
};
|
|
155
172
|
};
|
|
156
173
|
};
|
|
157
174
|
page(): {
|
|
@@ -192,6 +209,13 @@ export declare class User extends Carrier {
|
|
|
192
209
|
ask: string;
|
|
193
210
|
})[];
|
|
194
211
|
};
|
|
212
|
+
name(args: JsonObject): {
|
|
213
|
+
error: string;
|
|
214
|
+
named?: undefined;
|
|
215
|
+
} | {
|
|
216
|
+
error?: undefined;
|
|
217
|
+
named: string;
|
|
218
|
+
};
|
|
195
219
|
report(args: JsonObject, asker: Asker): {
|
|
196
220
|
reported: boolean;
|
|
197
221
|
};
|
package/dist/beings/user.js
CHANGED
|
@@ -40,6 +40,7 @@ export class User extends Carrier {
|
|
|
40
40
|
chores: { description: 'what the agent may run', input: { type: 'object' }, for: (occ) => client(occ) === 'agent' },
|
|
41
41
|
look: { description: 'how she is shown', input: { type: 'object' }, for: isDevice },
|
|
42
42
|
page: { description: 'her page, as a tree of values', input: { type: 'object' }, for: isDevice },
|
|
43
|
+
name: { description: 'what she is called', input: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, for: rootMinted },
|
|
43
44
|
forget: { description: 'revoke a device: drop its way in and her way back to it, in one act', input: { type: 'object', properties: { client: { type: 'string' } }, required: ['client'] }, for: isDesk },
|
|
44
45
|
report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
|
|
45
46
|
};
|
|
@@ -134,15 +135,17 @@ export class User extends Carrier {
|
|
|
134
135
|
look() {
|
|
135
136
|
return {
|
|
136
137
|
name: this.cells.name || 'you',
|
|
137
|
-
order: ['whoami', 'push'],
|
|
138
|
-
asks: { whoami: { title: 'who am I', readOnly: true }, push: { title: 'wake a device' } },
|
|
138
|
+
order: ['whoami', 'push', 'name'],
|
|
139
|
+
asks: { whoami: { title: 'who am I', readOnly: true }, push: { title: 'wake a device' }, name: { title: 'call her' } },
|
|
139
140
|
};
|
|
140
141
|
}
|
|
141
142
|
// Her page for a device: her name, who this device is, the worlds she
|
|
142
|
-
// holds as sections, and the
|
|
143
|
-
// another device. `hello` and `report` are wiring, a
|
|
144
|
-
// and an agent's callback; they stay in her describe
|
|
145
|
-
// off her page, since a page is presentation and the
|
|
143
|
+
// holds as sections, and the two things a human does here by hand, waking
|
|
144
|
+
// another device and naming her. `hello` and `report` are wiring, a
|
|
145
|
+
// device's first word and an agent's callback; they stay in her describe
|
|
146
|
+
// under the gate and off her page, since a page is presentation and the
|
|
147
|
+
// gate is permission. A device that may not do a thing sees no form for
|
|
148
|
+
// it, however the page names it.
|
|
146
149
|
page() {
|
|
147
150
|
return {
|
|
148
151
|
kind: 'stack',
|
|
@@ -151,9 +154,20 @@ export class User extends Carrier {
|
|
|
151
154
|
{ kind: 'row', of: [{ kind: 'text', text: 'this device', role: 'label' }, { kind: 'answer', ask: 'whoami' }] },
|
|
152
155
|
{ kind: 'standings' },
|
|
153
156
|
{ kind: 'form', ask: 'push' },
|
|
157
|
+
{ kind: 'form', ask: 'name' },
|
|
154
158
|
],
|
|
155
159
|
};
|
|
156
160
|
}
|
|
161
|
+
// A device the root minted may say what she is called: the root trusts
|
|
162
|
+
// it with its id and its reach already, and a being does not know her own
|
|
163
|
+
// key. A name is a word; she keeps the last one given.
|
|
164
|
+
name(args) {
|
|
165
|
+
const name = typeof args.name === 'string' ? args.name.trim() : '';
|
|
166
|
+
if (!/^[\w.-]{1,80}$/.test(name))
|
|
167
|
+
return { error: 'a name is a word' };
|
|
168
|
+
this.cells.name = name;
|
|
169
|
+
return { named: name };
|
|
170
|
+
}
|
|
157
171
|
// A device that ran something for her says what it found. Kept, so that
|
|
158
172
|
// whoever renders her can show it; the ask itself is the callback.
|
|
159
173
|
report(args, asker) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
|
|
2
|
+
export declare class Native implements Store {
|
|
3
|
+
#private;
|
|
4
|
+
readonly harbor: string;
|
|
5
|
+
private constructor();
|
|
6
|
+
static open(harbor: string): Promise<Native>;
|
|
7
|
+
static wipe(harbor: string): Promise<void>;
|
|
8
|
+
list(): Promise<string[]>;
|
|
9
|
+
load(name: string): Promise<Kept | undefined>;
|
|
10
|
+
put(name: string, kept: Kept): Promise<void>;
|
|
11
|
+
save(name: string, partition: Record<string, unknown>): Promise<void>;
|
|
12
|
+
record(name: string, record: WardRecord): Promise<void>;
|
|
13
|
+
take(name: string): Promise<Kept | undefined>;
|
|
14
|
+
hints(): Promise<Record<string, string>>;
|
|
15
|
+
hint(pk: string, url: string): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
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 { sealKey, seal, open } from './seal.js';
|
|
20
|
+
const { hex, unhex } = arithmetic;
|
|
21
|
+
// Where the files live: the folder iCloud does not copy on iOS, the app's
|
|
22
|
+
// own files on Android, whose manifest says no backup.
|
|
23
|
+
const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
|
|
24
|
+
async function exists(path) {
|
|
25
|
+
try {
|
|
26
|
+
await Filesystem.stat({ path, directory });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export class Native {
|
|
34
|
+
harbor;
|
|
35
|
+
#key;
|
|
36
|
+
#queues = new Map();
|
|
37
|
+
constructor(harbor, key) {
|
|
38
|
+
this.harbor = harbor;
|
|
39
|
+
this.#key = key;
|
|
40
|
+
}
|
|
41
|
+
// Open one harbor's store, applying the custody rule, minting the key and
|
|
42
|
+
// the folder when this is a fresh install.
|
|
43
|
+
static async open(harbor) {
|
|
44
|
+
const item = `quo-${harbor}`;
|
|
45
|
+
const root = `quo/${harbor}`;
|
|
46
|
+
// `set` keeps JSON, so `get` parses it back; `getItem` would hand back the quotes.
|
|
47
|
+
const got = await SecureStorage.get(item, false, false);
|
|
48
|
+
let secret = typeof got === 'string' ? got : undefined;
|
|
49
|
+
const folder = await exists(root);
|
|
50
|
+
if (secret && !folder) {
|
|
51
|
+
await SecureStorage.remove(item);
|
|
52
|
+
secret = undefined;
|
|
53
|
+
}
|
|
54
|
+
if (!secret && folder)
|
|
55
|
+
await Filesystem.rmdir({ path: root, directory, recursive: true });
|
|
56
|
+
if (!secret) {
|
|
57
|
+
secret = hex(crypto.getRandomValues(new Uint8Array(32)));
|
|
58
|
+
await SecureStorage.set(item, secret, false, false, KeychainAccess.afterFirstUnlockThisDeviceOnly);
|
|
59
|
+
}
|
|
60
|
+
// mkdir refuses a folder that exists, recursive or not.
|
|
61
|
+
if (!(await exists(`${root}/wards`)))
|
|
62
|
+
await Filesystem.mkdir({ path: `${root}/wards`, directory, recursive: true });
|
|
63
|
+
return new Native(harbor, await sealKey(secret));
|
|
64
|
+
}
|
|
65
|
+
// Everything this harbor has, key and files: what a person does by hand
|
|
66
|
+
// to leave a device, and what a test does between two openings.
|
|
67
|
+
static async wipe(harbor) {
|
|
68
|
+
await SecureStorage.remove(`quo-${harbor}`);
|
|
69
|
+
if (await exists(`quo/${harbor}`))
|
|
70
|
+
await Filesystem.rmdir({ path: `quo/${harbor}`, directory, recursive: true });
|
|
71
|
+
}
|
|
72
|
+
#path(name) {
|
|
73
|
+
return `quo/${this.harbor}/wards/${name}.sealed`;
|
|
74
|
+
}
|
|
75
|
+
async #read(name) {
|
|
76
|
+
const { data } = await Filesystem.readFile({ path: this.#path(name), directory, encoding: Encoding.UTF8 });
|
|
77
|
+
return JSON.parse(new TextDecoder().decode(await open(this.#key, data.trim())));
|
|
78
|
+
}
|
|
79
|
+
// A sealed ward is rewritten whole, so the read sits inside the queue
|
|
80
|
+
// with the write: two changes to one ward never lose each other's part.
|
|
81
|
+
#keep(name, change) {
|
|
82
|
+
const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
|
|
83
|
+
const blob = change((await exists(this.#path(name))) ? await this.#read(name) : undefined);
|
|
84
|
+
const sealed = await seal(this.#key, new TextEncoder().encode(JSON.stringify(blob)));
|
|
85
|
+
await Filesystem.writeFile({ path: this.#path(name), directory, data: sealed + '\n', encoding: Encoding.UTF8 });
|
|
86
|
+
});
|
|
87
|
+
this.#queues.set(name, next.catch(() => { }));
|
|
88
|
+
return next;
|
|
89
|
+
}
|
|
90
|
+
async list() {
|
|
91
|
+
const { files } = await Filesystem.readdir({ path: `quo/${this.harbor}/wards`, directory });
|
|
92
|
+
return files.filter((f) => f.name.endsWith('.sealed')).map((f) => f.name.slice(0, -'.sealed'.length));
|
|
93
|
+
}
|
|
94
|
+
async load(name) {
|
|
95
|
+
if (!(await exists(this.#path(name))))
|
|
96
|
+
return undefined;
|
|
97
|
+
const b = await this.#read(name);
|
|
98
|
+
return { seed: unhex(b.seed), partition: b.partition, record: b.record };
|
|
99
|
+
}
|
|
100
|
+
async put(name, kept) {
|
|
101
|
+
if (await exists(this.#path(name)))
|
|
102
|
+
throw new Error(`ward ${name} already exists on this device`);
|
|
103
|
+
return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
|
|
104
|
+
}
|
|
105
|
+
async save(name, partition) {
|
|
106
|
+
if (!(await exists(this.#path(name))))
|
|
107
|
+
return; // a name not kept is nothing
|
|
108
|
+
return this.#keep(name, (b) => ({ ...b, partition }));
|
|
109
|
+
}
|
|
110
|
+
async record(name, record) {
|
|
111
|
+
if (!(await exists(this.#path(name))))
|
|
112
|
+
return;
|
|
113
|
+
return this.#keep(name, (b) => ({ ...b, record }));
|
|
114
|
+
}
|
|
115
|
+
async take(name) {
|
|
116
|
+
const kept = await this.load(name);
|
|
117
|
+
if (!kept)
|
|
118
|
+
return undefined;
|
|
119
|
+
await this.#queues.get(name);
|
|
120
|
+
await Filesystem.deleteFile({ path: this.#path(name), directory });
|
|
121
|
+
return kept;
|
|
122
|
+
}
|
|
123
|
+
async hints() {
|
|
124
|
+
const p = `quo/${this.harbor}/reach.json`;
|
|
125
|
+
if (!(await exists(p)))
|
|
126
|
+
return {};
|
|
127
|
+
const { data } = await Filesystem.readFile({ path: p, directory, encoding: Encoding.UTF8 });
|
|
128
|
+
return JSON.parse(data);
|
|
129
|
+
}
|
|
130
|
+
async hint(pk, url) {
|
|
131
|
+
const all = await this.hints();
|
|
132
|
+
all[pk] = url;
|
|
133
|
+
await Filesystem.writeFile({ path: `quo/${this.harbor}/reach.json`, directory, data: JSON.stringify(all) + '\n', encoding: Encoding.UTF8 });
|
|
134
|
+
}
|
|
135
|
+
}
|
package/dist/harbor/disk.js
CHANGED
|
@@ -17,7 +17,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
17
17
|
// <dir>/
|
|
18
18
|
// lease pid of the one process that holds this harbor
|
|
19
19
|
// classes/index.ts the default class source, a module exporting classes
|
|
20
|
-
//
|
|
20
|
+
// tab/index.ts the classes for the tab, served bundled by the web route: human/web.ts
|
|
21
|
+
// wards/<name>/ seed, partition.json, ward.json, or one sealed
|
|
22
|
+
// blob when the daemon holds QUO_SEED_KEY: see files.ts
|
|
21
23
|
// reach.json the directory's hints
|
|
22
24
|
import { mkdir, readFile, writeFile, unlink, stat } from 'node:fs/promises';
|
|
23
25
|
import { existsSync } from 'node:fs';
|
|
@@ -54,7 +56,10 @@ export class DiskHarbor extends Harbor {
|
|
|
54
56
|
#held = false;
|
|
55
57
|
constructor(dir) {
|
|
56
58
|
const abs = resolve(dir);
|
|
57
|
-
|
|
59
|
+
// The key the environment gives, the edge's name for it: from the
|
|
60
|
+
// Keychain through the app that spawned this daemon, or nothing on a
|
|
61
|
+
// droplet, whose folder stays plain under its file modes.
|
|
62
|
+
super(new Files(abs, process.env.QUO_SEED_KEY), async (rec) => ({ ...BUILT_IN, ...(await loadClasses(isAbsolute(rec.code) ? rec.code : join(abs, rec.code))) }));
|
|
58
63
|
this.dir = abs;
|
|
59
64
|
}
|
|
60
65
|
// Create a harbor folder with one ward, and the root's setup in it: the
|
|
@@ -64,7 +69,7 @@ export class DiskHarbor extends Harbor {
|
|
|
64
69
|
// has one.
|
|
65
70
|
static async init(dir, name = 'main', user = 'me') {
|
|
66
71
|
const h = new DiskHarbor(dir);
|
|
67
|
-
if (
|
|
72
|
+
if ((await h.store.list()).includes(name))
|
|
68
73
|
throw new Error(`ward ${name} already exists in ${h.dir}`);
|
|
69
74
|
await mkdir(join(h.dir, 'classes'), { recursive: true });
|
|
70
75
|
const classes = join(h.dir, DEFAULT_CODE);
|
package/dist/harbor/edge/edge.js
CHANGED
|
@@ -2,7 +2,8 @@ import { isSilence } from '@quo-systems/quo';
|
|
|
2
2
|
import { User, Desk, Avatar } from '../../beings/index.js';
|
|
3
3
|
import { setup } from '../../beings/setup.js';
|
|
4
4
|
import { Harbor, Socket as Held, SUITE } from '@quo-systems/quo/harbor';
|
|
5
|
-
import { DurableStorage
|
|
5
|
+
import { DurableStorage } from './storage.js';
|
|
6
|
+
import { sealKey } from '../seal.js';
|
|
6
7
|
export const BUILT_IN = { User, Desk, Avatar };
|
|
7
8
|
const json = (status, v, headers = {}) => new Response(JSON.stringify(v), { status, headers: { 'content-type': 'application/json', ...headers } });
|
|
8
9
|
const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, quo-suite' };
|
|
@@ -11,7 +11,8 @@ import { conform, conformStore, conformReach } from '@quo-systems/quo/conformanc
|
|
|
11
11
|
import { request as byRequest } from '@quo-systems/quo/harbor';
|
|
12
12
|
import { Printer, Shop, Customer, Echo, Member } from '@quo-systems/quo/conformance';
|
|
13
13
|
import { EdgeHarbor } from './edge.js';
|
|
14
|
-
import { DurableStorage
|
|
14
|
+
import { DurableStorage } from './storage.js';
|
|
15
|
+
import { sealKey } from '../seal.js';
|
|
15
16
|
const assert = {
|
|
16
17
|
equal(a, b, m) {
|
|
17
18
|
if (a !== b)
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { type Kept, type Store, type WardRecord } from '@quo-systems/quo/harbor';
|
|
2
2
|
import type { Storage } from './platform.d.ts';
|
|
3
|
-
export declare function sealKey(secret: string): Promise<CryptoKey>;
|
|
4
|
-
export declare function seal(key: CryptoKey, seed: Uint8Array): Promise<string>;
|
|
5
|
-
export declare function open(key: CryptoKey, sealed: string): Promise<Uint8Array>;
|
|
6
3
|
export declare class DurableStorage implements Store {
|
|
7
4
|
#private;
|
|
8
5
|
readonly storage: Storage;
|
|
@@ -2,32 +2,13 @@
|
|
|
2
2
|
// The store as Durable Object storage: one object per harbor, its storage
|
|
3
3
|
// holding one row per ward under `ward:<name>`, seed, partition and record,
|
|
4
4
|
// and one row per hint under `hint:<pk>`. The seed is kept sealed under a
|
|
5
|
-
// key from the platform's secrets, `QUO_SEED_KEY`,
|
|
6
|
-
//
|
|
7
|
-
// harbor document's table says the edge
|
|
8
|
-
// they do into a file, because the ward
|
|
9
|
-
// guard that structured clone refuses.
|
|
10
|
-
import { arithmetic } from '@quo-systems/quo/ward';
|
|
5
|
+
// key from the platform's secrets, `QUO_SEED_KEY`, with the seal in
|
|
6
|
+
// `../seal.ts`, so the storage holds ciphertext and the secret store holds
|
|
7
|
+
// the one key, which is what the harbor document's table says the edge
|
|
8
|
+
// does. Values cross as JSON, as they do into a file, because the ward
|
|
9
|
+
// hands the partition out through a guard that structured clone refuses.
|
|
11
10
|
import { values } from '@quo-systems/quo/harbor';
|
|
12
|
-
|
|
13
|
-
const plain = (b) => new Uint8Array(b);
|
|
14
|
-
const { hex, unhex } = arithmetic;
|
|
15
|
-
// The seal on a seed: AES-GCM under the platform key, a fresh nonce each
|
|
16
|
-
// time, nonce and ciphertext together as hex.
|
|
17
|
-
export async function sealKey(secret) {
|
|
18
|
-
if (!/^[0-9a-f]{64}$/.test(secret))
|
|
19
|
-
throw new Error('QUO_SEED_KEY is 32 bytes as hex');
|
|
20
|
-
return crypto.subtle.importKey('raw', plain(unhex(secret)), 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
21
|
-
}
|
|
22
|
-
export async function seal(key, seed) {
|
|
23
|
-
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
24
|
-
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain(seed)));
|
|
25
|
-
return hex(iv) + hex(ct);
|
|
26
|
-
}
|
|
27
|
-
export async function open(key, sealed) {
|
|
28
|
-
const b = plain(unhex(sealed));
|
|
29
|
-
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, key, b.subarray(12)));
|
|
30
|
-
}
|
|
11
|
+
import { seal, open } from '../seal.js';
|
|
31
12
|
// `prefix` keeps more than one harbor apart in one object's storage: the
|
|
32
13
|
// exercise does that, a deployment never does.
|
|
33
14
|
export class DurableStorage {
|
package/dist/harbor/files.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
|
|
|
2
2
|
export declare class Files implements Store {
|
|
3
3
|
#private;
|
|
4
4
|
readonly dir: string;
|
|
5
|
-
constructor(dir: string);
|
|
5
|
+
constructor(dir: string, key?: string);
|
|
6
|
+
get sealed(): boolean;
|
|
6
7
|
list(): Promise<string[]>;
|
|
7
8
|
load(name: string): Promise<Kept | undefined>;
|
|
8
9
|
put(name: string, kept: Kept): Promise<void>;
|
package/dist/harbor/files.js
CHANGED
|
@@ -1,35 +1,99 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The store as a folder on disk, one folder per ward
|
|
2
|
+
// The store as a folder on disk, one folder per ward, in one of two forms.
|
|
3
|
+
//
|
|
4
|
+
// Plain, when the store has no key, what the droplets run:
|
|
3
5
|
//
|
|
4
6
|
// <dir>/wards/<name>/seed 32 bytes, hex, mode 0600
|
|
5
7
|
// <dir>/wards/<name>/partition.json
|
|
6
8
|
// <dir>/wards/<name>/ward.json the ward record
|
|
9
|
+
//
|
|
10
|
+
// Sealed, when the store holds a key, what a device's daemon runs with the
|
|
11
|
+
// key from its Keychain:
|
|
12
|
+
//
|
|
13
|
+
// <dir>/wards/<name>/ward.sealed seed, partition and record as one
|
|
14
|
+
// JSON, sealed under the key, hex
|
|
15
|
+
//
|
|
16
|
+
// and in both:
|
|
17
|
+
//
|
|
7
18
|
// <dir>/reach.json the directory's hints
|
|
8
19
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
20
|
+
// A ward is whole in either form, and a store that meets the other form
|
|
21
|
+
// refuses it by name: a daemon started without its key, or with one against
|
|
22
|
+
// a plain folder, fails loudly instead of booting on what it cannot read.
|
|
23
|
+
// Sealing a plain folder is a deliberate command, never a boot's doing.
|
|
24
|
+
// Every write of a ward goes through a temp file and a rename, and writes
|
|
25
|
+
// are queued per ward so two calls never race on one file.
|
|
11
26
|
import { mkdir, readFile, writeFile, rename, readdir, rm, chmod } from 'node:fs/promises';
|
|
12
27
|
import { existsSync } from 'node:fs';
|
|
13
28
|
import { join } from 'node:path';
|
|
14
29
|
import { arithmetic } from '@quo-systems/quo/ward';
|
|
30
|
+
import { sealKey, seal, open } from './seal.js';
|
|
15
31
|
const { hex, unhex } = arithmetic;
|
|
16
32
|
export class Files {
|
|
17
33
|
dir;
|
|
34
|
+
#key;
|
|
18
35
|
#queues = new Map();
|
|
19
|
-
constructor(dir) {
|
|
36
|
+
constructor(dir, key) {
|
|
20
37
|
this.dir = dir;
|
|
38
|
+
if (key)
|
|
39
|
+
this.#key = sealKey(key);
|
|
21
40
|
}
|
|
22
41
|
#ward(name) {
|
|
23
42
|
return join(this.dir, 'wards', name);
|
|
24
43
|
}
|
|
44
|
+
get sealed() {
|
|
45
|
+
return this.#key !== undefined;
|
|
46
|
+
}
|
|
47
|
+
// The form a folder holds, checked against the form this store speaks.
|
|
48
|
+
#form(name) {
|
|
49
|
+
const wd = this.#ward(name);
|
|
50
|
+
const form = existsSync(join(wd, 'ward.sealed')) ? 'sealed' : existsSync(join(wd, 'seed')) ? 'plain' : undefined;
|
|
51
|
+
if (form === 'sealed' && !this.sealed)
|
|
52
|
+
throw new Error(`ward ${name} in ${this.dir} is sealed and this harbor has no key`);
|
|
53
|
+
if (form === 'plain' && this.sealed)
|
|
54
|
+
throw new Error(`ward ${name} in ${this.dir} is plain and this harbor holds a key`);
|
|
55
|
+
return form;
|
|
56
|
+
}
|
|
57
|
+
// One write at a time per ward, through a temp file and a rename.
|
|
58
|
+
#write(name, file, body) {
|
|
59
|
+
const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
|
|
60
|
+
const tmp = join(this.#ward(name), `${file}.tmp`);
|
|
61
|
+
await writeFile(tmp, body, { mode: 0o600 });
|
|
62
|
+
await rename(tmp, join(this.#ward(name), file));
|
|
63
|
+
});
|
|
64
|
+
this.#queues.set(name, next.catch(() => { }));
|
|
65
|
+
return next;
|
|
66
|
+
}
|
|
67
|
+
async #read(name) {
|
|
68
|
+
const blob = (await readFile(join(this.#ward(name), 'ward.sealed'), 'utf8')).trim();
|
|
69
|
+
return JSON.parse(new TextDecoder().decode(await open(await this.#key, blob)));
|
|
70
|
+
}
|
|
71
|
+
// A sealed ward is rewritten whole, so the read sits inside the queue
|
|
72
|
+
// with the write: two changes to one ward never lose each other's part.
|
|
73
|
+
#keep(name, change) {
|
|
74
|
+
const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
|
|
75
|
+
const wd = this.#ward(name);
|
|
76
|
+
const blob = change(existsSync(join(wd, 'ward.sealed')) ? await this.#read(name) : undefined);
|
|
77
|
+
const sealed = await seal(await this.#key, new TextEncoder().encode(JSON.stringify(blob)));
|
|
78
|
+
await writeFile(join(wd, 'ward.sealed.tmp'), sealed + '\n', { mode: 0o600 });
|
|
79
|
+
await rename(join(wd, 'ward.sealed.tmp'), join(wd, 'ward.sealed'));
|
|
80
|
+
});
|
|
81
|
+
this.#queues.set(name, next.catch(() => { }));
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
25
84
|
async list() {
|
|
26
85
|
const wards = join(this.dir, 'wards');
|
|
27
|
-
return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed'))) : [];
|
|
86
|
+
return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed')) || existsSync(join(wards, n, 'ward.sealed'))) : [];
|
|
28
87
|
}
|
|
29
88
|
async load(name) {
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
89
|
+
const form = this.#form(name);
|
|
90
|
+
if (!form)
|
|
32
91
|
return undefined;
|
|
92
|
+
if (form === 'sealed') {
|
|
93
|
+
const b = await this.#read(name);
|
|
94
|
+
return { seed: unhex(b.seed), partition: b.partition, record: b.record };
|
|
95
|
+
}
|
|
96
|
+
const wd = this.#ward(name);
|
|
33
97
|
return {
|
|
34
98
|
seed: unhex((await readFile(join(wd, 'seed'), 'utf8')).trim()),
|
|
35
99
|
partition: JSON.parse(await readFile(join(wd, 'partition.json'), 'utf8')),
|
|
@@ -37,31 +101,32 @@ export class Files {
|
|
|
37
101
|
};
|
|
38
102
|
}
|
|
39
103
|
async put(name, kept) {
|
|
40
|
-
|
|
41
|
-
if (existsSync(join(wd, 'seed')))
|
|
104
|
+
if (this.#form(name))
|
|
42
105
|
throw new Error(`ward ${name} already exists in ${this.dir}`);
|
|
106
|
+
const wd = this.#ward(name);
|
|
43
107
|
await mkdir(wd, { recursive: true, mode: 0o700 });
|
|
108
|
+
if (this.sealed)
|
|
109
|
+
return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
|
|
44
110
|
await writeFile(join(wd, 'seed'), hex(kept.seed), { mode: 0o600 });
|
|
45
111
|
await chmod(join(wd, 'seed'), 0o600);
|
|
46
112
|
await writeFile(join(wd, 'partition.json'), JSON.stringify(kept.partition) + '\n', { mode: 0o600 });
|
|
47
113
|
await writeFile(join(wd, 'ward.json'), JSON.stringify(kept.record, null, 2) + '\n', { mode: 0o600 });
|
|
48
114
|
}
|
|
49
|
-
save(name, partition) {
|
|
50
|
-
const
|
|
51
|
-
if (!
|
|
52
|
-
return
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
await rename(tmp, join(wd, 'partition.json'));
|
|
57
|
-
});
|
|
58
|
-
this.#queues.set(name, next.catch(() => { }));
|
|
59
|
-
return next;
|
|
115
|
+
async save(name, partition) {
|
|
116
|
+
const form = this.#form(name);
|
|
117
|
+
if (!form)
|
|
118
|
+
return; // a name not kept is nothing
|
|
119
|
+
if (form === 'sealed')
|
|
120
|
+
return this.#keep(name, (b) => ({ ...b, partition }));
|
|
121
|
+
return this.#write(name, 'partition.json', JSON.stringify(partition) + '\n');
|
|
60
122
|
}
|
|
61
123
|
async record(name, record) {
|
|
62
|
-
|
|
124
|
+
const form = this.#form(name);
|
|
125
|
+
if (!form)
|
|
63
126
|
return;
|
|
64
|
-
|
|
127
|
+
if (form === 'sealed')
|
|
128
|
+
return this.#keep(name, (b) => ({ ...b, record }));
|
|
129
|
+
return this.#write(name, 'ward.json', JSON.stringify(record, null, 2) + '\n');
|
|
65
130
|
}
|
|
66
131
|
async take(name) {
|
|
67
132
|
const kept = await this.load(name);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The seal: what a store does to a ward's bytes before they rest on a data
|
|
3
|
+
// store, under one key the secret store holds. One piece with three users,
|
|
4
|
+
// the edge under the platform's secret, the daemon under a key from its
|
|
5
|
+
// environment, the phone under a key from the Keychain. AES-GCM under a
|
|
6
|
+
// 32-byte key, a fresh nonce each time, nonce and ciphertext together as
|
|
7
|
+
// hex. WebCrypto only, so it runs wherever a store does.
|
|
8
|
+
import { arithmetic } from '@quo-systems/quo/ward';
|
|
9
|
+
const { hex, unhex } = arithmetic;
|
|
10
|
+
// Bytes as the platform's crypto wants them: over a plain ArrayBuffer.
|
|
11
|
+
const plain = (b) => new Uint8Array(b);
|
|
12
|
+
export async function sealKey(secret) {
|
|
13
|
+
if (!/^[0-9a-f]{64}$/.test(secret))
|
|
14
|
+
throw new Error('a seal key is 32 bytes as hex');
|
|
15
|
+
return crypto.subtle.importKey('raw', plain(unhex(secret)), 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
16
|
+
}
|
|
17
|
+
export async function seal(key, bytes) {
|
|
18
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
19
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain(bytes)));
|
|
20
|
+
return hex(iv) + hex(ct);
|
|
21
|
+
}
|
|
22
|
+
export async function open(key, sealed) {
|
|
23
|
+
const b = plain(unhex(sealed));
|
|
24
|
+
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, key, b.subarray(12)));
|
|
25
|
+
}
|
package/dist/human/html.js
CHANGED
|
@@ -207,5 +207,7 @@ export function page(m) {
|
|
|
207
207
|
const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown)}</aside>` : '';
|
|
208
208
|
const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
|
|
209
209
|
const mine = look(m.look);
|
|
210
|
-
|
|
210
|
+
// a page carries its own title, so the header keeps only the notice; a being painted as forms is headed by her name
|
|
211
|
+
const head = m.tree ? '' : `${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1>`;
|
|
212
|
+
return `<header${mine.style}>${head}<p class="notice">${escape(m.notice)}</p></header>${notes}<main${mine.style}>${asks}</main>${pushes}`;
|
|
211
213
|
}
|