@quo-systems/dock 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/beings/avatar.ts +8 -1
- package/beings/carry.ts +1 -1
- package/beings/quo-dock.md +53 -39
- package/beings/setup.ts +2 -1
- package/beings/user.ts +48 -1
- package/cli/daemon.ts +51 -25
- package/cli/estate/quo.service +0 -1
- package/dist/beings/avatar.js +9 -2
- package/dist/beings/carry.js +2 -2
- package/dist/beings/setup.js +2 -1
- package/dist/beings/user.d.ts +89 -0
- package/dist/beings/user.js +47 -1
- package/dist/cli/daemon.d.ts +10 -2
- package/dist/cli/daemon.js +51 -23
- package/dist/cli/estate/quo.service +0 -1
- package/dist/harbor/capacitor.d.ts +16 -0
- package/dist/harbor/capacitor.js +135 -0
- package/dist/harbor/disk.js +8 -3
- package/dist/harbor/edge/edge.js +2 -1
- package/dist/harbor/edge/exercise.js +2 -1
- package/dist/harbor/edge/storage.d.ts +0 -3
- package/dist/harbor/edge/storage.js +6 -25
- package/dist/harbor/files.d.ts +2 -1
- package/dist/harbor/files.js +87 -22
- package/dist/harbor/seal.d.ts +3 -0
- package/dist/harbor/seal.js +25 -0
- package/dist/human/door.d.ts +2 -0
- package/dist/human/door.js +19 -7
- package/dist/human/html.d.ts +2 -0
- package/dist/human/html.js +43 -17
- package/dist/human/local.d.ts +10 -0
- package/dist/human/local.js +10 -0
- package/dist/human/screen.js +26 -8
- package/dist/human/tab.d.ts +1 -0
- package/dist/human/tab.js +59 -3
- package/dist/human/tree.d.ts +40 -0
- package/dist/human/tree.js +89 -0
- package/dist/human/web.d.ts +1 -0
- package/dist/human/web.js +36 -8
- package/dist/mcp/oauth.js +2 -2
- package/dist/mcp/route.js +1 -1
- package/dist/mcp/runner.js +5 -2
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/web/exchange.d.ts +5 -9
- package/dist/mcp/web/exchange.js +47 -98
- package/harbor/capacitor.ts +142 -0
- package/harbor/disk.ts +8 -3
- package/harbor/edge/edge.ts +2 -1
- package/harbor/edge/exercise.ts +2 -1
- package/harbor/edge/storage.ts +6 -25
- package/harbor/files.ts +79 -19
- package/harbor/quo-harbor.md +58 -15
- package/harbor/seal.ts +26 -0
- package/human/door.ts +20 -7
- package/human/html.ts +41 -17
- package/human/local.ts +26 -0
- package/human/quo-human.md +114 -19
- package/human/screen.ts +21 -7
- package/human/tab.ts +50 -5
- package/human/tree.ts +129 -0
- package/human/web.ts +36 -9
- package/mcp/oauth.ts +2 -2
- package/mcp/quo-mcp.md +27 -20
- package/mcp/route.ts +1 -1
- package/mcp/runner.ts +9 -6
- package/mcp/server.ts +1 -1
- package/mcp/web/exchange.ts +62 -96
- package/package.json +13 -2
package/dist/beings/user.js
CHANGED
|
@@ -38,16 +38,24 @@ export class User extends Carrier {
|
|
|
38
38
|
device: { description: 'mint an invitation for a device', input: { type: 'object', properties: { client: { type: 'string' }, wake: { type: 'boolean' }, reach: { type: 'boolean' } }, required: ['client'] }, for: isDesk },
|
|
39
39
|
push: { description: 'push an object to a device: wake it with an event', input: { type: 'object', properties: { client: { type: 'string' }, object: { type: 'object' } }, required: ['client', 'object'] }, for: mayWake },
|
|
40
40
|
chores: { description: 'what the agent may run', input: { type: 'object' }, for: (occ) => client(occ) === 'agent' },
|
|
41
|
+
look: { description: 'how she is shown', input: { type: 'object' }, for: isDevice },
|
|
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 },
|
|
41
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 },
|
|
42
45
|
report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
|
|
43
46
|
};
|
|
44
47
|
// Anyone may say hello. A device that hands her an invitation in the args
|
|
45
48
|
// is taken as a standing under its own id, so she can push to it later;
|
|
46
|
-
// if it cannot be taken now, it is kept and tried at the next push.
|
|
49
|
+
// if it cannot be taken now, it is kept and tried at the next push. The
|
|
50
|
+
// desk's first hello names her: a being does not know her own key, and the
|
|
51
|
+
// root's setup knocks with the name it booted her under. Once named, she
|
|
52
|
+
// keeps it.
|
|
47
53
|
async hello(args, asker) {
|
|
48
54
|
if (asker.id !== undefined && args.invitation && typeof args.invitation === 'object' && !Array.isArray(args.invitation)) {
|
|
49
55
|
await this.wayBack(asker.id, args.invitation);
|
|
50
56
|
}
|
|
57
|
+
if (asker.id === DESK && !this.cells.name && typeof args.name === 'string' && /^[\w.-]{1,80}$/.test(args.name))
|
|
58
|
+
this.cells.name = args.name;
|
|
51
59
|
return { welcome: asker.id ?? null, name: this.cells.name };
|
|
52
60
|
}
|
|
53
61
|
// Take a device's way back, or keep it for later when the knock did not
|
|
@@ -122,6 +130,44 @@ export class User extends Carrier {
|
|
|
122
130
|
chores() {
|
|
123
131
|
return { chores: ['census', 'report'] };
|
|
124
132
|
}
|
|
133
|
+
// How a device shows her: her name as the title, and what each ask is
|
|
134
|
+
// called. Values only; the screen and the model side each read their part.
|
|
135
|
+
look() {
|
|
136
|
+
return {
|
|
137
|
+
name: this.cells.name || 'you',
|
|
138
|
+
order: ['whoami', 'push', 'name'],
|
|
139
|
+
asks: { whoami: { title: 'who am I', readOnly: true }, push: { title: 'wake a device' }, name: { title: 'call her' } },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// Her page for a device: her name, who this device is, the worlds she
|
|
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.
|
|
149
|
+
page() {
|
|
150
|
+
return {
|
|
151
|
+
kind: 'stack',
|
|
152
|
+
of: [
|
|
153
|
+
{ kind: 'text', text: this.cells.name || 'you', role: 'title' },
|
|
154
|
+
{ kind: 'row', of: [{ kind: 'text', text: 'this device', role: 'label' }, { kind: 'answer', ask: 'whoami' }] },
|
|
155
|
+
{ kind: 'standings' },
|
|
156
|
+
{ kind: 'form', ask: 'push' },
|
|
157
|
+
{ kind: 'form', ask: 'name' },
|
|
158
|
+
],
|
|
159
|
+
};
|
|
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
|
+
}
|
|
125
171
|
// A device that ran something for her says what it found. Kept, so that
|
|
126
172
|
// whoever renders her can show it; the ask itself is the callback.
|
|
127
173
|
report(args, asker) {
|
package/dist/cli/daemon.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Invitation } from '@quo-systems/quo';
|
|
1
2
|
import { DiskHarbor, type Hosted } from '../harbor/disk.ts';
|
|
2
3
|
import { type Avatar } from '../beings/index.ts';
|
|
3
4
|
import { type Agent } from '../mcp/agent.ts';
|
|
@@ -33,11 +34,18 @@ export type Options = {
|
|
|
33
34
|
routes?: Routes;
|
|
34
35
|
agents?: Agents;
|
|
35
36
|
dial?: string[];
|
|
36
|
-
password?: () => string | undefined;
|
|
37
37
|
};
|
|
38
38
|
export declare const sockPath: (dir: string) => string;
|
|
39
39
|
export declare const sidePath: (dir: string) => string;
|
|
40
|
-
export declare function admit(hosted: Hosted, identity: string, kind: 'local'
|
|
40
|
+
export declare function admit(hosted: Hosted, identity: string, kind: 'local', wake?: boolean, reach?: boolean): Promise<{
|
|
41
|
+
avatar?: Avatar;
|
|
42
|
+
error?: string;
|
|
43
|
+
}>;
|
|
44
|
+
export declare function letIn(hosted: Hosted, identity: string, invitation: Invitation): Promise<{
|
|
45
|
+
avatar?: Avatar;
|
|
46
|
+
error?: string;
|
|
47
|
+
}>;
|
|
48
|
+
export declare function find(hosted: Hosted, identity: string): Promise<{
|
|
41
49
|
avatar?: Avatar;
|
|
42
50
|
error?: string;
|
|
43
51
|
}>;
|
package/dist/cli/daemon.js
CHANGED
|
@@ -78,14 +78,11 @@ const socketPath = (dir, name) => {
|
|
|
78
78
|
};
|
|
79
79
|
export const sockPath = (dir) => socketPath(dir, 'quo');
|
|
80
80
|
export const sidePath = (dir) => socketPath(dir, 'side');
|
|
81
|
-
// The nonces this daemon minted, honoured once each
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
81
|
+
// The nonces this daemon minted, honoured once each, for the one proof kind
|
|
82
|
+
// there is: `local`, a side that reached side.sock. That is the daemon
|
|
83
|
+
// vouching for a process it saw itself, by the device's own rule, file
|
|
84
|
+
// permissions on the socket; a proof arriving any other way names no nonce.
|
|
85
85
|
const NONCES = new Map();
|
|
86
|
-
// A nonce is minted for one kind and honoured under that kind alone: a tab's
|
|
87
|
-
// nonce offered as a `local` proof names no nonce, so a proof of a kind still
|
|
88
|
-
// arrives only the one way that kind is made.
|
|
89
86
|
const honour = (kind) => (proof) => {
|
|
90
87
|
const who = typeof proof.nonce === 'string' ? NONCES.get(proof.nonce) : undefined;
|
|
91
88
|
if (!who || who.kind !== kind)
|
|
@@ -95,13 +92,9 @@ const honour = (kind) => (proof) => {
|
|
|
95
92
|
return rest;
|
|
96
93
|
};
|
|
97
94
|
Desk.verifiers.local = honour('local');
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
// `user` and nothing is minted, so `wake`, the human's word that this
|
|
102
|
-
// device may wake her others, is read the first time only; to change it,
|
|
103
|
-
// remove the occupant and allow again. The one path for every side.
|
|
104
|
-
export async function admit(hosted, identity, kind, wake = false, reach = false) {
|
|
95
|
+
// Her avatar in a ward, found or booted under the identity's key. An
|
|
96
|
+
// identity is a word, and never a being of the ward.
|
|
97
|
+
async function avatarOf(hosted, identity) {
|
|
105
98
|
if (!/^[\w.-]+$/.test(identity) || identity === hosted.record.user || identity === 'desk')
|
|
106
99
|
return { error: 'an identity is a word, and not a being of the ward' };
|
|
107
100
|
const key = `avatar:${identity}`;
|
|
@@ -112,13 +105,51 @@ export async function admit(hosted, identity, kind, wake = false, reach = false)
|
|
|
112
105
|
return { error: out.error };
|
|
113
106
|
avatar = hosted.being(key);
|
|
114
107
|
}
|
|
108
|
+
return { avatar };
|
|
109
|
+
}
|
|
110
|
+
// Admit a local client identity into a ward: find or boot her avatar, and
|
|
111
|
+
// enter her with a nonce the desk honours once. On a reconnect she already
|
|
112
|
+
// holds `user` and nothing is minted, so `wake` and `reach` are read the
|
|
113
|
+
// first time only; to change one, remove the occupant and admit again. The
|
|
114
|
+
// one path for a side on the device: agents, and sides on side.sock.
|
|
115
|
+
export async function admit(hosted, identity, kind, wake = false, reach = false) {
|
|
116
|
+
const found = await avatarOf(hosted, identity);
|
|
117
|
+
if (!found.avatar)
|
|
118
|
+
return found;
|
|
115
119
|
const nonce = randomBytes(16).toString('hex');
|
|
116
120
|
NONCES.set(nonce, { kind, user: hosted.record.user, client: identity, ...(wake ? { wake: true } : {}), ...(reach ? { reach: true } : {}) });
|
|
117
|
-
const entered = await avatar.enter({ ward: hosted.pk }, { kind, nonce });
|
|
121
|
+
const entered = await found.avatar.enter({ ward: hosted.pk }, { kind, nonce });
|
|
118
122
|
NONCES.delete(nonce);
|
|
119
123
|
await hosted.save(); // the knock went through the ward's own door, which the harbor never sees
|
|
120
124
|
if ('error' in entered && !('asks' in entered))
|
|
121
125
|
return { error: entered.error };
|
|
126
|
+
return { avatar: found.avatar };
|
|
127
|
+
}
|
|
128
|
+
// Join a client identity to a ward with an invitation the root minted for
|
|
129
|
+
// it: find or boot her avatar, and she knocks. The one path for an identity
|
|
130
|
+
// from elsewhere, the exchange page. Nothing is typed to get in but the
|
|
131
|
+
// invitation, and the knock spends it.
|
|
132
|
+
export async function letIn(hosted, identity, invitation) {
|
|
133
|
+
const found = await avatarOf(hosted, identity);
|
|
134
|
+
if (!found.avatar)
|
|
135
|
+
return found;
|
|
136
|
+
const joined = await found.avatar.join(invitation);
|
|
137
|
+
await hosted.save();
|
|
138
|
+
if ('error' in joined && !('asks' in joined))
|
|
139
|
+
return { error: joined.error };
|
|
140
|
+
return { avatar: found.avatar };
|
|
141
|
+
}
|
|
142
|
+
// Her avatar as she stands, for a session opening under a grant: she is in
|
|
143
|
+
// or she is not, and nothing is minted here. An identity the user being
|
|
144
|
+
// removed hears `removed` on her describe, which the endpoint speaks as 401.
|
|
145
|
+
export async function find(hosted, identity) {
|
|
146
|
+
const avatar = hosted.being(`avatar:${identity}`);
|
|
147
|
+
if (!avatar)
|
|
148
|
+
return { error: 'not joined' };
|
|
149
|
+
const bp = await avatar.tools();
|
|
150
|
+
await hosted.save();
|
|
151
|
+
if ('error' in bp && !('asks' in bp))
|
|
152
|
+
return { error: bp.error };
|
|
122
153
|
return { avatar };
|
|
123
154
|
}
|
|
124
155
|
async function listen(path, onConnection) {
|
|
@@ -157,20 +188,18 @@ export async function serve(dir, options = {}) {
|
|
|
157
188
|
http = door;
|
|
158
189
|
http.mount('/quo', quoRoute(harbor, server, quo));
|
|
159
190
|
const routes = options.routes ?? (await readRoutes(harbor.dir));
|
|
160
|
-
const password = options.password ?? (() => process.env.QUO_OWNER_PASSWORD);
|
|
161
191
|
const here = `http://${http.host}:${http.port}`;
|
|
162
192
|
// the worlds' pages: always, on the daemon's own door when no route names a public one
|
|
163
193
|
const web = webRoute(harbor, { at: { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` } });
|
|
164
194
|
if (routes) {
|
|
165
|
-
// the worlds a client may be allowed into: every ward
|
|
195
|
+
// the worlds a client may be allowed into: every ward here, by name and pk, main first
|
|
166
196
|
const worlds = () => [...harbor.wards]
|
|
167
|
-
.filter(([, h]) => h.partition.public !== null)
|
|
168
197
|
.sort(([a], [b]) => (a === 'main' ? -1 : b === 'main' ? 1 : a.localeCompare(b)))
|
|
169
|
-
.map(([ward, h]) => ({ ward, user: h.record.user }));
|
|
198
|
+
.map(([ward, h]) => ({ ward, pk: h.pk, user: h.record.user }));
|
|
170
199
|
// the MCP endpoint: a bearer names an identity in a world, the identity names her avatar there, the side runs beside her
|
|
171
200
|
mcp = new McpHttp(async (identity, ward) => {
|
|
172
201
|
const hosted = harbor.wards.get(ward);
|
|
173
|
-
return hosted ?
|
|
202
|
+
return hosted ? find(hosted, identity) : { error: 'no such world' };
|
|
174
203
|
}, async () => {
|
|
175
204
|
for (const h of harbor.wards.values())
|
|
176
205
|
await h.save();
|
|
@@ -181,13 +210,12 @@ export async function serve(dir, options = {}) {
|
|
|
181
210
|
mcp.gone = (identity, ward) => oauth.revoke(identity, ward); // removal at the ward ends the grant at the route
|
|
182
211
|
exchange = new Exchange({
|
|
183
212
|
oauth,
|
|
184
|
-
password,
|
|
185
213
|
worlds,
|
|
186
|
-
|
|
214
|
+
join: async (identity, invitation, ward) => {
|
|
187
215
|
const hosted = harbor.wards.get(ward);
|
|
188
216
|
if (!hosted)
|
|
189
217
|
return { error: 'no such world' };
|
|
190
|
-
const r = await
|
|
218
|
+
const r = await letIn(hosted, identity, invitation);
|
|
191
219
|
return r.error ? { error: r.error } : {};
|
|
192
220
|
},
|
|
193
221
|
});
|
|
@@ -14,7 +14,6 @@ User=quo
|
|
|
14
14
|
Group=quo
|
|
15
15
|
Environment=QUO_DIR=/home/quo/.quo
|
|
16
16
|
Environment=QUO_HTTP=8787
|
|
17
|
-
# QUO_OWNER_PASSWORD, root-only. Without it the exchange pages are closed.
|
|
18
17
|
# CLAUDE_CODE_OAUTH_TOKEN, when an agent folder runs `claude -p`: the agent
|
|
19
18
|
# reads its credential from the device, never from cells.
|
|
20
19
|
EnvironmentFile=-/etc/quo/env
|
|
@@ -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>;
|