nervur 0.21.2 → 0.21.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.
package/README.md CHANGED
@@ -35,31 +35,29 @@ while it stands. A folder opens in one run at a time. Bundled for a
35
35
  browser, in a page or a worker, it keeps itself in the IndexedDB
36
36
  database `where` names, else `nervur`, its seed sealed under a key the
37
37
  browser never hands out, speaks the web, and opens in one run at a time.
38
- On Cloudflare Workers it is one Durable Object, answering Quo at `/quo`
39
- on whatever host reaches it, and asking over TCP and the web. Anywhere
38
+ On Cloudflare Workers it is one Durable Object of many a worker carries,
39
+ answering Quo at `/<name>/quo` on whatever host reaches it, and asking
40
+ over TCP and the web. Anywhere
40
41
  else it lives as long as its process and speaks the web. Hand in a
41
42
  terrain, as below, and that one is used.
42
43
 
43
44
  ## A harbor on the edge
44
45
 
45
46
  An edge harbor is born on your machine and carried there, so you pilot it
46
- from your own harbor, as you pilot one on a server. The worker is a
47
- folder of its own, an ES module project (`"type": "module"` in its
48
- `package.json`), holding the worker and its `wrangler.toml`:
47
+ from your own harbor, as you pilot one on a server. One worker carries as
48
+ many harbors as you pack into it, each a Durable Object by its name,
49
+ answering at `/<name>/quo`. The worker is a folder of its own, an ES
50
+ module project (`"type": "module"` in its `package.json`), holding the
51
+ worker and its `wrangler.toml`:
49
52
 
50
53
  ```js
51
54
  // worker.js
52
- import { harborObject } from 'nervur/edge';
53
- import carried from './package.edge.json';
55
+ import { harborObject, harborWorker } from 'nervur/edge';
56
+ import pack from './pack.edge.json';
54
57
  import * as greetings from './greetings.js';
55
58
 
56
- export const Harbor = harborObject({ modules: [greetings], package: carried });
57
-
58
- export default {
59
- fetch(request, env) {
60
- return env.HARBOR.get(env.HARBOR.idFromName('main')).fetch(request);
61
- },
62
- };
59
+ export const Harbor = harborObject({ modules: [greetings], pack });
60
+ export default harborWorker();
63
61
  ```
64
62
 
65
63
  ```toml
@@ -83,7 +81,7 @@ where the edge will reach it:
83
81
  ```sh
84
82
  nervur init --dir ./harbor
85
83
  nervur module add ./greetings.js --dir ./harbor
86
- nervur reach http://127.0.0.1:8787/quo --dir ./harbor
84
+ nervur reach http://127.0.0.1:8787/main/quo --dir ./harbor
87
85
  nervur own me --dir ./harbor
88
86
  ```
89
87
 
@@ -91,34 +89,41 @@ nervur own me --dir ./harbor
91
89
  it on this machine first:
92
90
 
93
91
  ```sh
94
- nervur edge dev ./package.edge.json --dir ./harbor
92
+ nervur edge dev ./pack.edge.json main --dir ./harbor
95
93
  ```
96
94
 
97
- `edge dev` packs the harbor into `package.edge.json` and runs your
98
- `wrangler dev` with its seed, keeping the object's storage in
99
- `.nervur-edge`, so the harbor stands again each time you run it. The
100
- first time the object starts, the package lands in its storage and the
101
- harbor opens under that seed; after that the storage is the harbor, and
102
- a new deploy leaves it be. Then, from your own harbor:
95
+ `edge dev` packs the harbor as `main` into `pack.edge.json`, beside any
96
+ harbor packed there before, and runs your `wrangler dev`, keeping the
97
+ objects' storage and a secret of its own in `.nervur-edge`, so the
98
+ harbor stands again each time you run it. The first time its object
99
+ starts, the harbor lands in its storage; after that the storage is the
100
+ harbor, and a new deploy leaves it be. Then, from your own harbor:
103
101
 
104
102
  ```sh
105
103
  nervur pilot edge '<the owner invitation>' --dir ./mine
106
104
  nervur host shop --via edge --dir ./mine
107
105
  ```
108
106
 
109
- For Cloudflare, name the address it will answer at with `reach`, then
110
- `nervur edge pack ./package.edge.json --dir ./harbor` prints the seed and
111
- writes the package. Keep the seed as the worker's secret,
112
- `wrangler secret put NERVUR_SEED`, and `wrangler deploy`.
107
+ For Cloudflare, keep one secret for the worker, `wrangler secret put
108
+ NERVUR_SECRET`, and hold the same one as `NERVUR_SECRET` in your shell.
109
+ Name each harbor's address with `reach`, pack each with
110
+ `nervur edge pack ./pack.edge.json <name> --dir <its folder>`, and
111
+ `wrangler deploy`. A pack holds every seed sealed under that secret, so
112
+ it ships in the bundle and opens nowhere else, and a new harbor is a new
113
+ deploy, never a new secret.
113
114
 
114
115
  `pilot` opens the web carrier where the invitation names a web address.
115
- An object with no package draws its own seed, sealed under the worker's
116
- `NERVUR_SECRET`, and `stood` runs the worker's own code on it as root:
116
+ A harbor may also be born on the edge, under a name `born` allows, with
117
+ a seed of its own sealed the same way, and `stood` runs the worker's own
118
+ code on it as root. A name the pack does not hold and `born` does not
119
+ allow answers 404, so a stranger's request mints nothing:
117
120
 
118
121
  ```js
119
- export const Shop = harborObject({
122
+ export const Harbor = harborObject({
120
123
  modules: [greetings],
121
- stood: async (harbor) => {
124
+ pack,
125
+ born: (name) => name === 'shop',
126
+ stood: async (harbor, name) => {
122
127
  // runs once the harbor stands
123
128
  },
124
129
  });
@@ -29,6 +29,7 @@ export declare class BrowserTerrain extends PointerTerrain {
29
29
  #private;
30
30
  readonly where: string;
31
31
  constructor(parts?: BrowserParts);
32
+ get foreground(): boolean;
32
33
  claim(): Promise<void>;
33
34
  release(): Promise<void>;
34
35
  }
@@ -156,6 +156,14 @@ export class BrowserTerrain extends PointerTerrain {
156
156
  super({ entropy, loader: new HeldLoader(parts.modules ?? []), custody: new IdbCustody(where, entropy), memory: new IdbMemory(where), faculties: [webFaculty(), webPush], opens: [WEB_OPENING] });
157
157
  this.where = where;
158
158
  }
159
+ // A shared or a service worker outlives any one tab, and a service
160
+ // worker is woken for a push with none open. Anywhere else, a page or a
161
+ // worker a tab made, the harbor lives with that tab.
162
+ get foreground() {
163
+ const scope = globalThis;
164
+ const is = (name) => typeof scope[name] === 'function' && globalThis instanceof scope[name];
165
+ return !is('SharedWorkerGlobalScope') && !is('ServiceWorkerGlobalScope');
166
+ }
159
167
  async claim() {
160
168
  const locks = globalThis.navigator?.locks;
161
169
  if (!locks)
@@ -19,14 +19,14 @@
19
19
  // --with-seed the seed goes too
20
20
  // nervur import <file> a carried harbor into an empty
21
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
26
- // nervur edge dev <file> [--port P] the harbor packed to the file and
22
+ // nervur edge pack <file> <name> the harbor for an edge, under its
23
+ // name, in the pack the file holds:
24
+ // its seed sealed under
25
+ // $NERVUR_SECRET, and its places
26
+ // nervur edge dev <file> <name> [--port P] the harbor packed so, and the pack
27
27
  // run by the worker's own wrangler
28
28
  // dev, from this folder, at
29
- // http://127.0.0.1:P/quo
29
+ // http://127.0.0.1:P/<name>/quo
30
30
  // nervur open <key> <kind> a faculty stands in the box ward
31
31
  // nervur host <ward> [--seed S] [--store K] a hosted ward stands, kept by the
32
32
  // root memory or the store K
@@ -207,15 +207,15 @@ export const main = async (argv, env, print) => {
207
207
  return out({ landed: await land(dir, JSON.parse(text)) });
208
208
  }
209
209
  if (command === 'edge') {
210
- if ((words[0] !== 'pack' && words[0] !== 'dev') || words.length !== 2)
211
- throw new Error('edge takes pack or dev, and a file');
210
+ if ((words[0] !== 'pack' && words[0] !== 'dev') || words.length !== 3)
211
+ throw new Error('edge takes pack or dev, a file, and the harbor name');
212
212
  if (flags.via !== undefined)
213
213
  throw new Error(`edge ${words[0]} reads this folder, so it takes no --via`);
214
214
  if (await isServed(dir))
215
215
  throw new Error(`a harbor is served from ${dir}: stop it first`);
216
216
  if (words[0] === 'pack')
217
- return out(await pack(dir, words[1]));
218
- const dev = await edgeDev(dir, words[1], process.cwd(), flags.port === undefined ? EDGE_PORT : Number(flags.port));
217
+ return out(await pack(dir, words[1], words[2], env.NERVUR_SECRET));
218
+ const dev = await edgeDev(dir, words[1], words[2], process.cwd(), flags.port === undefined ? EDGE_PORT : Number(flags.port), env.NERVUR_SECRET);
219
219
  const stop = () => void dev.close().then(() => process.exit(0));
220
220
  process.once('SIGINT', stop);
221
221
  process.once('SIGTERM', stop);
@@ -238,7 +238,8 @@ export const main = async (argv, env, print) => {
238
238
  const [name, invitation, at] = words;
239
239
  const held = JSON.parse(invitation);
240
240
  const census = (await request(dir, {}));
241
- const web = census.answer?.beings !== undefined && Object.hasOwn(census.answer.beings, WEB_OPENING.key);
241
+ const beings = census.answer?.notes?.beings;
242
+ const web = beings !== undefined && Object.hasOwn(beings, WEB_OPENING.key);
242
243
  for (const req of pilotRequests(name, held, at, web)) {
243
244
  const answer = await request(dir, req);
244
245
  if (!failed(answer))
@@ -4,4 +4,4 @@ export type EdgeDev = {
4
4
  readonly places: number;
5
5
  close(): Promise<void>;
6
6
  };
7
- export declare const edgeDev: (dir: string, file: string, cwd: string, port: number) => Promise<EdgeDev>;
7
+ export declare const edgeDev: (dir: string, file: string, name: string, cwd: string, port: number, named?: string) => Promise<EdgeDev>;
package/dist/cli/edge.js CHANGED
@@ -1,16 +1,22 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // `nervur edge dev`: an edge harbor on this machine, as it will stand on
3
- // Cloudflare. The harbor of a folder is packed into the file the worker
4
- // imports, and the worker's own `wrangler dev` runs it, in the folder the
5
- // command runs from, with the seed handed as `NERVUR_SEED` through the
6
- // environment so no listing of processes shows it. The object's storage is
7
- // kept under `.nervur-edge` there, so a harbor landed once stands again
8
- // across restarts, as it does on Cloudflare.
3
+ // Cloudflare. The harbor of a folder is packed under its name into the
4
+ // pack the worker imports, beside the harbors the pack holds already, and
5
+ // the worker's own `wrangler dev` runs them all, in the folder the command
6
+ // runs from, with the secret handed as `NERVUR_SECRET` through the
7
+ // environment so no listing of processes shows it. The secret is the one
8
+ // the command's environment names, or one drawn once and kept under
9
+ // `.nervur-edge`, beside the objects' storage, so a harbor landed once
10
+ // stands again across restarts, as it does on Cloudflare.
9
11
  //
10
12
  // wrangler is the worker's tool and never the kit's: the one who deploys
11
13
  // an edge has it already, and `edge dev` finds it where npm put it.
12
14
  import { spawn } from 'node:child_process';
15
+ import { existsSync } from 'node:fs';
16
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
13
17
  import { join } from 'node:path';
18
+ import { hex } from '../crypto/index.js';
19
+ import { CryptoEntropy } from '../pointer/index.js';
14
20
  import { pack } from './harbor.js';
15
21
  export const EDGE_STATE = '.nervur-edge';
16
22
  const READY_TRIES = 240;
@@ -30,17 +36,30 @@ const ready = async (child, url, said) => {
30
36
  }
31
37
  throw new Error(`wrangler dev never answered at ${url}: ${said()}`);
32
38
  };
33
- export const edgeDev = async (dir, file, cwd, port) => {
34
- const { seed, places } = (await pack(dir, file));
35
- const child = spawn('npx', ['--no-install', 'wrangler', 'dev', '--ip', '127.0.0.1', '--port', String(port), '--persist-to', join(cwd, EDGE_STATE), '--log-level', 'warn'], {
39
+ // The secret `edge dev` runs under: the one named, or the one kept here.
40
+ const devSecret = async (state, named) => {
41
+ if (named)
42
+ return named;
43
+ const file = join(state, 'secret');
44
+ if (!existsSync(file)) {
45
+ await mkdir(state, { recursive: true, mode: 0o700 });
46
+ await writeFile(file, `${hex(new CryptoEntropy().draw(32))}\n`, { mode: 0o600, flag: 'wx' });
47
+ }
48
+ return (await readFile(file, 'utf8')).trim();
49
+ };
50
+ export const edgeDev = async (dir, file, name, cwd, port, named) => {
51
+ const state = join(cwd, EDGE_STATE);
52
+ const secret = await devSecret(state, named);
53
+ const { places } = (await pack(dir, file, name, secret));
54
+ const child = spawn('npx', ['--no-install', 'wrangler', 'dev', '--ip', '127.0.0.1', '--port', String(port), '--persist-to', state, '--log-level', 'warn'], {
36
55
  cwd,
37
- env: { ...process.env, NERVUR_SEED: seed, CLOUDFLARE_INCLUDE_PROCESS_ENV: 'true' },
56
+ env: { ...process.env, NERVUR_SECRET: secret, CLOUDFLARE_INCLUDE_PROCESS_ENV: 'true' },
38
57
  stdio: ['ignore', 'ignore', 'pipe'],
39
58
  detached: true,
40
59
  });
41
60
  let err = '';
42
61
  child.stderr.on('data', (chunk) => (err = `${err}${chunk.toString('utf8')}`.slice(-4000)));
43
- const at = `http://127.0.0.1:${String(port)}/quo`;
62
+ const at = `http://127.0.0.1:${String(port)}/${name}/quo`;
44
63
  const close = () => new Promise((done) => {
45
64
  if (child.exitCode !== null)
46
65
  return done();
@@ -4,7 +4,7 @@ export declare const open: (dir: string) => Promise<Harbor>;
4
4
  export declare const isServed: (dir: string) => Promise<boolean>;
5
5
  export declare const request: (dir: string, req: RootRequest) => Promise<JsonObject>;
6
6
  export declare const carry: (dir: string, seed: boolean) => Promise<Carried>;
7
- export declare const pack: (dir: string, file: string) => Promise<JsonObject>;
7
+ export declare const pack: (dir: string, file: string, name: string, secret: string | undefined) => Promise<JsonObject>;
8
8
  export declare const land: (dir: string, value: unknown) => Promise<string[]>;
9
9
  export type Served = {
10
10
  pk: string;
@@ -9,7 +9,8 @@
9
9
  import { existsSync } from 'node:fs';
10
10
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
- import { hex } from '../crypto/index.js';
12
+ import { hex, unhex } from '../crypto/index.js';
13
+ import { isHarborName, readPack, sealSeed } from '../edge/index.js';
13
14
  import { FolderCustody, FolderMemory } from '../folder/index.js';
14
15
  import { carryPlaces, Harbor, landPlaces, readCarried, rootLine } from '../harbor/index.js';
15
16
  import { askServed, MODULES, NodeTerrain, socketOf } from '../node/index.js';
@@ -45,14 +46,25 @@ export const carry = async (dir, seed) => {
45
46
  ...(modules ? { modules } : {}),
46
47
  };
47
48
  };
48
- // What `nervur edge pack` hands a deploy: the seed, for the worker's
49
- // `NERVUR_SEED`, and the sealed package without it, for the bundle.
50
- export const pack = async (dir, file) => {
49
+ // What `nervur edge pack` hands a deploy: the harbor of a folder, under
50
+ // its name, in the pack the worker bundles, its seed sealed under the
51
+ // worker's `NERVUR_SECRET` beside its sealed places. Every other harbor
52
+ // the pack holds stays, so one worker carries them all, and nothing in the
53
+ // pack opens without the secret.
54
+ export const pack = async (dir, file, name, secret) => {
55
+ if (!isHarborName(name))
56
+ throw new Error(`${name} is no harbor name: lowercase letters, digits and -`);
57
+ if (!secret)
58
+ throw new Error('edge pack seals the seed under NERVUR_SECRET, and none is set');
51
59
  if (!existsSync(join(dir, 'seed')))
52
60
  throw new Error(`no harbor stands at ${dir}`);
53
61
  const { seed, places } = await carry(dir, true);
54
- await writeFile(file, `${JSON.stringify({ places })}\n`, { mode: 0o600 });
55
- return { seed: seed, package: file, places: Object.keys(places).length };
62
+ const held = existsSync(file) ? readPack(JSON.parse(await readFile(file, 'utf8'))) : {};
63
+ if (held === null)
64
+ throw new Error(`${file} is no pack`);
65
+ const packed = { ...held, [name]: { sealed: await sealSeed(secret, unhex(seed)), places } };
66
+ await writeFile(file, `${JSON.stringify(packed)}\n`, { mode: 0o600 });
67
+ return { packed: name, package: file, harbors: Object.keys(packed).sort(), places: Object.keys(places).length };
56
68
  };
57
69
  // A carried harbor into an empty folder. A folder holding a seed or a
58
70
  // place is left as it stands, and a package with no seed needs one there
@@ -34,6 +34,7 @@ export type Loaded = Module & {
34
34
  };
35
35
  export declare abstract class Loader {
36
36
  abstract load(from: string): Promise<Loaded | null>;
37
+ missing(_from: string): string | null;
37
38
  }
38
39
  export declare abstract class Carrier {
39
40
  abstract carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | null>;
@@ -22,6 +22,11 @@ export class Custody {
22
22
  // crosses a door and never lives in a cell, so it is the
23
23
  // one thing beside the seed a terrain hands in: a map, an import, a bundle.
24
24
  export class Loader {
25
+ // Why a name it found nothing under holds nothing, where it can say more
26
+ // than that: a bundle names that code comes from it alone.
27
+ missing(_from) {
28
+ return null;
29
+ }
25
30
  }
26
31
  // Bytes to a ward pk in; bytes, or null for nothing, out. A harbor hands a
27
32
  // terrain's carrier what it holds no route for; what it routes goes to its
@@ -18,14 +18,21 @@ export interface EdgeState {
18
18
  blockConcurrencyWhile<T>(work: () => Promise<T>): Promise<T>;
19
19
  }
20
20
  export type EdgeEnv = {
21
- readonly NERVUR_SEED?: string;
22
21
  readonly NERVUR_SECRET?: string;
23
22
  };
24
23
  export type EdgeGiven = {
25
24
  readonly state: EdgeState;
26
25
  readonly env: EdgeEnv;
27
- readonly package?: Carried;
26
+ readonly package?: Packed;
28
27
  };
28
+ export type Packed = {
29
+ readonly sealed: string;
30
+ readonly places: Carried['places'];
31
+ };
32
+ export type Pack = Record<string, Packed>;
33
+ export declare const isHarborName: (name: unknown) => name is string;
34
+ export declare const readPack: (value: unknown) => Pack | null;
35
+ export declare const sealSeed: (secret: string, seed: Uint8Array, entropy?: Entropy) => Promise<string>;
29
36
  export declare class EdgeMemory extends Memory {
30
37
  #private;
31
38
  constructor(storage: EdgeStorage, root?: string);
@@ -34,13 +41,11 @@ export declare class EdgeMemory extends Memory {
34
41
  places(): Promise<string[]>;
35
42
  forget(place: string): Promise<void>;
36
43
  }
37
- export type EdgeKeys = {
38
- readonly seed?: string | undefined;
39
- readonly secret?: string | undefined;
40
- };
41
44
  export declare class EdgeCustody extends Custody {
42
45
  #private;
43
- constructor(storage: EdgeStorage, keys: EdgeKeys, entropy?: Entropy, root?: string);
46
+ constructor(storage: EdgeStorage, secret: string | undefined, entropy?: Entropy, root?: string);
47
+ kept(): Promise<boolean>;
48
+ land(sealed: string, txn?: EdgeStorage): Promise<void>;
44
49
  seed(): Promise<Uint8Array>;
45
50
  }
46
51
  export declare const edgeTcp: GroundFaculty;
@@ -58,14 +63,19 @@ export declare class EdgeTerrain extends PointerTerrain {
58
63
  answer(request: Request): Promise<Response>;
59
64
  }
60
65
  export declare const edgeGround: GroundProbe;
66
+ export declare const harborNameOf: (request: Request) => string | null;
61
67
  export type HarborObjectParts = {
62
68
  readonly modules: readonly Module[];
63
- readonly package?: Carried | ((env: EdgeEnv) => Carried | undefined);
64
- readonly path?: string;
65
- readonly stood?: (harbor: Harbor) => Promise<void>;
69
+ readonly pack?: Pack | ((env: EdgeEnv) => Pack | undefined);
70
+ readonly born?: (name: string) => boolean;
71
+ readonly stood?: (harbor: Harbor, name: string) => Promise<void>;
66
72
  };
67
- export type HarborObjectClass = new (state: EdgeState, env: EdgeEnv) => {
68
- readonly harbor: Promise<Harbor>;
73
+ export type HarborObject = {
74
+ harborFor(request: Request): Promise<Harbor | null>;
69
75
  fetch(request: Request): Promise<Response>;
70
76
  };
71
- export declare const harborObject: ({ modules, package: carried, path, stood }: HarborObjectParts) => HarborObjectClass;
77
+ export type HarborObjectClass = new (state: EdgeState, env: EdgeEnv) => HarborObject;
78
+ export declare const harborObject: ({ modules, pack, born, stood }: HarborObjectParts) => HarborObjectClass;
79
+ export declare const harborWorker: (binding?: string) => {
80
+ fetch(request: Request, env: Record<string, unknown>): Promise<Response>;
81
+ };
@@ -7,8 +7,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  import { Custody, Memory } from '../contract/index.js';
10
- import { decrypt, encrypt, hkdf, isHex, unhex, utf8 } from '../crypto/index.js';
11
- import { addGround, CarrierFaculty, Harbor, landPlaces, UNDIALED } from '../harbor/index.js';
10
+ import { concat, decrypt, encrypt, hex, hkdf, isHex, unhex, utf8 } from '../crypto/index.js';
11
+ import { addGround, CarrierFaculty, Harbor, landPlaces, readCarried, UNDIALED } from '../harbor/index.js';
12
12
  import { answerHeld, answerPost, askFrame, FrameReader, TCP, TCP_OPENING, WEB_OPENING, webFaculty, webPush } from '../line/index.js';
13
13
  import { CryptoEntropy, HeldLoader, PointerTerrain } from '../pointer/index.js';
14
14
  import { tcpAddress, tcpAt } from '../quo/index.js';
@@ -20,6 +20,30 @@ const SEED = 'c/seed';
20
20
  const BATCH = 128;
21
21
  const SEAL_INFO = utf8('nervur edge custody');
22
22
  const MAX_ID = 0xffff_ffff;
23
+ // A harbor's name on the edge: one path segment, so it is where the
24
+ // harbor answers, `/<name>/quo`.
25
+ export const isHarborName = (name) => typeof name === 'string' && /^[a-z0-9][a-z0-9-]{0,62}$/.test(name);
26
+ const NONCE = 12;
27
+ // A pack as JSON reads, or null for anything else.
28
+ export const readPack = (value) => {
29
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
30
+ return null;
31
+ const pack = {};
32
+ for (const [name, entry] of Object.entries(value)) {
33
+ const { sealed, places } = (entry ?? {});
34
+ const carried = readCarried({ places });
35
+ if (!isHarborName(name) || !isHex(sealed) || sealed.length <= NONCE * 2 || carried === null)
36
+ return null;
37
+ pack[name] = { sealed: sealed, places: carried.places };
38
+ }
39
+ return pack;
40
+ };
41
+ const sealKey = (secret) => hkdf(utf8(secret), SEAL_INFO, 32);
42
+ // A seed sealed under a worker's secret, as a pack keeps it.
43
+ export const sealSeed = async (secret, seed, entropy = new CryptoEntropy()) => {
44
+ const nonce = entropy.draw(NONCE);
45
+ return hex(concat(nonce, await encrypt(await sealKey(secret), nonce, new Uint8Array(0), seed)));
46
+ };
23
47
  const chunks = (items) => Array.from({ length: Math.ceil(items.length / BATCH) }, (_, i) => items.slice(i * BATCH, (i + 1) * BATCH));
24
48
  const bytesOf = (value) => new Uint8Array(value instanceof ArrayBuffer ? value : value);
25
49
  // A body keeps its keys under `root`, so one object may hold more than one
@@ -66,28 +90,34 @@ export class EdgeMemory extends Memory {
66
90
  });
67
91
  }
68
92
  }
93
+ // The seed, kept sealed under a key the worker's secret gives, so the
94
+ // storage alone opens nothing: the one a pack landed, or one drawn the
95
+ // first time it is asked for. A worker with no secret stands no harbor.
69
96
  export class EdgeCustody extends Custody {
70
97
  #storage;
71
- #keys;
98
+ #secret;
72
99
  #entropy;
73
100
  #key;
74
- constructor(storage, keys, entropy = new CryptoEntropy(), root = '') {
101
+ constructor(storage, secret, entropy = new CryptoEntropy(), root = '') {
75
102
  super();
76
103
  this.#storage = storage;
77
- this.#keys = keys;
104
+ this.#secret = secret;
78
105
  this.#entropy = entropy;
79
106
  this.#key = `${root}${SEED}`;
80
107
  }
108
+ // Whether a seed is kept, so a pack lands only where none is.
109
+ async kept() {
110
+ return (await this.#storage.get(this.#key)) !== undefined;
111
+ }
112
+ // A packed seed, sealed as `sealSeed` sealed it, kept as it came.
113
+ async land(sealed, txn = this.#storage) {
114
+ const bytes = unhex(sealed);
115
+ await txn.put({ [this.#key]: { nonce: bytes.slice(0, NONCE), sealed: bytes.slice(NONCE) } });
116
+ }
81
117
  async seed() {
82
- const { seed: named, secret } = this.#keys;
83
- if (named !== undefined) {
84
- if (!isHex(named.trim(), 32))
85
- throw new Error('NERVUR_SEED is not 64 hex');
86
- return unhex(named.trim());
87
- }
88
- if (!secret)
118
+ if (!this.#secret)
89
119
  throw new Error('the worker holds no NERVUR_SECRET');
90
- const key = await hkdf(utf8(secret), SEAL_INFO, 32);
120
+ const key = await sealKey(this.#secret);
91
121
  return this.#storage.transaction(async (txn) => {
92
122
  const kept = (await txn.get(this.#key));
93
123
  if (kept) {
@@ -97,7 +127,7 @@ export class EdgeCustody extends Custody {
97
127
  return seed;
98
128
  }
99
129
  const seed = this.#entropy.draw(32);
100
- const nonce = this.#entropy.draw(12);
130
+ const nonce = this.#entropy.draw(NONCE);
101
131
  await txn.put({ [this.#key]: { nonce, sealed: await encrypt(key, nonce, new Uint8Array(0), seed) } });
102
132
  return seed.slice();
103
133
  });
@@ -245,24 +275,24 @@ export class EdgeTerrain extends PointerTerrain {
245
275
  super({
246
276
  entropy,
247
277
  loader: new HeldLoader(parts.modules ?? []),
248
- // Only a harbor carried here takes the worker's NERVUR_SEED; every
249
- // other object of the worker draws its own.
250
- custody: new EdgeCustody(storage, { seed: parts.given.package ? parts.given.env.NERVUR_SEED : undefined, secret: parts.given.env.NERVUR_SECRET }, entropy),
278
+ custody: new EdgeCustody(storage, parts.given.env.NERVUR_SECRET, entropy),
251
279
  memory: new EdgeMemory(storage),
252
280
  faculties: [edgeTcp, webFaculty(edgeServe), webPush],
253
281
  opens: [TCP_OPENING, WEB_OPENING],
254
282
  });
255
283
  this.given = parts.given;
256
284
  }
257
- // A carried package lands where the object keeps nothing yet, and only
258
- // under the seed it was sealed with, the worker's `NERVUR_SEED`.
285
+ // A packed harbor lands where the object keeps no seed yet: its places
286
+ // first and its seed last, so a start cut short lands it whole again,
287
+ // and once the seed is kept the storage is the harbor and a new deploy
288
+ // leaves it be. A seed sealed under another secret does not open.
259
289
  async claim() {
260
- const { package: carried, env } = this.given;
261
- if (!carried || (await this.memory.places()).length > 0)
290
+ const packed = this.given.package;
291
+ const custody = this.custody;
292
+ if (!packed || (await custody.kept()))
262
293
  return;
263
- if (env.NERVUR_SEED === undefined)
264
- throw new Error('a carried package opens only under NERVUR_SEED');
265
- await landPlaces(this.memory, carried);
294
+ await landPlaces(this.memory, { places: packed.places });
295
+ await custody.land(packed.sealed);
266
296
  }
267
297
  stand(harbor) {
268
298
  this.#harbor = harbor;
@@ -299,32 +329,56 @@ export const edgeGround = {
299
329
  fits: ({ given }) => isGiven(given),
300
330
  terrain: ({ given, modules }) => new EdgeTerrain({ given: given, ...(modules ? { modules } : {}) }),
301
331
  };
302
- // A Durable Object class whose every instance is one harbor: opened once
303
- // per object, its web carrier opened, and every request it receives
304
- // answered by that carrier at `path`.
305
- export const harborObject = ({ modules, package: carried, path = '/quo', stood }) => class HarborObject {
332
+ // The harbor a request is for: the first segment of its path.
333
+ export const harborNameOf = (request) => {
334
+ const name = new URL(request.url).pathname.split('/')[1];
335
+ return isHarborName(name) ? name : null;
336
+ };
337
+ // A Durable Object class whose every instance is one harbor, by the name
338
+ // the worker reached it under: opened at its first request, the one its
339
+ // pack holds under that name or one `born` allows, its web carrier opened,
340
+ // and every request it receives answered by that carrier at
341
+ // `/<name>/quo`. An object answers the one name it opened under, and a
342
+ // name it may not open is 404, with nothing written.
343
+ export const harborObject = ({ modules, pack, born, stood }) => class {
344
+ #state;
345
+ #env;
346
+ #name;
306
347
  #opening;
307
- // The object's harbor, once it stands.
308
- get harbor() {
309
- return this.#opening;
310
- }
311
348
  constructor(state, env) {
312
349
  addGround(edgeGround);
313
- const held = typeof carried === 'function' ? carried(env) : carried;
314
- const given = { state, env, ...(held ? { package: held } : {}) };
350
+ this.#state = state;
351
+ this.#env = env;
352
+ }
353
+ // The harbor a request names, opened once, or null where it names
354
+ // another harbor or none.
355
+ harborFor(request) {
356
+ const name = harborNameOf(request);
357
+ if (name === null || (this.#name !== undefined && this.#name !== name))
358
+ return Promise.resolve(null);
359
+ const packed = typeof pack === 'function' ? pack(this.#env) : pack;
360
+ const held = packed && Object.hasOwn(packed, name) ? packed[name] : undefined;
361
+ if (this.#name === undefined && !held && !born?.(name))
362
+ return Promise.resolve(null);
363
+ this.#name = name;
315
364
  // Opened inside the object's block, so no request reaches it half
316
365
  // open and its storage answers the opening.
317
- this.#opening = state.blockConcurrencyWhile(async () => {
366
+ this.#opening ??= this.#state.blockConcurrencyWhile(async () => {
367
+ const given = { state: this.#state, env: this.#env, ...(held ? { package: held } : {}) };
318
368
  const harbor = await Harbor.open({ modules, given });
319
369
  if (!harbor.box.being(WEB_OPENING.key))
320
370
  await harbor.root('open', { key: WEB_OPENING.key, class: WEB_OPENING.kind });
321
- await stood?.(harbor);
371
+ await stood?.(harbor, name);
322
372
  return harbor;
323
373
  });
374
+ return this.#opening;
324
375
  }
325
376
  async fetch(request) {
326
- const harbor = await this.#opening;
377
+ const harbor = await this.harborFor(request);
378
+ if (!harbor)
379
+ return new Response(null, { status: 404 });
327
380
  const terrain = harbor.terrain;
381
+ const path = `/${this.#name}/quo`;
328
382
  if (terrain.listening?.path !== path) {
329
383
  const url = new URL(request.url);
330
384
  await harbor.root('ask', { being: WEB_OPENING.key, method: 'listen', args: { host: url.hostname, port: url.port === '' ? 0 : Number(url.port), path } });
@@ -332,3 +386,15 @@ export const harborObject = ({ modules, package: carried, path = '/quo', stood }
332
386
  return terrain.answer(request);
333
387
  }
334
388
  };
389
+ // A worker of many harbors: each request goes to the object of the
390
+ // harbor its path names, in the Durable Object namespace bound as
391
+ // `binding`, and a path naming none is 404.
392
+ export const harborWorker = (binding = 'HARBOR') => ({
393
+ fetch(request, env) {
394
+ const name = harborNameOf(request);
395
+ const objects = env[binding];
396
+ if (name === null || !objects)
397
+ return Promise.resolve(new Response(null, { status: 404 }));
398
+ return objects.get(objects.idFromName(name)).fetch(request);
399
+ },
400
+ });
@@ -8,6 +8,7 @@ export type ModuleRow = {
8
8
  };
9
9
  export interface Cataloguing {
10
10
  load(from: string): Promise<Loaded | null>;
11
+ missing(from: string): string | null;
11
12
  check(modules: readonly Loaded[]): string | null;
12
13
  run(modules: readonly Loaded[]): string | null;
13
14
  stands(kinds: readonly string[]): boolean;