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