@quo-systems/dock 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/beings/carry.ts +7 -3
  2. package/beings/desk.ts +5 -1
  3. package/beings/look.ts +12 -3
  4. package/beings/quo-dock.md +22 -13
  5. package/beings/user.ts +20 -6
  6. package/dist/beings/carry.js +10 -4
  7. package/dist/beings/desk.d.ts +0 -1
  8. package/dist/beings/desk.js +5 -1
  9. package/dist/beings/look.d.ts +2 -0
  10. package/dist/beings/look.js +9 -0
  11. package/dist/beings/user.d.ts +24 -0
  12. package/dist/beings/user.js +20 -6
  13. package/dist/harbor/browser.d.ts +4 -4
  14. package/dist/harbor/browser.js +18 -9
  15. package/dist/harbor/capacitor.d.ts +19 -0
  16. package/dist/harbor/capacitor.js +151 -0
  17. package/dist/harbor/disk.js +8 -3
  18. package/dist/harbor/edge/edge.js +2 -1
  19. package/dist/harbor/edge/exercise.js +2 -1
  20. package/dist/harbor/edge/storage.d.ts +0 -3
  21. package/dist/harbor/edge/storage.js +6 -25
  22. package/dist/harbor/files.d.ts +2 -1
  23. package/dist/harbor/files.js +87 -22
  24. package/dist/harbor/seal.d.ts +3 -0
  25. package/dist/harbor/seal.js +25 -0
  26. package/dist/human/html.d.ts +1 -0
  27. package/dist/human/html.js +40 -19
  28. package/dist/human/local.d.ts +10 -0
  29. package/dist/human/local.js +10 -0
  30. package/dist/human/screen.js +32 -18
  31. package/dist/human/tab.d.ts +1 -0
  32. package/dist/human/tab.js +59 -3
  33. package/dist/human/web.d.ts +1 -0
  34. package/dist/human/web.js +34 -4
  35. package/dist/mcp/runner.js +5 -3
  36. package/dist/mcp/server.js +5 -3
  37. package/harbor/browser.ts +28 -17
  38. package/harbor/capacitor.ts +159 -0
  39. package/harbor/disk.ts +8 -3
  40. package/harbor/edge/edge.ts +2 -1
  41. package/harbor/edge/exercise.ts +2 -1
  42. package/harbor/edge/storage.ts +6 -25
  43. package/harbor/files.ts +79 -19
  44. package/harbor/quo-harbor.md +66 -14
  45. package/harbor/seal.ts +26 -0
  46. package/human/html.ts +39 -18
  47. package/human/local.ts +26 -0
  48. package/human/quo-human.md +70 -13
  49. package/human/screen.ts +27 -15
  50. package/human/tab.ts +50 -5
  51. package/human/web.ts +34 -5
  52. package/mcp/runner.ts +5 -3
  53. package/mcp/server.ts +5 -3
  54. package/package.json +14 -2
@@ -6,4 +6,5 @@ export type WebOptions = {
6
6
  web: string;
7
7
  };
8
8
  };
9
+ export declare const TAB_CODE = "tab/index.ts";
9
10
  export declare function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessage, res: ServerResponse, rest: string) => Promise<boolean>;
package/dist/human/web.js CHANGED
@@ -1,16 +1,30 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
+ import { join } from 'node:path';
4
+ import process from 'node:process';
3
5
  import { build } from 'esbuild';
4
6
  import { door } from './door.js';
5
7
  // The paths under `/web` that are the exchange's, never a ward's name.
6
- const RESERVED_PATHS = new Set(['allow', 'tab.js']);
8
+ const RESERVED_PATHS = new Set(['allow', 'tab.js', 'beings.js']);
9
+ // The world's code for the tab: a module in the harbor folder, the twin of
10
+ // `classes/index.ts`, exporting by name the classes the tab boots into its
11
+ // local ward for a world of this harbor. Served bundled as `beings.js` on
12
+ // this origin, and one origin is one world's code.
13
+ export const TAB_CODE = 'tab/index.ts';
7
14
  export function webRoute(harbor, o) {
8
15
  const tabEntry = () => {
9
16
  const js = fileURLToPath(new URL('./tab.js', import.meta.url));
10
17
  return existsSync(js) ? js : fileURLToPath(new URL('./tab.ts', import.meta.url));
11
18
  };
19
+ const beingsEntry = join(harbor.dir, TAB_CODE);
20
+ const bundled = (entry) =>
21
+ // the entry may sit in a harbor folder with no node_modules of its own, as on a droplet whose
22
+ // estate folder is the working directory: what it imports is resolved from there too
23
+ build({ entryPoints: [entry], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false, nodePaths: [join(process.cwd(), 'node_modules')] }).then((out) => out.outputFiles[0].text);
12
24
  let bundle;
13
- const built = () => (bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((out) => out.outputFiles[0].text));
25
+ const built = () => (bundle ??= bundled(tabEntry()));
26
+ let beings;
27
+ const builtBeings = () => (beings ??= bundled(beingsEntry));
14
28
  const quoOrigin = (() => {
15
29
  try {
16
30
  const u = new URL(o.at.quo);
@@ -41,7 +55,7 @@ export function webRoute(harbor, o) {
41
55
  };
42
56
  const wards = () => Object.fromEntries([...harbor.wards].map(([n, h]) => [n, { pk: h.pk, public: publicOf(h) !== null }]));
43
57
  const tab = (ward) => {
44
- const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), ...(ward ? { ward } : {}) };
58
+ const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), beings: existsSync(beingsEntry), ...(ward ? { ward } : {}) };
45
59
  return `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
46
60
  };
47
61
  return async (req, res, rest) => {
@@ -58,6 +72,14 @@ export function webRoute(harbor, o) {
58
72
  res.end(await built());
59
73
  return true;
60
74
  }
75
+ // the world's code for the tab, when the harbor folder holds any; a harbor with none has no such path
76
+ if (rest === '/beings.js' && req.method === 'GET') {
77
+ if (!existsSync(beingsEntry))
78
+ return false;
79
+ res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
80
+ res.end(await builtBeings());
81
+ return true;
82
+ }
61
83
  const wardName = parts[0] ?? '';
62
84
  if (!wardName || RESERVED_PATHS.has(wardName))
63
85
  return false;
@@ -76,18 +98,26 @@ const CSS = [
76
98
  ':root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px;--ink:#1c1b22;--paper:#fbfaf7;--mute:#6b6a73}',
77
99
  '@media(prefers-color-scheme:dark){:root{--ink:#ecebe6;--paper:#141318;--mute:#9a99a2}}',
78
100
  'html{background:var(--paper);color:var(--ink)}',
79
- 'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}',
101
+ // one column that is a phone first and a desk after: the same page, more air
102
+ 'body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:1rem auto;padding:0 1rem}@media(min-width:40rem){body{margin:2rem auto}}',
103
+ 'input:not([type=checkbox]),select,textarea{width:100%;max-width:24rem;box-sizing:border-box}',
80
104
  'nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}',
81
105
  'nav.relations{display:flex;flex-wrap:wrap;gap:.25rem;margin:.5rem 0}nav.relations button{background:transparent;color:inherit;border:1px solid color-mix(in srgb,currentColor 30%,transparent)}nav.relations button[aria-current]{border-color:var(--accent);font-weight:600}',
82
106
  'header,main{background:var(--bg);color:var(--fg);font-family:var(--font)}header{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}header .notice{width:100%;margin:0}',
83
107
  'input,select,textarea,button{font:inherit;padding:.4rem;margin:.2rem;border-radius:var(--radius)}button{background:var(--accent);color:#fff;border:0;padding:.4rem .9rem}',
84
108
  'fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}',
109
+ // a being of the world running in the tab: her own section under the far page
110
+ 'section.local{border-top:1px solid color-mix(in srgb,currentColor 15%,transparent);margin:1.5rem 0;padding-top:.5rem}section.local header:empty{display:none}',
85
111
  'section.standing{background:var(--bg);color:var(--fg);font-family:var(--font);border-left:4px solid var(--accent);border-radius:var(--radius);padding:.25rem 1rem;margin:1.5rem 0}section.standing h2{display:flex;align-items:center;gap:.5rem;font-size:1.1rem}img.logo{height:1.6rem}',
86
112
  'table{border-collapse:collapse}td,th{padding:.15rem .5rem;text-align:left}.answer{margin:.5rem 0 1rem;padding:.5rem;border-left:3px solid var(--accent)}.answer.error{border-color:#c33}.answer.silence,.answer.word,.answer.unreached{border-color:#c93}pre{padding:.75rem;overflow:auto}',
87
113
  // a page in the grammar: regions, roles, cards
88
114
  '.page .stack{display:flex;flex-direction:column;gap:.5rem}.page .row{display:flex;flex-wrap:wrap;gap:.5rem 1rem;align-items:baseline}',
89
115
  '.page .t-title{font-size:2rem;font-weight:600;letter-spacing:-.02em;margin:.5rem 0}.page .t-lead{font-size:1.25rem;margin:.25rem 0}.page .t-label{font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;color:var(--mute);margin:0}.page .t-quiet{color:var(--mute);font-size:.9rem}.page .t-body{margin:.25rem 0}',
90
116
  '.page .picture{max-height:6rem}.cards{display:flex;flex-wrap:wrap;gap:.75rem}.card{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);padding:.5rem .75rem}',
117
+ // a row with a picture is a line about someone or something: the picture small and the words beside it, centred
118
+ '.page .row:has(>.picture){align-items:center;flex-wrap:nowrap}.page .row>.picture{max-height:2.75rem;flex:none}.page .row>.stack{gap:0;min-width:0}.page .row>.stack>p{margin:0}',
119
+ // a far page inside a standing's section: her title is a section's, not the page's
120
+ 'section.standing .page{padding:.5rem 0}section.standing .page .t-title{font-size:1.3rem;margin:.25rem 0}',
91
121
  // the door page: one column, generous air, the mark, a word, a sentence
92
122
  'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
93
123
  'main.door .picture{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
@@ -1,13 +1,15 @@
1
1
  import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
2
+ import { presentation } from '../beings/look.js';
2
3
  export const TURNS = 10;
3
4
  // Her describe, spoken as a tools array. Name, description and input are
4
5
  // verbatim, with the one narrowing an endpoint has asked for: an ask that
5
6
  // declares no properties is sent with an empty `properties`, because
6
- // LM Studio refuses a parameters schema without one. `look` and `page` are
7
- // presentation, for a screen, and are not functions.
7
+ // LM Studio refuses a parameters schema without one. Her look, her page and
8
+ // every carried page are presentation, for a screen, and are not functions.
8
9
  export function tools(bp) {
10
+ const shown = presentation(bp);
9
11
  return bp.asks
10
- .filter((a) => a.name !== 'look' && a.name !== 'page')
12
+ .filter((a) => !shown.has(a.name))
11
13
  .map((a) => {
12
14
  const t = { type: 'function', function: { name: a.name, parameters: { properties: {}, ...a.input, type: 'object' } } };
13
15
  if (a.description !== undefined)
@@ -11,7 +11,7 @@
11
11
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
12
12
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
13
13
  import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
14
- import { hintFor, sanitise } from '../beings/look.js';
14
+ import { hintFor, presentation, sanitise } from '../beings/look.js';
15
15
  import { isSilence, isWord, digest } from '@quo-systems/quo';
16
16
  export const NAME = 'quo';
17
17
  export const VERSION = '0.0.0';
@@ -20,10 +20,12 @@ export const DESCRIBE = { name: 'describe', description: 'the empty ask: her des
20
20
  // Her describe, spoken as tools, the empty ask first. Name, description and
21
21
  // input are verbatim; an output schema crosses when she declared one. Her
22
22
  // look, when she has one, is the hints: a title, and the annotations a host
23
- // reads. The `look` ask itself is presentation, and is not a tool.
23
+ // reads. Her look, her page and every carried page are presentation, for a
24
+ // screen, and are not tools.
24
25
  export function tools(bp, look = {}) {
26
+ const shown = presentation(bp);
25
27
  const asks = bp.asks
26
- .filter((a) => a.name !== 'look' && a.name !== 'page') // presentation, for a screen, and not a tool
28
+ .filter((a) => !shown.has(a.name))
27
29
  .map((a) => {
28
30
  const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
29
31
  if (a.description !== undefined)
package/harbor/browser.ts CHANGED
@@ -1,33 +1,38 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The browser harbor: the core over IndexedDB, in a tab. A dialer and never
3
- // a listener: it holds one socket per world it is connected to, and every
4
- // ask to a pk it does not hold goes down a socket to the listener, which is
5
- // the rendezvous. The lease is a web lock on the database name, held for
6
- // the life of the tab: a second tab on one origin meets the lock and is a
7
- // screen, not a harbor. The classes are the estate's beings, handed in
8
- // with the bundle, plus whatever a page hands in beside them.
2
+ // The browser harbor: the core over a store, in a webview. A dialer and
3
+ // never a listener: it holds one socket per world it is connected to, and
4
+ // every ask to a pk it does not hold goes down a socket to the listener,
5
+ // which is the rendezvous. In a tab the store is IndexedDB under a name,
6
+ // and the lease is a web lock on that name, held for the life of the tab:
7
+ // a second tab on one origin meets the lock and is a screen, not a harbor.
8
+ // In an app the store is handed in, and the process that holds it is the
9
+ // lease: an app has one harbor and nothing else can open its files. The
10
+ // classes are the estate's beings, handed in with the bundle, plus whatever
11
+ // a page hands in beside them.
9
12
  import type { BeingClass } from '@quo-systems/quo';
10
13
  import { User, Desk, Avatar } from '../beings/index.ts';
11
- import { Harbor, dial, type Dialer } from '@quo-systems/quo/harbor';
14
+ import { Harbor, dial, type Dialer, type Store } from '@quo-systems/quo/harbor';
12
15
  import { Idb } from './idb.ts';
13
16
 
14
17
  export const BUILT_IN: Record<string, BeingClass> = { User, Desk, Avatar };
15
18
 
16
19
  export class BrowserHarbor extends Harbor {
17
- readonly db: Idb;
20
+ readonly name: string;
18
21
  readonly dialers: Dialer[] = [];
22
+ readonly #locked: boolean;
19
23
  #release: (() => void) | undefined;
20
24
 
21
- constructor(name = 'quo', classes: Record<string, BeingClass> = {}) {
22
- const db = new Idb(name);
23
- super(db, async () => ({ ...BUILT_IN, ...classes }));
24
- this.db = db;
25
+ constructor(store: string | Store = 'quo', classes: Record<string, BeingClass> = {}) {
26
+ const s = typeof store === 'string' ? new Idb(store) : store;
27
+ super(s, async () => ({ ...BUILT_IN, ...classes }));
28
+ this.name = typeof store === 'string' ? store : 'quo';
29
+ this.#locked = typeof store === 'string';
25
30
  }
26
31
 
27
32
  // Take the lock and boot every ward kept here. A lock already held is
28
33
  // another tab's harbor over this seed, and this one refuses to boot.
29
34
  override async boot(): Promise<void> {
30
- await this.#lease();
35
+ if (this.#locked) await this.#lease();
31
36
  await super.boot();
32
37
  }
33
38
 
@@ -38,9 +43,15 @@ export class BrowserHarbor extends Harbor {
38
43
  return d;
39
44
  }
40
45
 
46
+ // The device is back: a phone out of a pocket, an app to the foreground.
47
+ // Every socket is told, and one that died silently is dialed again.
48
+ wake(): void {
49
+ for (const d of this.dialers) d.wake();
50
+ }
51
+
41
52
  async close(): Promise<void> {
42
53
  for (const d of this.dialers.splice(0)) d.close();
43
- this.db.close();
54
+ (this.store as { close?(): void }).close?.();
44
55
  this.#release?.();
45
56
  this.#release = undefined;
46
57
  }
@@ -49,7 +60,7 @@ export class BrowserHarbor extends Harbor {
49
60
  const locks = (globalThis.navigator as Navigator | undefined)?.locks;
50
61
  if (!locks) return; // no web locks on this terrain: nothing vouches, as in the memory harbor
51
62
  const granted = await new Promise<boolean>((ok) => {
52
- void locks.request(`quo:${this.db.name}`, { ifAvailable: true }, (lock) => {
63
+ void locks.request(`quo:${this.name}`, { ifAvailable: true }, (lock) => {
53
64
  if (!lock) {
54
65
  ok(false);
55
66
  return;
@@ -58,6 +69,6 @@ export class BrowserHarbor extends Harbor {
58
69
  return new Promise<void>((release) => (this.#release = release)); // held until close
59
70
  });
60
71
  });
61
- if (!granted) throw new Error(`harbor ${this.db.name} is held by another tab`);
72
+ if (!granted) throw new Error(`harbor ${this.name} is held by another tab`);
62
73
  }
63
74
  }
@@ -0,0 +1,159 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The store on a phone: the wrapped form over Capacitor's Filesystem and
3
+ // a secure-storage plugin. One key per harbor in the Keychain, this device
4
+ // only, so it never travels in a backup; one sealed file per ward, seed,
5
+ // partition and record as one blob, in the folder iCloud does not copy.
6
+ //
7
+ // <Library/NoCloud>/quo/<harbor>/wards/<name>.sealed
8
+ // <Library/NoCloud>/quo/<harbor>/reach.json
9
+ // Keychain: quo-<harbor> 32 bytes as hex
10
+ //
11
+ // Custody is the backup question, and opening the store answers it: a key
12
+ // with no folder is a reinstall, and the stale key is deleted; a folder
13
+ // with no key is a restore to another device, and the unreadable files are
14
+ // deleted. Either way the harbor starts fresh and there is never a twin.
15
+ //
16
+ // The harbor on a phone is the browser harbor over this store: the app
17
+ // process is its lease, and the App plugin's foreground is its wake.
18
+ import { Capacitor } from '@capacitor/core';
19
+ import { App } from '@capacitor/app';
20
+ import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
21
+ import { SecureStorage, KeychainAccess } from '@aparajita/capacitor-secure-storage';
22
+ import type { BeingClass } from '@quo-systems/quo';
23
+ import { arithmetic } from '@quo-systems/quo/ward';
24
+ import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
25
+ import { BrowserHarbor } from './browser.ts';
26
+ import { sealKey, seal, open } from './seal.ts';
27
+
28
+ // The app's one harbor, booted, told `wake` every time the app comes to
29
+ // the foreground, since a phone asleep loses its sockets silently.
30
+ export async function nativeHarbor(name = 'quo', classes: Record<string, BeingClass> = {}): Promise<BrowserHarbor> {
31
+ const h = new BrowserHarbor(await Native.open(name), classes);
32
+ await h.boot();
33
+ await App.addListener('appStateChange', ({ isActive }) => {
34
+ if (isActive) h.wake();
35
+ });
36
+ return h;
37
+ }
38
+
39
+ const { hex, unhex } = arithmetic;
40
+ type Blob = { seed: string; partition: Record<string, unknown>; record: WardRecord };
41
+
42
+ // Where the files live: the folder iCloud does not copy on iOS, the app's
43
+ // own files on Android, whose manifest says no backup.
44
+ const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
45
+
46
+ async function exists(path: string): Promise<boolean> {
47
+ try {
48
+ await Filesystem.stat({ path, directory });
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ export class Native implements Store {
56
+ readonly harbor: string;
57
+ readonly #key: CryptoKey;
58
+ readonly #queues = new Map<string, Promise<void>>();
59
+ private constructor(harbor: string, key: CryptoKey) {
60
+ this.harbor = harbor;
61
+ this.#key = key;
62
+ }
63
+
64
+ // Open one harbor's store, applying the custody rule, minting the key and
65
+ // the folder when this is a fresh install.
66
+ static async open(harbor: string): Promise<Native> {
67
+ const item = `quo-${harbor}`;
68
+ const root = `quo/${harbor}`;
69
+ // `set` keeps JSON, so `get` parses it back; `getItem` would hand back the quotes.
70
+ const got = await SecureStorage.get(item, false, false);
71
+ let secret = typeof got === 'string' ? got : undefined;
72
+ const folder = await exists(root);
73
+ if (secret && !folder) {
74
+ await SecureStorage.remove(item);
75
+ secret = undefined;
76
+ }
77
+ if (!secret && folder) await Filesystem.rmdir({ path: root, directory, recursive: true });
78
+ if (!secret) {
79
+ secret = hex(crypto.getRandomValues(new Uint8Array(32)));
80
+ await SecureStorage.set(item, secret, false, false, KeychainAccess.afterFirstUnlockThisDeviceOnly);
81
+ }
82
+ // mkdir refuses a folder that exists, recursive or not.
83
+ if (!(await exists(`${root}/wards`))) await Filesystem.mkdir({ path: `${root}/wards`, directory, recursive: true });
84
+ return new Native(harbor, await sealKey(secret));
85
+ }
86
+
87
+ // Everything this harbor has, key and files: what a person does by hand
88
+ // to leave a device, and what a test does between two openings.
89
+ static async wipe(harbor: string): Promise<void> {
90
+ await SecureStorage.remove(`quo-${harbor}`);
91
+ if (await exists(`quo/${harbor}`)) await Filesystem.rmdir({ path: `quo/${harbor}`, directory, recursive: true });
92
+ }
93
+
94
+ #path(name: string) {
95
+ return `quo/${this.harbor}/wards/${name}.sealed`;
96
+ }
97
+ async #read(name: string): Promise<Blob> {
98
+ const { data } = await Filesystem.readFile({ path: this.#path(name), directory, encoding: Encoding.UTF8 });
99
+ return JSON.parse(new TextDecoder().decode(await open(this.#key, (data as string).trim()))) as Blob;
100
+ }
101
+ // A sealed ward is rewritten whole, so the read sits inside the queue
102
+ // with the write: two changes to one ward never lose each other's part.
103
+ #keep(name: string, change: (b: Blob | undefined) => Blob): Promise<void> {
104
+ const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
105
+ const blob = change((await exists(this.#path(name))) ? await this.#read(name) : undefined);
106
+ const sealed = await seal(this.#key, new TextEncoder().encode(JSON.stringify(blob)));
107
+ await Filesystem.writeFile({ path: this.#path(name), directory, data: sealed + '\n', encoding: Encoding.UTF8 });
108
+ });
109
+ this.#queues.set(name, next.catch(() => {}));
110
+ return next;
111
+ }
112
+
113
+ async list(): Promise<string[]> {
114
+ const { files } = await Filesystem.readdir({ path: `quo/${this.harbor}/wards`, directory });
115
+ return files.filter((f) => f.name.endsWith('.sealed')).map((f) => f.name.slice(0, -'.sealed'.length));
116
+ }
117
+
118
+ async load(name: string): Promise<Kept | undefined> {
119
+ if (!(await exists(this.#path(name)))) return undefined;
120
+ const b = await this.#read(name);
121
+ return { seed: unhex(b.seed), partition: b.partition, record: b.record };
122
+ }
123
+
124
+ async put(name: string, kept: Kept): Promise<void> {
125
+ if (await exists(this.#path(name))) throw new Error(`ward ${name} already exists on this device`);
126
+ return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
127
+ }
128
+
129
+ async save(name: string, partition: Record<string, unknown>): Promise<void> {
130
+ if (!(await exists(this.#path(name)))) return; // a name not kept is nothing
131
+ return this.#keep(name, (b) => ({ ...b!, partition }));
132
+ }
133
+
134
+ async record(name: string, record: WardRecord): Promise<void> {
135
+ if (!(await exists(this.#path(name)))) return;
136
+ return this.#keep(name, (b) => ({ ...b!, record }));
137
+ }
138
+
139
+ async take(name: string): Promise<Kept | undefined> {
140
+ const kept = await this.load(name);
141
+ if (!kept) return undefined;
142
+ await this.#queues.get(name);
143
+ await Filesystem.deleteFile({ path: this.#path(name), directory });
144
+ return kept;
145
+ }
146
+
147
+ async hints(): Promise<Record<string, string>> {
148
+ const p = `quo/${this.harbor}/reach.json`;
149
+ if (!(await exists(p))) return {};
150
+ const { data } = await Filesystem.readFile({ path: p, directory, encoding: Encoding.UTF8 });
151
+ return JSON.parse(data as string) as Record<string, string>;
152
+ }
153
+
154
+ async hint(pk: string, url: string): Promise<void> {
155
+ const all = await this.hints();
156
+ all[pk] = url;
157
+ await Filesystem.writeFile({ path: `quo/${this.harbor}/reach.json`, directory, data: JSON.stringify(all) + '\n', encoding: Encoding.UTF8 });
158
+ }
159
+ }
package/harbor/disk.ts CHANGED
@@ -9,7 +9,9 @@
9
9
  // <dir>/
10
10
  // lease pid of the one process that holds this harbor
11
11
  // classes/index.ts the default class source, a module exporting classes
12
- // wards/<name>/ seed, partition.json, ward.json: see files.ts
12
+ // tab/index.ts the classes for the tab, served bundled by the web route: human/web.ts
13
+ // wards/<name>/ seed, partition.json, ward.json, or one sealed
14
+ // blob when the daemon holds QUO_SEED_KEY: see files.ts
13
15
  // reach.json the directory's hints
14
16
  import { mkdir, readFile, writeFile, unlink, stat } from 'node:fs/promises';
15
17
  import { existsSync } from 'node:fs';
@@ -51,7 +53,10 @@ export class DiskHarbor extends Harbor {
51
53
 
52
54
  constructor(dir: string) {
53
55
  const abs = resolve(dir);
54
- super(new Files(abs), async (rec) => ({ ...BUILT_IN, ...(await loadClasses(isAbsolute(rec.code) ? rec.code : join(abs, rec.code))) }));
56
+ // The key the environment gives, the edge's name for it: from the
57
+ // Keychain through the app that spawned this daemon, or nothing on a
58
+ // droplet, whose folder stays plain under its file modes.
59
+ super(new Files(abs, process.env.QUO_SEED_KEY), async (rec) => ({ ...BUILT_IN, ...(await loadClasses(isAbsolute(rec.code) ? rec.code : join(abs, rec.code))) }));
55
60
  this.dir = abs;
56
61
  }
57
62
 
@@ -62,7 +67,7 @@ export class DiskHarbor extends Harbor {
62
67
  // has one.
63
68
  static async init(dir: string, name = 'main', user = 'me'): Promise<{ dir: string; name: string; pk: string; user: string }> {
64
69
  const h = new DiskHarbor(dir);
65
- if (existsSync(join(h.dir, 'wards', name, 'seed'))) throw new Error(`ward ${name} already exists in ${h.dir}`);
70
+ if ((await h.store.list()).includes(name)) throw new Error(`ward ${name} already exists in ${h.dir}`);
66
71
  await mkdir(join(h.dir, 'classes'), { recursive: true });
67
72
  const classes = join(h.dir, DEFAULT_CODE);
68
73
  if (!existsSync(classes)) await writeFile(classes, '// The classes this harbor holds beside the built-in ones. Export each one by name.\nexport {};\n');
@@ -24,7 +24,8 @@ import { isSilence } from '@quo-systems/quo';
24
24
  import { User, Desk, Avatar } from '../../beings/index.ts';
25
25
  import { setup } from '../../beings/setup.ts';
26
26
  import { Harbor, Socket as Held, SUITE, type Line } from '@quo-systems/quo/harbor';
27
- import { DurableStorage, sealKey } from './storage.ts';
27
+ import { DurableStorage } from './storage.ts';
28
+ import { sealKey } from '../seal.ts';
28
29
  import type { State, Env } from './platform.d.ts';
29
30
 
30
31
  export const BUILT_IN: Record<string, BeingClass> = { User, Desk, Avatar };
@@ -13,7 +13,8 @@ import type { World, Handle, Census } from '@quo-systems/quo/conformance';
13
13
  import { Printer, Shop, Customer, Echo, Member } from '@quo-systems/quo/conformance';
14
14
  import type { BeingClass, Cells, Invitation, JsonObject } from '@quo-systems/quo';
15
15
  import { EdgeHarbor } from './edge.ts';
16
- import { DurableStorage, sealKey } from './storage.ts';
16
+ import { DurableStorage } from './storage.ts';
17
+ import { sealKey } from '../seal.ts';
17
18
  import type { Hosted } from '@quo-systems/quo/harbor';
18
19
  import type { State, Env } from './platform.d.ts';
19
20
 
@@ -2,35 +2,16 @@
2
2
  // The store as Durable Object storage: one object per harbor, its storage
3
3
  // holding one row per ward under `ward:<name>`, seed, partition and record,
4
4
  // and one row per hint under `hint:<pk>`. The seed is kept sealed under a
5
- // key from the platform's secrets, `QUO_SEED_KEY`, 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, type Kept, type Store, type WardRecord } from '@quo-systems/quo/harbor';
11
+ import { seal, open } from '../seal.ts';
12
12
  import type { Storage } from './platform.d.ts';
13
13
 
14
14
  type Row = { seed: string; partition: Record<string, unknown>; record: WardRecord };
15
- // Bytes as the platform's crypto wants them: over a plain ArrayBuffer.
16
- const plain = (b: Uint8Array): Uint8Array<ArrayBuffer> => new Uint8Array(b);
17
- const { hex, unhex } = arithmetic;
18
-
19
- // The seal on a seed: AES-GCM under the platform key, a fresh nonce each
20
- // time, nonce and ciphertext together as hex.
21
- export async function sealKey(secret: string): Promise<CryptoKey> {
22
- if (!/^[0-9a-f]{64}$/.test(secret)) throw new Error('QUO_SEED_KEY is 32 bytes as hex');
23
- return crypto.subtle.importKey('raw', plain(unhex(secret)), 'AES-GCM', false, ['encrypt', 'decrypt']);
24
- }
25
- export async function seal(key: CryptoKey, seed: Uint8Array): Promise<string> {
26
- const iv = crypto.getRandomValues(new Uint8Array(12));
27
- const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain(seed)));
28
- return hex(iv) + hex(ct);
29
- }
30
- export async function open(key: CryptoKey, sealed: string): Promise<Uint8Array> {
31
- const b = plain(unhex(sealed));
32
- return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, key, b.subarray(12)));
33
- }
34
15
 
35
16
  // `prefix` keeps more than one harbor apart in one object's storage: the
36
17
  // exercise does that, a deployment never does.
package/harbor/files.ts CHANGED
@@ -1,39 +1,101 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The store as a folder on disk, one folder per ward:
2
+ // The store as a folder on disk, one folder per ward, in one of two forms.
3
+ //
4
+ // Plain, when the store has no key, what the droplets run:
3
5
  //
4
6
  // <dir>/wards/<name>/seed 32 bytes, hex, mode 0600
5
7
  // <dir>/wards/<name>/partition.json
6
8
  // <dir>/wards/<name>/ward.json the ward record
9
+ //
10
+ // Sealed, when the store holds a key, what a device's daemon runs with the
11
+ // key from its Keychain:
12
+ //
13
+ // <dir>/wards/<name>/ward.sealed seed, partition and record as one
14
+ // JSON, sealed under the key, hex
15
+ //
16
+ // and in both:
17
+ //
7
18
  // <dir>/reach.json the directory's hints
8
19
  //
9
- // Every write of the partition goes through a temp file and a rename, and
10
- // writes are queued per ward so two calls never race on one file.
20
+ // A ward is whole in either form, and a store that meets the other form
21
+ // refuses it by name: a daemon started without its key, or with one against
22
+ // a plain folder, fails loudly instead of booting on what it cannot read.
23
+ // Sealing a plain folder is a deliberate command, never a boot's doing.
24
+ // Every write of a ward goes through a temp file and a rename, and writes
25
+ // are queued per ward so two calls never race on one file.
11
26
  import { mkdir, readFile, writeFile, rename, readdir, rm, chmod } from 'node:fs/promises';
12
27
  import { existsSync } from 'node:fs';
13
28
  import { join } from 'node:path';
14
29
  import { arithmetic } from '@quo-systems/quo/ward';
15
30
  import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
31
+ import { sealKey, seal, open } from './seal.ts';
16
32
 
17
33
  const { hex, unhex } = arithmetic;
34
+ type Blob = { seed: string; partition: Record<string, unknown>; record: WardRecord };
18
35
 
19
36
  export class Files implements Store {
20
37
  readonly dir: string;
38
+ readonly #key?: Promise<CryptoKey>;
21
39
  readonly #queues = new Map<string, Promise<void>>();
22
- constructor(dir: string) {
40
+ constructor(dir: string, key?: string) {
23
41
  this.dir = dir;
42
+ if (key) this.#key = sealKey(key);
24
43
  }
25
44
  #ward(name: string) {
26
45
  return join(this.dir, 'wards', name);
27
46
  }
47
+ get sealed(): boolean {
48
+ return this.#key !== undefined;
49
+ }
50
+ // The form a folder holds, checked against the form this store speaks.
51
+ #form(name: string): 'plain' | 'sealed' | undefined {
52
+ const wd = this.#ward(name);
53
+ const form = existsSync(join(wd, 'ward.sealed')) ? 'sealed' : existsSync(join(wd, 'seed')) ? 'plain' : undefined;
54
+ if (form === 'sealed' && !this.sealed) throw new Error(`ward ${name} in ${this.dir} is sealed and this harbor has no key`);
55
+ if (form === 'plain' && this.sealed) throw new Error(`ward ${name} in ${this.dir} is plain and this harbor holds a key`);
56
+ return form;
57
+ }
58
+ // One write at a time per ward, through a temp file and a rename.
59
+ #write(name: string, file: string, body: string): Promise<void> {
60
+ const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
61
+ const tmp = join(this.#ward(name), `${file}.tmp`);
62
+ await writeFile(tmp, body, { mode: 0o600 });
63
+ await rename(tmp, join(this.#ward(name), file));
64
+ });
65
+ this.#queues.set(name, next.catch(() => {}));
66
+ return next;
67
+ }
68
+ async #read(name: string): Promise<Blob> {
69
+ const blob = (await readFile(join(this.#ward(name), 'ward.sealed'), 'utf8')).trim();
70
+ return JSON.parse(new TextDecoder().decode(await open(await this.#key!, blob))) as Blob;
71
+ }
72
+ // A sealed ward is rewritten whole, so the read sits inside the queue
73
+ // with the write: two changes to one ward never lose each other's part.
74
+ #keep(name: string, change: (b: Blob | undefined) => Blob): Promise<void> {
75
+ const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
76
+ const wd = this.#ward(name);
77
+ const blob = change(existsSync(join(wd, 'ward.sealed')) ? await this.#read(name) : undefined);
78
+ const sealed = await seal(await this.#key!, new TextEncoder().encode(JSON.stringify(blob)));
79
+ await writeFile(join(wd, 'ward.sealed.tmp'), sealed + '\n', { mode: 0o600 });
80
+ await rename(join(wd, 'ward.sealed.tmp'), join(wd, 'ward.sealed'));
81
+ });
82
+ this.#queues.set(name, next.catch(() => {}));
83
+ return next;
84
+ }
28
85
 
29
86
  async list(): Promise<string[]> {
30
87
  const wards = join(this.dir, 'wards');
31
- return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed'))) : [];
88
+ return existsSync(wards) ? (await readdir(wards)).filter((n) => existsSync(join(wards, n, 'seed')) || existsSync(join(wards, n, 'ward.sealed'))) : [];
32
89
  }
33
90
 
34
91
  async load(name: string): Promise<Kept | undefined> {
92
+ const form = this.#form(name);
93
+ if (!form) return undefined;
94
+ if (form === 'sealed') {
95
+ const b = await this.#read(name);
96
+ return { seed: unhex(b.seed), partition: b.partition, record: b.record };
97
+ }
35
98
  const wd = this.#ward(name);
36
- if (!existsSync(join(wd, 'seed'))) return undefined;
37
99
  return {
38
100
  seed: unhex((await readFile(join(wd, 'seed'), 'utf8')).trim()),
39
101
  partition: JSON.parse(await readFile(join(wd, 'partition.json'), 'utf8')) as Record<string, unknown>,
@@ -42,30 +104,28 @@ export class Files implements Store {
42
104
  }
43
105
 
44
106
  async put(name: string, kept: Kept): Promise<void> {
107
+ if (this.#form(name)) throw new Error(`ward ${name} already exists in ${this.dir}`);
45
108
  const wd = this.#ward(name);
46
- if (existsSync(join(wd, 'seed'))) throw new Error(`ward ${name} already exists in ${this.dir}`);
47
109
  await mkdir(wd, { recursive: true, mode: 0o700 });
110
+ if (this.sealed) return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
48
111
  await writeFile(join(wd, 'seed'), hex(kept.seed), { mode: 0o600 });
49
112
  await chmod(join(wd, 'seed'), 0o600);
50
113
  await writeFile(join(wd, 'partition.json'), JSON.stringify(kept.partition) + '\n', { mode: 0o600 });
51
114
  await writeFile(join(wd, 'ward.json'), JSON.stringify(kept.record, null, 2) + '\n', { mode: 0o600 });
52
115
  }
53
116
 
54
- save(name: string, partition: Record<string, unknown>): Promise<void> {
55
- const wd = this.#ward(name);
56
- if (!existsSync(join(wd, 'seed'))) return Promise.resolve(); // a name not kept is nothing
57
- const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
58
- const tmp = join(wd, 'partition.json.tmp');
59
- await writeFile(tmp, JSON.stringify(partition) + '\n', { mode: 0o600 });
60
- await rename(tmp, join(wd, 'partition.json'));
61
- });
62
- this.#queues.set(name, next.catch(() => {}));
63
- return next;
117
+ async save(name: string, partition: Record<string, unknown>): Promise<void> {
118
+ const form = this.#form(name);
119
+ if (!form) return; // a name not kept is nothing
120
+ if (form === 'sealed') return this.#keep(name, (b) => ({ ...b!, partition }));
121
+ return this.#write(name, 'partition.json', JSON.stringify(partition) + '\n');
64
122
  }
65
123
 
66
124
  async record(name: string, record: WardRecord): Promise<void> {
67
- if (!existsSync(join(this.#ward(name), 'seed'))) return;
68
- await writeFile(join(this.#ward(name), 'ward.json'), JSON.stringify(record, null, 2) + '\n', { mode: 0o600 });
125
+ const form = this.#form(name);
126
+ if (!form) return;
127
+ if (form === 'sealed') return this.#keep(name, (b) => ({ ...b!, record }));
128
+ return this.#write(name, 'ward.json', JSON.stringify(record, null, 2) + '\n');
69
129
  }
70
130
 
71
131
  async take(name: string): Promise<Kept | undefined> {