@quo-systems/dock 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ import type { JsonObject } from '@quo-systems/quo';
2
+ import type { Subject } from '../beings/side.ts';
3
+ export declare const TAB = "tab";
4
+ type Door = {
5
+ answer(asker: {
6
+ id: string;
7
+ }, method?: string, args?: JsonObject): Promise<unknown>;
8
+ };
9
+ export declare function local(being: Door): Subject;
10
+ export {};
@@ -0,0 +1,10 @@
1
+ // The asker the tab speaks under to its own beings.
2
+ export const TAB = 'tab';
3
+ export function local(being) {
4
+ const me = { id: TAB };
5
+ return {
6
+ tools: async () => (await being.answer(me)),
7
+ call: (name, args = {}) => being.answer(me, name, args),
8
+ ears: new Set(),
9
+ };
10
+ }
@@ -29,7 +29,8 @@ export async function screenSide(avatar, surface, options = {}) {
29
29
  }
30
30
  else {
31
31
  const d = await digest(bp);
32
- if (d !== seen) {
32
+ const moved = d !== seen;
33
+ if (moved) {
33
34
  seen = d;
34
35
  model.blueprint = bp;
35
36
  for (const k of Object.keys(model.answers))
@@ -42,21 +43,28 @@ export async function screenSide(avatar, surface, options = {}) {
42
43
  }
43
44
  else
44
45
  model.look = {};
45
- // her page, once per digest: a tree of values, or nothing and her asks are forms
46
- if (bp.asks.some((a) => a.name === 'page')) {
47
- const t = await avatar.call('page');
48
- model.tree = isSilence(t) || isWord(t) ? null : sanitiseTree(t);
49
- }
50
- else
51
- model.tree = null;
52
- // a read-only ask that needs nothing typed, or one her page shows the answer of, is run on
53
- // her behalf, so the page opens with what it shows
54
- const wanted = new Set(model.tree ? answersIn(model.tree) : []);
55
- for (const a of bp.asks) {
56
- const needs = (a.input.required ?? []).length > 0;
57
- if ((hintFor(bp, model.look, a.name).readOnly || wanted.has(a.name)) && !needs && !(a.name in model.answers))
58
- model.answers[a.name] = word(await avatar.call(a.name, {}));
59
- }
46
+ }
47
+ // her page, after every ask: a tree of values from what she holds now, or nothing and her asks
48
+ // are forms. A cell moved is a page moved, and her describe need not have; so the answers the
49
+ // page shows are run again with it, and the page never stands more than one ask behind her
50
+ const shows = new Set(model.tree ? answersIn(model.tree) : []);
51
+ if (bp.asks.some((a) => a.name === 'page')) {
52
+ const t = await avatar.call('page');
53
+ model.tree = isSilence(t) || isWord(t) ? null : sanitiseTree(t);
54
+ }
55
+ else
56
+ model.tree = null;
57
+ const wanted = new Set(model.tree ? answersIn(model.tree) : []);
58
+ for (const n of shows)
59
+ if (wanted.has(n))
60
+ delete model.answers[n];
61
+ // a read-only ask that needs nothing typed is run on her behalf once per digest, and one her page
62
+ // shows the answer of after every ask, so the page opens and stays with what it shows
63
+ for (const a of bp.asks) {
64
+ const needs = (a.input.required ?? []).length > 0;
65
+ const run = wanted.has(a.name) || (moved && hintFor(bp, model.look, a.name).readOnly);
66
+ if (run && !needs && !(a.name in model.answers))
67
+ model.answers[a.name] = word(await avatar.call(a.name, {}));
60
68
  }
61
69
  }
62
70
  await after();
@@ -7,6 +7,7 @@ export type Config = {
7
7
  public: boolean;
8
8
  }>;
9
9
  ward?: string;
10
+ beings?: boolean;
10
11
  };
11
12
  export declare function start(cfg: Config, root?: HTMLElement): Promise<void>;
12
13
  export { USER };
package/dist/human/tab.js CHANGED
@@ -1,3 +1,11 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
1
9
  import { BrowserHarbor } from '../harbor/browser.js';
2
10
  import { USER } from '../beings/avatar.js';
3
11
  import { parse, strip } from '../beings/link.js';
@@ -6,6 +14,7 @@ import { domSurface } from './dom.js';
6
14
  import { guest } from './guest.js';
7
15
  import { door } from './door.js';
8
16
  import { world, relations, fresh } from './worlds.js';
17
+ import { local } from './local.js';
9
18
  const kept = (key, fallback) => {
10
19
  try {
11
20
  return JSON.parse(localStorage.getItem(key) ?? 'null') ?? fallback;
@@ -48,7 +57,22 @@ export async function start(cfg, root = document.body) {
48
57
  root.append(nav, who, status, screen);
49
58
  const say = (s) => (status.textContent = s);
50
59
  // The harbor in the tab: one database per origin, one ward per world.
60
+ // The world's code first, when its origin serves any: the classes for
61
+ // the tab, handed to the harbor beside the built-in ones before any ward
62
+ // boots, since a being kept from the last visit is booted by class name.
63
+ // A world that serves none has no beings in the tab, and the page is the
64
+ // far being's alone.
51
65
  const harbor = new BrowserHarbor('quo');
66
+ const classes = cfg.beings
67
+ ? await import(__rewriteRelativeImportExtension(`${cfg.web}/beings.js`)).then((mod) => {
68
+ const out = {};
69
+ for (const [name, v] of Object.entries(mod))
70
+ if (typeof v === 'function' && 'prototype' in v)
71
+ out[name] = v;
72
+ return out;
73
+ })
74
+ : {};
75
+ Object.assign(harbor.classes, classes);
52
76
  await harbor.boot();
53
77
  harbor.dial(cfg.quo);
54
78
  // The world this page is about: the link's, since an invitation names
@@ -100,16 +124,45 @@ export async function start(cfg, root = document.body) {
100
124
  who.append(more);
101
125
  }
102
126
  };
103
- // In, as one relation: her page. Every call rotates her keys and a
104
- // same-ward ask never crosses the harbor, so the side saves after each.
127
+ // The world's beings in the tab, for one relation: one of each class,
128
+ // booted under her key the first time and found there after, since their
129
+ // cells are in the ward and come back with it. Each is a subject of her
130
+ // own with her own side in her own section, so a note she keeps and a
131
+ // form she shows are hers, painted by the same painter as the far page.
132
+ const locals = [];
133
+ const boot = async (rel) => {
134
+ const beings = ward.partition.beings ?? {};
135
+ for (const name of Object.keys(classes)) {
136
+ const key = `${rel.key}-${name}`;
137
+ if (!beings[key]) {
138
+ const out = (await ward.ask('boot', { key, class: name }));
139
+ if (out.error)
140
+ continue;
141
+ await ward.save();
142
+ }
143
+ const being = ward.being(key);
144
+ if (!being)
145
+ continue;
146
+ const section = el('section', '', { class: 'local', 'data-being': key });
147
+ screen.append(section);
148
+ locals.push(await screenSide(local(being), domSurface(section), { after: () => ward.save() }));
149
+ }
150
+ };
151
+ // In, as one relation: her page, then the world's beings for her. Every
152
+ // call rotates her keys and a same-ward ask never crosses the harbor, so
153
+ // the side saves after each.
105
154
  const inside = async (rel, notice, called) => {
106
155
  await side?.close();
156
+ for (const l of locals.splice(0))
157
+ await l.close();
107
158
  status.remove();
108
159
  root.querySelector('form.password')?.remove();
109
160
  screen.replaceChildren();
110
161
  current = rel;
111
162
  keep(AT(pk), rel.key);
112
- const s = await screenSide(rel.avatar, domSurface(screen), { after: () => ward.save(), notice });
163
+ const mine = el('div', '', { class: 'far' });
164
+ screen.append(mine);
165
+ const s = await screenSide(rel.avatar, domSurface(mine), { after: () => ward.save(), notice });
113
166
  side = s;
114
167
  const bp = s.model.blueprint;
115
168
  const notesName = typeof bp?.notes?.name === 'string' ? bp.notes.name : '';
@@ -118,6 +171,7 @@ export async function start(cfg, root = document.body) {
118
171
  keep(NAMES(pk), { ...names(), [rel.key]: called });
119
172
  switcher();
120
173
  people();
174
+ await boot(rel);
121
175
  };
122
176
  // The way in, from any of the three: a fresh avatar joins, the ward is
123
177
  // saved, and she is the one on screen.
@@ -138,6 +192,8 @@ export async function start(cfg, root = document.body) {
138
192
  // there, the door page: a link is the only way in, and nothing to type.
139
193
  const atDoor = async () => {
140
194
  await side?.close();
195
+ for (const l of locals.splice(0))
196
+ await l.close();
141
197
  side = null;
142
198
  current = null;
143
199
  screen.replaceChildren();
@@ -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;
@@ -82,6 +104,8 @@ const CSS = [
82
104
  '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
105
  '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
106
  'fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}',
107
+ // a being of the world running in the tab: her own section under the far page
108
+ '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
109
  '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
110
  '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
111
  // a page in the grammar: regions, roles, cards
@@ -0,0 +1,142 @@
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 type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
20
+ import { sealKey, seal, open } from './seal.ts';
21
+
22
+ const { hex, unhex } = arithmetic;
23
+ type Blob = { seed: string; partition: Record<string, unknown>; record: WardRecord };
24
+
25
+ // Where the files live: the folder iCloud does not copy on iOS, the app's
26
+ // own files on Android, whose manifest says no backup.
27
+ const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
28
+
29
+ async function exists(path: string): Promise<boolean> {
30
+ try {
31
+ await Filesystem.stat({ path, directory });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ export class Native implements Store {
39
+ readonly harbor: string;
40
+ readonly #key: CryptoKey;
41
+ readonly #queues = new Map<string, Promise<void>>();
42
+ private constructor(harbor: string, key: CryptoKey) {
43
+ this.harbor = harbor;
44
+ this.#key = key;
45
+ }
46
+
47
+ // Open one harbor's store, applying the custody rule, minting the key and
48
+ // the folder when this is a fresh install.
49
+ static async open(harbor: string): Promise<Native> {
50
+ const item = `quo-${harbor}`;
51
+ const root = `quo/${harbor}`;
52
+ // `set` keeps JSON, so `get` parses it back; `getItem` would hand back the quotes.
53
+ const got = await SecureStorage.get(item, false, false);
54
+ let secret = typeof got === 'string' ? got : undefined;
55
+ const folder = await exists(root);
56
+ if (secret && !folder) {
57
+ await SecureStorage.remove(item);
58
+ secret = undefined;
59
+ }
60
+ if (!secret && folder) await Filesystem.rmdir({ path: root, directory, recursive: true });
61
+ if (!secret) {
62
+ secret = hex(crypto.getRandomValues(new Uint8Array(32)));
63
+ await SecureStorage.set(item, secret, false, false, KeychainAccess.afterFirstUnlockThisDeviceOnly);
64
+ }
65
+ // mkdir refuses a folder that exists, recursive or not.
66
+ if (!(await exists(`${root}/wards`))) await Filesystem.mkdir({ path: `${root}/wards`, directory, recursive: true });
67
+ return new Native(harbor, await sealKey(secret));
68
+ }
69
+
70
+ // Everything this harbor has, key and files: what a person does by hand
71
+ // to leave a device, and what a test does between two openings.
72
+ static async wipe(harbor: string): Promise<void> {
73
+ await SecureStorage.remove(`quo-${harbor}`);
74
+ if (await exists(`quo/${harbor}`)) await Filesystem.rmdir({ path: `quo/${harbor}`, directory, recursive: true });
75
+ }
76
+
77
+ #path(name: string) {
78
+ return `quo/${this.harbor}/wards/${name}.sealed`;
79
+ }
80
+ async #read(name: string): Promise<Blob> {
81
+ const { data } = await Filesystem.readFile({ path: this.#path(name), directory, encoding: Encoding.UTF8 });
82
+ return JSON.parse(new TextDecoder().decode(await open(this.#key, (data as string).trim()))) as Blob;
83
+ }
84
+ // A sealed ward is rewritten whole, so the read sits inside the queue
85
+ // with the write: two changes to one ward never lose each other's part.
86
+ #keep(name: string, change: (b: Blob | undefined) => Blob): Promise<void> {
87
+ const next = (this.#queues.get(name) ?? Promise.resolve()).then(async () => {
88
+ const blob = change((await exists(this.#path(name))) ? await this.#read(name) : undefined);
89
+ const sealed = await seal(this.#key, new TextEncoder().encode(JSON.stringify(blob)));
90
+ await Filesystem.writeFile({ path: this.#path(name), directory, data: sealed + '\n', encoding: Encoding.UTF8 });
91
+ });
92
+ this.#queues.set(name, next.catch(() => {}));
93
+ return next;
94
+ }
95
+
96
+ async list(): Promise<string[]> {
97
+ const { files } = await Filesystem.readdir({ path: `quo/${this.harbor}/wards`, directory });
98
+ return files.filter((f) => f.name.endsWith('.sealed')).map((f) => f.name.slice(0, -'.sealed'.length));
99
+ }
100
+
101
+ async load(name: string): Promise<Kept | undefined> {
102
+ if (!(await exists(this.#path(name)))) return undefined;
103
+ const b = await this.#read(name);
104
+ return { seed: unhex(b.seed), partition: b.partition, record: b.record };
105
+ }
106
+
107
+ async put(name: string, kept: Kept): Promise<void> {
108
+ if (await exists(this.#path(name))) throw new Error(`ward ${name} already exists on this device`);
109
+ return this.#keep(name, () => ({ seed: hex(kept.seed), partition: kept.partition, record: kept.record }));
110
+ }
111
+
112
+ async save(name: string, partition: Record<string, unknown>): Promise<void> {
113
+ if (!(await exists(this.#path(name)))) return; // a name not kept is nothing
114
+ return this.#keep(name, (b) => ({ ...b!, partition }));
115
+ }
116
+
117
+ async record(name: string, record: WardRecord): Promise<void> {
118
+ if (!(await exists(this.#path(name)))) return;
119
+ return this.#keep(name, (b) => ({ ...b!, record }));
120
+ }
121
+
122
+ async take(name: string): Promise<Kept | undefined> {
123
+ const kept = await this.load(name);
124
+ if (!kept) return undefined;
125
+ await this.#queues.get(name);
126
+ await Filesystem.deleteFile({ path: this.#path(name), directory });
127
+ return kept;
128
+ }
129
+
130
+ async hints(): Promise<Record<string, string>> {
131
+ const p = `quo/${this.harbor}/reach.json`;
132
+ if (!(await exists(p))) return {};
133
+ const { data } = await Filesystem.readFile({ path: p, directory, encoding: Encoding.UTF8 });
134
+ return JSON.parse(data as string) as Record<string, string>;
135
+ }
136
+
137
+ async hint(pk: string, url: string): Promise<void> {
138
+ const all = await this.hints();
139
+ all[pk] = url;
140
+ await Filesystem.writeFile({ path: `quo/${this.harbor}/reach.json`, directory, data: JSON.stringify(all) + '\n', encoding: Encoding.UTF8 });
141
+ }
142
+ }
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.