nervur 0.20.2 → 0.20.3

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.
@@ -21,10 +21,11 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
21
21
  //
22
22
  // One run holds a folder: a terrain refuses a folder another run serves.
23
23
  import { createHash } from 'node:crypto';
24
- import { chmod, rm } from 'node:fs/promises';
24
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
25
+ import { createRequire } from 'node:module';
25
26
  import { connect, createServer } from 'node:net';
26
27
  import { homedir, tmpdir } from 'node:os';
27
- import { join, resolve } from 'node:path';
28
+ import { dirname, join, resolve } from 'node:path';
28
29
  import { pathToFileURL } from 'node:url';
29
30
  import { Loader } from '../contract/index.js';
30
31
  import { FolderCustody, FolderMemory } from '../folder/index.js';
@@ -47,15 +48,26 @@ export const socketOf = (dir) => {
47
48
  return beside;
48
49
  return join(tmpdir(), `nervur-${createHash('sha256').update(resolve(dir)).digest('hex').slice(0, 32)}.sock`);
49
50
  };
50
- // Code from a file: a module that exports `module`, `version` and
51
- // `classes`, found again by its absolute path. A file that cannot be
52
- // imported, or exports no module, is none.
51
+ // A specifier that names a file rather than a package.
52
+ export const isFile = (from) => /^(\.{1,2}[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(from) || /\.(m?[jt]s|c[jt]s)$/.test(from);
53
+ // Code from a file or an installed package: a module that exports
54
+ // `module`, `version` and `classes`, found again by where it was found, a
55
+ // file by its absolute path and a package by its name, resolved from each
56
+ // folder of `bases` in order. What cannot be imported, or exports no
57
+ // module, is none.
53
58
  export class ImportLoader extends Loader {
59
+ #bases;
60
+ constructor(bases = [process.cwd()]) {
61
+ super();
62
+ this.#bases = bases;
63
+ }
54
64
  async load(from) {
55
- const path = resolve(from);
65
+ const found = isFile(from) ? resolve(from) : this.#package(from);
66
+ if (found === null)
67
+ return null;
56
68
  let exported;
57
69
  try {
58
- exported = (await import(__rewriteRelativeImportExtension(pathToFileURL(path).href)));
70
+ exported = (await import(__rewriteRelativeImportExtension(pathToFileURL(found).href)));
59
71
  }
60
72
  catch {
61
73
  return null;
@@ -63,20 +75,72 @@ export class ImportLoader extends Loader {
63
75
  const { module, version, classes } = exported;
64
76
  if (typeof module !== 'string' || typeof version !== 'string' || !Array.isArray(classes))
65
77
  return null;
66
- return { module, version, classes, from: path };
78
+ return { module, version, classes, from: isFile(from) ? found : from };
79
+ }
80
+ #package(name) {
81
+ for (const base of this.#bases) {
82
+ try {
83
+ return createRequire(join(resolve(base), 'noop.js')).resolve(name);
84
+ }
85
+ catch {
86
+ // Not installed from here; the next base may hold it.
87
+ }
88
+ }
89
+ return null;
67
90
  }
68
91
  }
69
- // The modules a harbor is handed, by name, and then files.
92
+ // A module by its id, the name it declares, so the catalogue's cells name
93
+ // code the same way on every ground. `add` may name a module by its id, a
94
+ // file, or an installed package, and the ground works out which. The
95
+ // modules a harbor is handed are found by their id first. Where else an id
96
+ // is found, a file's path or a package's name, is this ground's own
97
+ // knowledge, kept beside the harbor in `<dir>/modules.json` and never in
98
+ // the sealed package, since another ground finds the same id in its own
99
+ // bundle. Packages resolve from where the command runs, then from the
100
+ // harbor's folder.
70
101
  export class NodeLoader extends ImportLoader {
71
102
  #held;
72
- constructor(modules = []) {
73
- super();
103
+ #map;
104
+ constructor(modules = [], dir) {
105
+ super(dir === undefined ? [process.cwd()] : [process.cwd(), dir]);
74
106
  this.#held = new HeldLoader(modules);
107
+ this.#map = dir === undefined ? undefined : join(dir, MODULES);
75
108
  }
76
109
  async load(from) {
77
- return (await this.#held.load(from)) ?? super.load(from);
110
+ const held = await this.#held.load(from);
111
+ if (held)
112
+ return held;
113
+ const known = await this.#known();
114
+ const where = known[from];
115
+ if (where !== undefined) {
116
+ const loaded = await super.load(where);
117
+ return loaded?.module === from ? { ...loaded, from } : null;
118
+ }
119
+ const loaded = await super.load(from);
120
+ if (!loaded)
121
+ return null;
122
+ if (this.#map && known[loaded.module] !== loaded.from)
123
+ await this.#keep({ ...known, [loaded.module]: loaded.from });
124
+ return { ...loaded, from: loaded.module };
125
+ }
126
+ async #known() {
127
+ if (!this.#map)
128
+ return {};
129
+ try {
130
+ return JSON.parse(await readFile(this.#map, 'utf8'));
131
+ }
132
+ catch {
133
+ return {};
134
+ }
135
+ }
136
+ async #keep(files) {
137
+ await mkdir(dirname(this.#map), { recursive: true, mode: 0o700 });
138
+ await writeFile(`${this.#map}.new`, `${JSON.stringify(files, null, 2)}\n`, { mode: 0o600 });
139
+ await rename(`${this.#map}.new`, this.#map);
78
140
  }
79
141
  }
142
+ // Where a Node harbor's module ids find their files.
143
+ export const MODULES = 'modules.json';
80
144
  // One root request over a served harbor's socket, or undefined where none
81
145
  // is served.
82
146
  export const askServed = (dir, request) => new Promise((done, fail) => {
@@ -101,7 +165,7 @@ export class NodeTerrain extends PointerTerrain {
101
165
  const entropy = new CryptoEntropy();
102
166
  super({
103
167
  entropy,
104
- loader: new NodeLoader(parts.modules),
168
+ loader: new NodeLoader(parts.modules, parts.dir),
105
169
  custody: new FolderCustody(parts.dir, entropy),
106
170
  memory: new FolderMemory(parts.dir),
107
171
  faculties: [tcpFaculty, webGround, webPush],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nervur",
3
- "version": "0.20.2",
3
+ "version": "0.20.3",
4
4
  "description": "Nervur's kit of Quo: an object asks another object and gets an answer, without knowing where it is.",
5
5
  "keywords": [
6
6
  "quo",
@@ -7,7 +7,8 @@
7
7
  // invitation for whoever made it
8
8
  // nervur serve [--dir D] [--host H] [--port P] the harbor served: Quo over TCP,
9
9
  // and the root line on its socket
10
- // nervur module add <file> a module the catalogue runs
10
+ // nervur module add <file | package | id> a module the catalogue runs, kept
11
+ // by the id it declares
11
12
  // nervur module remove <module> a module it runs no more
12
13
  // nervur modules what the catalogue runs
13
14
  // nervur census [--ward W] the empty ask
@@ -18,6 +19,10 @@
18
19
  // --with-seed the seed goes too
19
20
  // nervur import <file> a carried harbor into an empty
20
21
  // folder, `-` being this input
22
+ // nervur edge pack <file> the harbor for an edge: its seed
23
+ // printed, for the worker's
24
+ // NERVUR_SEED, and its sealed
25
+ // package written to the file
21
26
  // nervur open <key> <kind> a faculty stands in the box ward
22
27
  // nervur host <ward> [--seed S] a hosted ward stands
23
28
  // nervur route <ward pk> <address... | none> where a ward is reached, in order
@@ -45,10 +50,12 @@ import { readFile } from 'node:fs/promises';
45
50
  import { join, resolve } from 'node:path';
46
51
  import type { JsonObject } from '../being/index.ts';
47
52
  import type { RootRequest } from '../harbor/index.ts';
48
- import { harborDir } from '../node/index.ts';
49
- import { carry, isServed, land, open, request, serve } from './harbor.ts';
53
+ import { WEB_OPENING } from '../line/index.ts';
54
+ import { harborDir, isFile } from '../node/index.ts';
55
+ import { readAt, webAddress } from '../quo/index.ts';
56
+ import { carry, isServed, land, open, pack, request, serve } from './harbor.ts';
50
57
 
51
- const USAGE = 'usage: nervur <init|serve|module|modules|census|sweep|open|host|route|reach|boot|unboot|public|invite|ask|own|disown|pilot|export|import> [args] [--dir D] [--ward W] [--via V] [--seed S] [--with-seed] [--host H] [--port P]';
58
+ const USAGE = 'usage: nervur <init|serve|module|modules|census|sweep|open|host|route|reach|boot|unboot|public|invite|ask|own|disown|pilot|export|import|edge> [args] [--dir D] [--ward W] [--via V] [--seed S] [--with-seed] [--host H] [--port P]';
52
59
  const FLAGS = new Set(['dir', 'ward', 'via', 'seed', 'host', 'port']);
53
60
  // The one flag that stands alone, since what it names is a yes.
54
61
  const ALONE = 'with-seed';
@@ -89,7 +96,7 @@ export const requestOf = (command: string, words: string[], flags: Record<string
89
96
  switch (command) {
90
97
  case 'module': {
91
98
  const [verb, what] = need(2);
92
- if (verb === 'add') return catalogue('add', { from: resolve(what!) });
99
+ if (verb === 'add') return catalogue('add', { from: isFile(what!) ? resolve(what!) : what! });
93
100
  if (verb === 'remove') return catalogue('remove', { module: what! });
94
101
  throw new Error('module is add or remove');
95
102
  }
@@ -174,6 +181,12 @@ export const main = async (argv: string[], env: Record<string, string | undefine
174
181
  const text = words[0] === '-' ? await standingIn() : await readFile(words[0]!, 'utf8');
175
182
  return out({ landed: await land(dir, JSON.parse(text)) });
176
183
  }
184
+ if (command === 'edge') {
185
+ if (words[0] !== 'pack' || words.length !== 2) throw new Error('edge takes pack and a file');
186
+ if (flags.via !== undefined) throw new Error('edge pack reads this folder, so it takes no --via');
187
+ if (await isServed(dir)) throw new Error(`a harbor is served from ${dir}: stop it first`);
188
+ return out(await pack(dir, words[1]!));
189
+ }
177
190
  if (command === 'serve') {
178
191
  if (words.length > 0) throw new Error('serve takes no argument');
179
192
  const served = await serve(dir, flags.host, flags.port === undefined ? undefined : Number(flags.port));
@@ -187,7 +200,9 @@ export const main = async (argv: string[], env: Record<string, string | undefine
187
200
  if (words.length !== 2 && words.length !== 3) throw new Error('pilot takes a name, an invitation, and an address where the invitation carries none');
188
201
  const [name, invitation, at] = words as [string, string, string | undefined];
189
202
  const held = JSON.parse(invitation) as JsonObject;
190
- for (const req of pilotRequests(name, held, at)) {
203
+ const census = (await request(dir, {})) as { answer?: { beings?: JsonObject } };
204
+ const web = census.answer?.beings !== undefined && Object.hasOwn(census.answer.beings, WEB_OPENING.key);
205
+ for (const req of pilotRequests(name, held, at, web)) {
191
206
  const answer = await request(dir, req);
192
207
  if (!failed(answer)) continue;
193
208
  if (req.method === 'ask') await request(dir, { method: 'unboot', args: { being: name } });
@@ -200,9 +215,12 @@ export const main = async (argv: string[], env: Record<string, string | undefine
200
215
  return out(farAnswer(await request(dir, via(flags.via, requestOf(command, words, flags)))));
201
216
  };
202
217
 
203
- // The root requests that hold a far ward: its route where one is named,
204
- // else the take learns it from the invitation's `at`; the pilot; the take.
205
- export const pilotRequests = (name: string, invitation: JsonObject, at?: string): RootRequest[] => [
218
+ // The root requests that hold a far ward: the web carrier opened, where the
219
+ // far ward is reached on the web and this harbor has none; its route where
220
+ // one is named, else the take learns it from the invitation's `at`; the
221
+ // pilot; the take.
222
+ export const pilotRequests = (name: string, invitation: JsonObject, at?: string, web = false): RootRequest[] => [
223
+ ...(!web && readAt([...(at === undefined ? [] : [at]), ...readAt(invitation.at)]).some((a) => webAddress(a) !== null) ? [{ method: 'open', args: { key: WEB_OPENING.key, class: WEB_OPENING.kind } }] : []),
206
224
  ...(at === undefined ? [] : [{ method: 'route', args: { ward: invitation.ward ?? null, at: [at] } }]),
207
225
  { method: 'boot', args: { key: name, class: 'org.nervur.pilot' } },
208
226
  { method: 'ask', args: { being: name, method: 'hold', args: { invitation } } },
package/src/cli/harbor.ts CHANGED
@@ -7,13 +7,13 @@
7
7
  // the command unpacks the harbor from its folder, asks, and closes it, so
8
8
  // one process at a time writes the folder.
9
9
  import { existsSync } from 'node:fs';
10
- import { mkdir, writeFile } from 'node:fs/promises';
10
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
12
  import type { JsonObject } from '../being/index.ts';
13
- import { hex, isHex, unhex } from '../crypto/index.ts';
13
+ import { hex } from '../crypto/index.ts';
14
14
  import { FolderCustody, FolderMemory } from '../folder/index.ts';
15
- import { Harbor, rootLine, type CarrierFaculty, type RootRequest } from '../harbor/index.ts';
16
- import { askServed, NodeTerrain, socketOf } from '../node/index.ts';
15
+ import { carryPlaces, Harbor, landPlaces, readCarried, rootLine, type CarrierFaculty, type Carried, type RootRequest } from '../harbor/index.ts';
16
+ import { askServed, MODULES, NodeTerrain, socketOf } from '../node/index.ts';
17
17
  import { CryptoEntropy } from '../pointer/index.ts';
18
18
  import { TCP_OPENING } from '../tcp/index.ts';
19
19
 
@@ -35,51 +35,42 @@ export const request = async (dir: string, req: RootRequest): Promise<JsonObject
35
35
  }
36
36
  };
37
37
 
38
- // A harbor as one JSON value, for carrying it to another ground: every
39
- // place of its memory with its sealed entries as hex, and its seed only
40
- // where the one who asks says so, since custody holds it and a package
41
- // without it opens nowhere.
42
- export type Carried = { seed?: string; places: Record<string, Record<string, string>> };
43
-
44
- const carried = (value: unknown): Carried | null => {
45
- if (typeof value !== 'object' || value === null) return null;
46
- const { seed, places } = value as { seed?: unknown; places?: unknown };
47
- if (seed !== undefined && !isHex(seed, 32)) return null;
48
- if (typeof places !== 'object' || places === null || Array.isArray(places)) return null;
49
- for (const entries of Object.values(places)) {
50
- if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) return null;
51
- for (const bytes of Object.values(entries)) if (!isHex(bytes)) return null;
52
- }
53
- return { ...(seed === undefined ? {} : { seed }), places: places as Carried['places'] };
38
+ // The harbor of a folder, read as bytes alone, its seed only where the
39
+ // one who asks says so, since custody holds it and a package without it
40
+ // opens nowhere. No harbor opens, so nothing is asked and nothing is
41
+ // written.
42
+ export const carry = async (dir: string, seed: boolean): Promise<Carried> => {
43
+ const places = await carryPlaces(new FolderMemory(dir));
44
+ const modules = await readFile(join(dir, MODULES), 'utf8').then((text) => JSON.parse(text) as Record<string, string>, () => undefined);
45
+ return {
46
+ ...(seed ? { seed: hex(await new FolderCustody(dir, new CryptoEntropy()).seed()) } : {}),
47
+ places,
48
+ ...(modules ? { modules } : {}),
49
+ };
54
50
  };
55
51
 
56
- // The harbor of a folder, read as bytes alone. No harbor opens, so
57
- // nothing is asked and nothing is written.
58
- export const carry = async (dir: string, seed: boolean): Promise<Carried> => {
59
- const memory = new FolderMemory(dir);
60
- const places: Carried['places'] = {};
61
- for (const place of await memory.places()) {
62
- places[place] = Object.fromEntries([...(await memory.read(place))].map(([name, bytes]) => [name, hex(bytes)]));
63
- }
64
- if (!seed) return { places };
65
- return { seed: hex(await new FolderCustody(dir, new CryptoEntropy()).seed()), places };
52
+ // What `nervur edge pack` hands a deploy: the seed, for the worker's
53
+ // `NERVUR_SEED`, and the sealed package without it, for the bundle.
54
+ export const pack = async (dir: string, file: string): Promise<JsonObject> => {
55
+ if (!existsSync(join(dir, 'seed'))) throw new Error(`no harbor stands at ${dir}`);
56
+ const { seed, places } = await carry(dir, true);
57
+ await writeFile(file, `${JSON.stringify({ places })}\n`, { mode: 0o600 });
58
+ return { seed: seed!, package: file, places: Object.keys(places).length };
66
59
  };
67
60
 
68
61
  // A carried harbor into an empty folder. A folder holding a seed or a
69
62
  // place is left as it stands, and a package with no seed needs one there
70
63
  // already, which a terrain that seals its custody writes its own way.
71
64
  export const land = async (dir: string, value: unknown): Promise<string[]> => {
72
- const held = carried(value);
65
+ const held = readCarried(value);
73
66
  if (held === null) throw new Error('that is no carried harbor');
74
67
  const memory = new FolderMemory(dir);
75
68
  if ((await memory.places()).length > 0 || (held.seed !== undefined && existsSync(join(dir, 'seed')))) throw new Error(`a harbor stands at ${dir}`);
76
69
  await mkdir(dir, { recursive: true, mode: 0o700 });
77
70
  if (held.seed !== undefined) await writeFile(join(dir, 'seed'), `${held.seed}\n`, { mode: 0o600, flag: 'wx' });
78
71
  else if (!existsSync(join(dir, 'seed'))) throw new Error('the package carries no seed, and none is held here');
79
- for (const [place, entries] of Object.entries(held.places)) {
80
- await memory.write(place, new Map(Object.entries(entries).map(([name, bytes]) => [name, unhex(bytes)])));
81
- }
82
- return Object.keys(held.places);
72
+ if (held.modules && !existsSync(join(dir, MODULES))) await writeFile(join(dir, MODULES), `${JSON.stringify(held.modules, null, 2)}\n`, { mode: 0o600 });
73
+ return landPlaces(memory, held);
83
74
  };
84
75
 
85
76
  // A served harbor: its pk, its tcp address, every address its carriers
package/src/edge/index.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // `nervur/edge`: the edge ground, Cloudflare Workers with a Durable Object
3
- // per harbor. The object's storage keeps the harbor's sealed memory and
4
- // its seed, sealed under the worker's `NERVUR_SECRET`; the object answers
5
- // Quo over the web, a post or a held line, since a worker takes no
6
- // inbound TCP; and it asks over TCP and the web.
3
+ // per harbor. The object's storage keeps the harbor's sealed memory; the
4
+ // object answers Quo over the web, a post or a held line, since a worker
5
+ // takes no inbound TCP; and it asks over TCP and the web.
6
+ //
7
+ // A harbor born on another ground is carried here: its seed is the
8
+ // worker's `NERVUR_SEED`, and its sealed package, bundled with the worker,
9
+ // lands in the object's storage the first time the object starts. Its
10
+ // owner holds the owner invitation minted where it was born, and pilots
11
+ // it over the web. A harbor born here draws its seed and seals it under
12
+ // the worker's `NERVUR_SECRET`.
7
13
  //
8
14
  // m/<place>/<name> one sealed entry of the harbor's memory
9
- // c/seed the seed, sealed under a key from the secret
15
+ // c/seed a drawn seed, sealed under a key from the secret
10
16
  //
11
17
  // A keep is one storage transaction, whole or nothing. The storage and
12
18
  // state types name only what the kit uses, so no Cloudflare package is a
@@ -14,12 +20,14 @@
14
20
  import type { AskSpec, JsonObject } from '../being/index.ts';
15
21
  import { Custody, Memory, type Entropy, type Module } from '../contract/index.ts';
16
22
  import { decrypt, encrypt, hkdf, isHex, unhex, utf8 } from '../crypto/index.ts';
17
- import { addGround, CarrierFaculty, Harbor, UNDIALED, type Dialed, type Ground, type GroundFaculty, type GroundProbe } from '../harbor/index.ts';
23
+ import { addGround, CarrierFaculty, Harbor, landPlaces, UNDIALED, type Carried, type Dialed, type Ground, type GroundFaculty, type GroundProbe } from '../harbor/index.ts';
18
24
  import { answerHeld, answerPost, askFrame, FrameReader, TCP, TCP_OPENING, WEB_OPENING, webFaculty, webPush, type WebListen, type WebListener } from '../line/index.ts';
19
25
  import { CryptoEntropy, HeldLoader, PointerTerrain } from '../pointer/index.ts';
20
26
  import { tcpAddress, tcpAt, type TcpAddress } from '../quo/index.ts';
21
27
  import { ownerAsk } from '../ward/index.ts';
22
28
 
29
+ export type { Carried } from '../harbor/index.ts';
30
+
23
31
  export const EDGE = 'edge';
24
32
  const ENTRY = 'm/';
25
33
  const SEED = 'c/seed';
@@ -39,8 +47,10 @@ export interface EdgeState {
39
47
  readonly storage: EdgeStorage;
40
48
  blockConcurrencyWhile<T>(work: () => Promise<T>): Promise<T>;
41
49
  }
42
- export type EdgeEnv = { readonly NERVUR_SECRET?: string };
43
- export type EdgeGiven = { readonly state: EdgeState; readonly env: EdgeEnv };
50
+ export type EdgeEnv = { readonly NERVUR_SEED?: string; readonly NERVUR_SECRET?: string };
51
+ // What the object hands its harbor: its state, the worker's environment,
52
+ // and a carried package to land where the object keeps nothing yet.
53
+ export type EdgeGiven = { readonly state: EdgeState; readonly env: EdgeEnv; readonly package?: Carried };
44
54
 
45
55
  const chunks = <T>(items: readonly T[]): T[][] => Array.from({ length: Math.ceil(items.length / BATCH) }, (_, i) => items.slice(i * BATCH, (i + 1) * BATCH));
46
56
  const bytesOf = (value: unknown): Uint8Array => new Uint8Array(value instanceof ArrayBuffer ? value : (value as Uint8Array));
@@ -89,24 +99,33 @@ export class EdgeMemory extends Memory {
89
99
  }
90
100
  }
91
101
 
92
- // The seed, drawn once and kept sealed under a key the secret gives, so
93
- // the storage alone opens nothing. A harbor with no secret does not stand.
102
+ // The seed a harbor carried here was born with, as 64 hex, where it is
103
+ // named. Otherwise a seed drawn once and kept sealed under a key the
104
+ // secret gives, so the storage alone opens nothing. A harbor with neither
105
+ // does not stand.
106
+ export type EdgeKeys = { readonly seed?: string | undefined; readonly secret?: string | undefined };
107
+
94
108
  export class EdgeCustody extends Custody {
95
109
  readonly #storage: EdgeStorage;
96
- readonly #secret: string | undefined;
110
+ readonly #keys: EdgeKeys;
97
111
  readonly #entropy: Entropy;
98
112
  readonly #key: string;
99
- constructor(storage: EdgeStorage, secret: string | undefined, entropy: Entropy = new CryptoEntropy(), root = '') {
113
+ constructor(storage: EdgeStorage, keys: EdgeKeys, entropy: Entropy = new CryptoEntropy(), root = '') {
100
114
  super();
101
115
  this.#storage = storage;
102
- this.#secret = secret;
116
+ this.#keys = keys;
103
117
  this.#entropy = entropy;
104
118
  this.#key = `${root}${SEED}`;
105
119
  }
106
120
 
107
121
  async seed(): Promise<Uint8Array> {
108
- if (!this.#secret) throw new Error('the worker holds no NERVUR_SECRET');
109
- const key = await hkdf(utf8(this.#secret), SEAL_INFO, 32);
122
+ const { seed: named, secret } = this.#keys;
123
+ if (named !== undefined) {
124
+ if (!isHex(named.trim(), 32)) throw new Error('NERVUR_SEED is not 64 hex');
125
+ return unhex(named.trim());
126
+ }
127
+ if (!secret) throw new Error('the worker holds no NERVUR_SECRET');
128
+ const key = await hkdf(utf8(secret), SEAL_INFO, 32);
110
129
  return this.#storage.transaction(async (txn) => {
111
130
  const kept = (await txn.get(this.#key)) as { nonce: Uint8Array; sealed: Uint8Array } | undefined;
112
131
  if (kept) {
@@ -284,7 +303,9 @@ export class EdgeTerrain extends PointerTerrain {
284
303
  super({
285
304
  entropy,
286
305
  loader: new HeldLoader(parts.modules ?? []),
287
- custody: new EdgeCustody(storage, parts.given.env.NERVUR_SECRET, entropy),
306
+ // Only a harbor carried here takes the worker's NERVUR_SEED; every
307
+ // other object of the worker draws its own.
308
+ custody: new EdgeCustody(storage, { seed: parts.given.package ? parts.given.env.NERVUR_SEED : undefined, secret: parts.given.env.NERVUR_SECRET }, entropy),
288
309
  memory: new EdgeMemory(storage),
289
310
  faculties: [edgeTcp, webFaculty(edgeServe), webPush],
290
311
  opens: [TCP_OPENING, WEB_OPENING],
@@ -292,6 +313,15 @@ export class EdgeTerrain extends PointerTerrain {
292
313
  this.given = parts.given;
293
314
  }
294
315
 
316
+ // A carried package lands where the object keeps nothing yet, and only
317
+ // under the seed it was sealed with, the worker's `NERVUR_SEED`.
318
+ override async claim(): Promise<void> {
319
+ const { package: carried, env } = this.given;
320
+ if (!carried || (await this.memory.places()).length > 0) return;
321
+ if (env.NERVUR_SEED === undefined) throw new Error('a carried package opens only under NERVUR_SEED');
322
+ await landPlaces(this.memory, carried);
323
+ }
324
+
295
325
  override stand(harbor: Harbor): Promise<void> {
296
326
  this.#harbor = harbor;
297
327
  return Promise.resolve();
@@ -328,11 +358,27 @@ export const edgeGround: GroundProbe = {
328
358
  terrain: ({ given, modules }) => new EdgeTerrain({ given: given as EdgeGiven, ...(modules ? { modules } : {}) }),
329
359
  };
330
360
 
331
- // A Durable Object class whose every instance is one harbor: opened once
332
- // per object, with `stood` run on it as the worker's own code, and every
333
- // request it receives answered by the harbor's web carrier.
361
+ // What a Durable Object class of harbors is made of:
362
+ //
363
+ // modules the code its harbors run
364
+ // package a harbor carried here, from `nervur edge pack`, landed the
365
+ // first time an object starts; or a function of the worker's
366
+ // environment giving one
367
+ // path where every object answers Quo, `/quo` unless named, on
368
+ // whatever host reaches it
369
+ // stood the worker's own code, the root, run once the harbor stands
370
+ export type HarborObjectParts = {
371
+ readonly modules: readonly Module[];
372
+ readonly package?: Carried | ((env: EdgeEnv) => Carried | undefined);
373
+ readonly path?: string;
374
+ readonly stood?: (harbor: Harbor) => Promise<void>;
375
+ };
334
376
  export type HarborObjectClass = new (state: EdgeState, env: EdgeEnv) => { readonly harbor: Promise<Harbor>; fetch(request: Request): Promise<Response> };
335
- export const harborObject = (modules: readonly Module[], stood?: (harbor: Harbor) => Promise<void>): HarborObjectClass =>
377
+
378
+ // A Durable Object class whose every instance is one harbor: opened once
379
+ // per object, its web carrier opened, and every request it receives
380
+ // answered by that carrier at `path`.
381
+ export const harborObject = ({ modules, package: carried, path = '/quo', stood }: HarborObjectParts): HarborObjectClass =>
336
382
  class HarborObject {
337
383
  readonly #opening: Promise<Harbor>;
338
384
 
@@ -343,10 +389,13 @@ export const harborObject = (modules: readonly Module[], stood?: (harbor: Harbor
343
389
 
344
390
  constructor(state: EdgeState, env: EdgeEnv) {
345
391
  addGround(edgeGround);
392
+ const held = typeof carried === 'function' ? carried(env) : carried;
393
+ const given: EdgeGiven = { state, env, ...(held ? { package: held } : {}) };
346
394
  // Opened inside the object's block, so no request reaches it half
347
395
  // open and its storage answers the opening.
348
396
  this.#opening = state.blockConcurrencyWhile(async () => {
349
- const harbor = await Harbor.open({ modules, given: { state, env } });
397
+ const harbor = await Harbor.open({ modules, given });
398
+ if (!harbor.box.being(WEB_OPENING.key)) await harbor.root('open', { key: WEB_OPENING.key, class: WEB_OPENING.kind });
350
399
  await stood?.(harbor);
351
400
  return harbor;
352
401
  });
@@ -354,6 +403,11 @@ export const harborObject = (modules: readonly Module[], stood?: (harbor: Harbor
354
403
 
355
404
  async fetch(request: Request): Promise<Response> {
356
405
  const harbor = await this.#opening;
357
- return (harbor.terrain as EdgeTerrain).answer(request);
406
+ const terrain = harbor.terrain as EdgeTerrain;
407
+ if (terrain.listening?.path !== path) {
408
+ const url = new URL(request.url);
409
+ await harbor.root('ask', { being: WEB_OPENING.key, method: 'listen', args: { host: url.hostname, port: url.port === '' ? 0 : Number(url.port), path } });
410
+ }
411
+ return terrain.answer(request);
358
412
  }
359
413
  };
@@ -5,7 +5,7 @@ export { Catalogue, CATALOGUE, type Cataloguing, type ModuleRow } from './catalo
5
5
  export { BOX, Dock, type Failures, type Hosted, type Hosting } from './dock.ts';
6
6
  export { CarrierFaculty, UNDIALED, type Dialed, type Ground, type GroundFaculty } from './carrying.ts';
7
7
  export { Harbor } from './harbor.ts';
8
- export { copyPackage, Package } from './package.ts';
8
+ export { carryPlaces, copyPackage, landPlaces, Package, readCarried, type Carried } from './package.ts';
9
9
  export { readRootRequest, rootLine, type RootRequest } from './root-line.ts';
10
10
  export { addGround, grounds, probeGround, type GroundProbe, type Probing } from './probe.ts';
11
11
  export { Registry } from './registry.ts';
@@ -13,7 +13,7 @@
13
13
  // A row is the head, under the empty name, or one being, under her key. An
14
14
  // entry that does not open refuses the whole ward, so nothing unpacks from
15
15
  // bytes the package did not seal.
16
- import { concat, decrypt, encrypt, hex, hkdf, utf8 } from '../crypto/index.ts';
16
+ import { concat, decrypt, encrypt, hex, hkdf, isHex, unhex, utf8 } from '../crypto/index.ts';
17
17
  import type { Entropy, Memory, Rows } from '../contract/index.ts';
18
18
  import { HEAD_ROW } from '../ward/index.ts';
19
19
 
@@ -28,6 +28,44 @@ export const copyPackage = async (from: Memory, to: Memory): Promise<string[]> =
28
28
  return places;
29
29
  };
30
30
 
31
+ // A harbor as one JSON value, for carrying it to another ground: every
32
+ // place of its memory with its sealed entries as hex, its seed only where
33
+ // the one who carries it says so, and where the ground it left found each
34
+ // module id, a hint the next ground of the same kind may take.
35
+ export type Carried = { seed?: string; places: Record<string, Record<string, string>>; modules?: Record<string, string> };
36
+
37
+ // A carried harbor as JSON reads, or null for anything else.
38
+ export const readCarried = (value: unknown): Carried | null => {
39
+ if (typeof value !== 'object' || value === null) return null;
40
+ const { seed, places, modules } = value as { seed?: unknown; places?: unknown; modules?: unknown };
41
+ if (seed !== undefined && !isHex(seed, 32)) return null;
42
+ const hints = modules === undefined || (typeof modules === 'object' && modules !== null && !Array.isArray(modules) && Object.values(modules).every((m) => typeof m === 'string'));
43
+ if (!hints) return null;
44
+ if (typeof places !== 'object' || places === null || Array.isArray(places)) return null;
45
+ for (const entries of Object.values(places)) {
46
+ if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) return null;
47
+ for (const bytes of Object.values(entries)) if (!isHex(bytes)) return null;
48
+ }
49
+ return { ...(seed === undefined ? {} : { seed }), places: places as Carried['places'], ...(modules === undefined ? {} : { modules: modules as Record<string, string> }) };
50
+ };
51
+
52
+ // Every place of a memory, as a carried harbor holds it.
53
+ export const carryPlaces = async (memory: Memory): Promise<Carried['places']> => {
54
+ const places: Carried['places'] = {};
55
+ for (const place of await memory.places()) {
56
+ places[place] = Object.fromEntries([...(await memory.read(place))].map(([name, bytes]) => [name, hex(bytes)]));
57
+ }
58
+ return places;
59
+ };
60
+
61
+ // A carried harbor's places, written into a memory, and their names.
62
+ export const landPlaces = async (memory: Memory, carried: Carried): Promise<string[]> => {
63
+ for (const [place, entries] of Object.entries(carried.places)) {
64
+ await memory.write(place, new Map(Object.entries(entries).map(([name, bytes]) => [name, unhex(bytes)])));
65
+ }
66
+ return Object.keys(carried.places);
67
+ };
68
+
31
69
  const NONCE = 12;
32
70
  const LABEL = utf8('nervur-package');
33
71
 
@@ -183,10 +183,20 @@ export const relayFaculty = (clock: Clock): BeingClass =>
183
183
  if (replies === null) return { error: 'replies are a list of { n, box }' };
184
184
  const id = asker.id!;
185
185
  this.#rung.delete(id);
186
+ const replied = new Set(replies.map((r) => r.n));
186
187
  for (const { n, box } of replies) {
187
188
  const taken = this.#taken.get(n);
188
189
  if (taken?.collector === id) this.#settle(taken, box === null ? null : unhex(box));
189
190
  }
191
+ // A client answers every box it took in its next collect, so a box it
192
+ // left out never reached its doors, and waits again. A door that
193
+ // hears it twice answers the second as repeated.
194
+ for (const taken of [...this.#taken.values()]) {
195
+ if (taken.collector !== id || replied.has(taken.n)) continue;
196
+ this.#taken.delete(taken.n);
197
+ delete taken.collector;
198
+ this.#waiting.unshift(taken);
199
+ }
190
200
  const wards = wardsOf(this.notes(asker));
191
201
  if (args.wait === true && !this.#waiting.some((b) => wards.includes(b.ward))) await this.#hold(wards);
192
202
  const boxes = this.#waiting.filter((b) => wards.includes(b.ward)).slice(0, BATCH);
@@ -271,6 +281,7 @@ export const relayClient = (ground: Ground, clock: Clock): BeingClass =>
271
281
 
272
282
  #awake = false;
273
283
  #replies: Reply[] = [];
284
+ #turn: Promise<unknown> = Promise.resolve();
274
285
  // Ends every rest and schedule when the client sleeps or lets go.
275
286
  #stop: () => void = () => {};
276
287
 
@@ -380,7 +391,15 @@ export const relayClient = (ground: Ground, clock: Clock): BeingClass =>
380
391
  // One collect: the replies owed go with it, and each box it brings is
381
392
  // handed to this harbor's doors. The number of boxes, or null where the
382
393
  // relay was not reached.
383
- async #collect(wait: boolean): Promise<number | null> {
394
+ // Collects run one after another, so each carries the replies to every
395
+ // box the one before it brought, as the relay counts on.
396
+ #collect(wait: boolean): Promise<number | null> {
397
+ const run = this.#turn.then(() => this.#collectNow(wait));
398
+ this.#turn = run.catch(() => undefined);
399
+ return run;
400
+ }
401
+
402
+ async #collectNow(wait: boolean): Promise<number | null> {
384
403
  const standing = this.stance.standings.get(CLIENT);
385
404
  if (!standing) return null;
386
405
  const replies = this.#replies.splice(0);