nervur 0.21.1 → 0.21.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.
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
  });
@@ -212,6 +217,13 @@ catalogue, and the root's asks write the rest: `add` on the catalogue,
212
217
  `open`, `host`, `route`, and `boot` on a ward. `host { ward, seed? }`
213
218
  stands a ward under a seed you name, or a drawn one.
214
219
 
220
+ A ward's place is kept by the root memory unless you name a store.
221
+ `open { key: 'vault', class: 'org.nervur.ground-store' }` opens the
222
+ store every ground ships, and `host { ward, store: 'vault' }` keeps the
223
+ ward there. A class of yours extending `Store` keeps a ward in a bucket
224
+ or a database the same way, and `move { ward, store }` carries a ward
225
+ between keepers whole.
226
+
215
227
  ## What it holds
216
228
 
217
229
  - **Being, Faculty.** What you extend. A faculty's contract is its class
@@ -19,16 +19,19 @@
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
- // nervur host <ward> [--seed S] a hosted ward stands
31
+ // nervur host <ward> [--seed S] [--store K] a hosted ward stands, kept by the
32
+ // root memory or the store K
33
+ // nervur move <ward> <store | root> a hosted ward carried to a store,
34
+ // or back to the root memory
32
35
  // nervur route <ward pk> <address... | none> where a ward is reached, in order
33
36
  // nervur reach <address...> where this harbor is reached, as
34
37
  // every invitation it gives says
@@ -59,8 +62,8 @@ import { edgeDev } from './edge.js';
59
62
  import { carry, isServed, land, open, pack, request, serve } from './harbor.js';
60
63
  // Where `edge dev` answers unless --port names another, wrangler's own.
61
64
  const EDGE_PORT = 8787;
62
- 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]';
63
- const FLAGS = new Set(['dir', 'ward', 'via', 'seed', 'host', 'port']);
65
+ const USAGE = 'usage: nervur <init|serve|module|modules|census|sweep|open|host|move|route|reach|boot|unboot|public|invite|ask|own|disown|pilot|export|import|edge> [args] [--dir D] [--ward W] [--via V] [--seed S] [--store K] [--with-seed] [--host H] [--port P]';
66
+ const FLAGS = new Set(['dir', 'ward', 'via', 'seed', 'store', 'host', 'port']);
64
67
  // The one flag that stands alone, since what it names is a yes.
65
68
  const ALONE = 'with-seed';
66
69
  export const parse = (argv) => {
@@ -120,7 +123,11 @@ export const requestOf = (command, words, flags) => {
120
123
  }
121
124
  case 'host': {
122
125
  const [ward] = need(1);
123
- return { method: 'host', args: { ward: ward, ...(flags.seed === undefined ? {} : { seed: flags.seed }) } };
126
+ return { method: 'host', args: { ward: ward, ...(flags.seed === undefined ? {} : { seed: flags.seed }), ...(flags.store === undefined ? {} : { store: flags.store }) } };
127
+ }
128
+ case 'move': {
129
+ const [ward, store] = need(2);
130
+ return { method: 'move', args: { ward: ward, store: store === 'root' ? null : store } };
124
131
  }
125
132
  case 'route': {
126
133
  const [ward, ...where] = words;
@@ -200,15 +207,15 @@ export const main = async (argv, env, print) => {
200
207
  return out({ landed: await land(dir, JSON.parse(text)) });
201
208
  }
202
209
  if (command === 'edge') {
203
- if ((words[0] !== 'pack' && words[0] !== 'dev') || words.length !== 2)
204
- 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');
205
212
  if (flags.via !== undefined)
206
213
  throw new Error(`edge ${words[0]} reads this folder, so it takes no --via`);
207
214
  if (await isServed(dir))
208
215
  throw new Error(`a harbor is served from ${dir}: stop it first`);
209
216
  if (words[0] === 'pack')
210
- return out(await pack(dir, words[1]));
211
- 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);
212
219
  const stop = () => void dev.close().then(() => process.exit(0));
213
220
  process.once('SIGINT', stop);
214
221
  process.once('SIGTERM', stop);
@@ -231,7 +238,8 @@ export const main = async (argv, env, print) => {
231
238
  const [name, invitation, at] = words;
232
239
  const held = JSON.parse(invitation);
233
240
  const census = (await request(dir, {}));
234
- 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);
235
243
  for (const req of pilotRequests(name, held, at, web)) {
236
244
  const answer = await request(dir, req);
237
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
@@ -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
+ });