@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.
Files changed (68) hide show
  1. package/beings/avatar.ts +8 -1
  2. package/beings/carry.ts +1 -1
  3. package/beings/quo-dock.md +53 -39
  4. package/beings/setup.ts +2 -1
  5. package/beings/user.ts +48 -1
  6. package/cli/daemon.ts +51 -25
  7. package/cli/estate/quo.service +0 -1
  8. package/dist/beings/avatar.js +9 -2
  9. package/dist/beings/carry.js +2 -2
  10. package/dist/beings/setup.js +2 -1
  11. package/dist/beings/user.d.ts +89 -0
  12. package/dist/beings/user.js +47 -1
  13. package/dist/cli/daemon.d.ts +10 -2
  14. package/dist/cli/daemon.js +51 -23
  15. package/dist/cli/estate/quo.service +0 -1
  16. package/dist/harbor/capacitor.d.ts +16 -0
  17. package/dist/harbor/capacitor.js +135 -0
  18. package/dist/harbor/disk.js +8 -3
  19. package/dist/harbor/edge/edge.js +2 -1
  20. package/dist/harbor/edge/exercise.js +2 -1
  21. package/dist/harbor/edge/storage.d.ts +0 -3
  22. package/dist/harbor/edge/storage.js +6 -25
  23. package/dist/harbor/files.d.ts +2 -1
  24. package/dist/harbor/files.js +87 -22
  25. package/dist/harbor/seal.d.ts +3 -0
  26. package/dist/harbor/seal.js +25 -0
  27. package/dist/human/door.d.ts +2 -0
  28. package/dist/human/door.js +19 -7
  29. package/dist/human/html.d.ts +2 -0
  30. package/dist/human/html.js +43 -17
  31. package/dist/human/local.d.ts +10 -0
  32. package/dist/human/local.js +10 -0
  33. package/dist/human/screen.js +26 -8
  34. package/dist/human/tab.d.ts +1 -0
  35. package/dist/human/tab.js +59 -3
  36. package/dist/human/tree.d.ts +40 -0
  37. package/dist/human/tree.js +89 -0
  38. package/dist/human/web.d.ts +1 -0
  39. package/dist/human/web.js +36 -8
  40. package/dist/mcp/oauth.js +2 -2
  41. package/dist/mcp/route.js +1 -1
  42. package/dist/mcp/runner.js +5 -2
  43. package/dist/mcp/server.js +1 -1
  44. package/dist/mcp/web/exchange.d.ts +5 -9
  45. package/dist/mcp/web/exchange.js +47 -98
  46. package/harbor/capacitor.ts +142 -0
  47. package/harbor/disk.ts +8 -3
  48. package/harbor/edge/edge.ts +2 -1
  49. package/harbor/edge/exercise.ts +2 -1
  50. package/harbor/edge/storage.ts +6 -25
  51. package/harbor/files.ts +79 -19
  52. package/harbor/quo-harbor.md +58 -15
  53. package/harbor/seal.ts +26 -0
  54. package/human/door.ts +20 -7
  55. package/human/html.ts +41 -17
  56. package/human/local.ts +26 -0
  57. package/human/quo-human.md +114 -19
  58. package/human/screen.ts +21 -7
  59. package/human/tab.ts +50 -5
  60. package/human/tree.ts +129 -0
  61. package/human/web.ts +36 -9
  62. package/mcp/oauth.ts +2 -2
  63. package/mcp/quo-mcp.md +27 -20
  64. package/mcp/route.ts +1 -1
  65. package/mcp/runner.ts +9 -6
  66. package/mcp/server.ts +1 -1
  67. package/mcp/web/exchange.ts +62 -96
  68. package/package.json +13 -2
@@ -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) {
@@ -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' | 'web', wake?: boolean, reach?: boolean): Promise<{
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
  }>;
@@ -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. Two proof kinds share
82
- // them: `local`, for a side that reached side.sock, and `web`, for a human
83
- // who passed the exchange pages. Both are the daemon vouching for someone it
84
- // saw itself; a proof of either kind arriving any other way names no nonce.
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
- Desk.verifiers.web = honour('web');
99
- // Admit a client identity into a ward: find or boot her avatar, and enter
100
- // her with a nonce the desk honours once. On a reconnect she already holds
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 with a public being, main first
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 ? admit(hosted, identity, 'web') : { error: 'no such world' };
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
- admit: async (identity, wake, reach, ward) => {
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 admit(hosted, identity, 'web', wake, reach);
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
+ }
@@ -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
- // wards/<name>/ seed, partition.json, ward.json: see files.ts
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
- super(new Files(abs), async (rec) => ({ ...BUILT_IN, ...(await loadClasses(isAbsolute(rec.code) ? rec.code : join(abs, rec.code))) }));
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 (existsSync(join(h.dir, 'wards', name, 'seed')))
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);
@@ -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, sealKey } from './storage.js';
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, sealKey } from './storage.js';
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`, so the storage holds
6
- // ciphertext and the secret store holds the one key, which is what the
7
- // harbor document's table says the edge does. Values cross as JSON, as
8
- // they do into a file, because the ward hands the partition out through a
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
- // Bytes as the platform's crypto wants them: over a plain ArrayBuffer.
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 {
@@ -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>;