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/src/edge/index.ts CHANGED
@@ -4,23 +4,25 @@
4
4
  // object answers Quo over the web, a post or a held line, since a worker
5
5
  // takes no inbound TCP; and it asks over TCP and the web.
6
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
+ // One worker carries many harbors, each a Durable Object by its name,
8
+ // answering at `/<name>/quo`, and one secret, `NERVUR_SECRET`, for all of
9
+ // them. A harbor born on another ground is carried here in a pack: by
10
+ // name, its seed sealed under that secret and its sealed places, bundled
11
+ // with the worker and landed the first time its object starts. So a
12
+ // harbor is added by a deploy, never by a new secret. Its owner holds the
13
+ // owner invitation minted where it was born, and pilots it over the web.
14
+ // A harbor born here draws its seed and seals it the same way.
13
15
  //
14
16
  // m/<place>/<name> one sealed entry of the harbor's memory
15
- // c/seed a drawn seed, sealed under a key from the secret
17
+ // c/seed the seed, sealed under a key from the secret
16
18
  //
17
19
  // A keep is one storage transaction, whole or nothing. The storage and
18
20
  // state types name only what the kit uses, so no Cloudflare package is a
19
21
  // dependency.
20
22
  import type { AskSpec, JsonObject } from '../being/index.ts';
21
23
  import { Custody, Memory, type Entropy, type Module } from '../contract/index.ts';
22
- import { decrypt, encrypt, hkdf, isHex, unhex, utf8 } from '../crypto/index.ts';
23
- import { addGround, CarrierFaculty, Harbor, landPlaces, UNDIALED, type Carried, type Dialed, type Ground, type GroundFaculty, type GroundProbe } from '../harbor/index.ts';
24
+ import { concat, decrypt, encrypt, hex, hkdf, isHex, unhex, utf8 } from '../crypto/index.ts';
25
+ import { addGround, CarrierFaculty, Harbor, landPlaces, readCarried, UNDIALED, type Carried, type Dialed, type Ground, type GroundFaculty, type GroundProbe } from '../harbor/index.ts';
24
26
  import { answerHeld, answerPost, askFrame, FrameReader, TCP, TCP_OPENING, WEB_OPENING, webFaculty, webPush, type WebListen, type WebListener } from '../line/index.ts';
25
27
  import { CryptoEntropy, HeldLoader, PointerTerrain } from '../pointer/index.ts';
26
28
  import { tcpAddress, tcpAt, type TcpAddress } from '../quo/index.ts';
@@ -47,10 +49,42 @@ export interface EdgeState {
47
49
  readonly storage: EdgeStorage;
48
50
  blockConcurrencyWhile<T>(work: () => Promise<T>): Promise<T>;
49
51
  }
50
- export type EdgeEnv = { readonly NERVUR_SEED?: string; readonly NERVUR_SECRET?: string };
52
+ export type EdgeEnv = { readonly NERVUR_SECRET?: string };
51
53
  // 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 };
54
+ // and a packed harbor to land where the object keeps no seed yet.
55
+ export type EdgeGiven = { readonly state: EdgeState; readonly env: EdgeEnv; readonly package?: Packed };
56
+
57
+ // One harbor packed for an edge: its seed sealed under the worker's
58
+ // secret, as hex of the nonce and the sealed bytes, and its sealed places.
59
+ // A pack is every harbor of a worker, by name.
60
+ export type Packed = { readonly sealed: string; readonly places: Carried['places'] };
61
+ export type Pack = Record<string, Packed>;
62
+
63
+ // A harbor's name on the edge: one path segment, so it is where the
64
+ // harbor answers, `/<name>/quo`.
65
+ export const isHarborName = (name: unknown): name is string => typeof name === 'string' && /^[a-z0-9][a-z0-9-]{0,62}$/.test(name);
66
+ const NONCE = 12;
67
+
68
+ // A pack as JSON reads, or null for anything else.
69
+ export const readPack = (value: unknown): Pack | null => {
70
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
71
+ const pack: Record<string, Packed> = {};
72
+ for (const [name, entry] of Object.entries(value)) {
73
+ const { sealed, places } = (entry ?? {}) as { sealed?: unknown; places?: unknown };
74
+ const carried = readCarried({ places });
75
+ if (!isHarborName(name) || !isHex(sealed) || (sealed as string).length <= NONCE * 2 || carried === null) return null;
76
+ pack[name] = { sealed: sealed as string, places: carried.places };
77
+ }
78
+ return pack;
79
+ };
80
+
81
+ const sealKey = (secret: string): Promise<Uint8Array> => hkdf(utf8(secret), SEAL_INFO, 32);
82
+
83
+ // A seed sealed under a worker's secret, as a pack keeps it.
84
+ export const sealSeed = async (secret: string, seed: Uint8Array, entropy: Entropy = new CryptoEntropy()): Promise<string> => {
85
+ const nonce = entropy.draw(NONCE);
86
+ return hex(concat(nonce, await encrypt(await sealKey(secret), nonce, new Uint8Array(0), seed)));
87
+ };
54
88
 
55
89
  const chunks = <T>(items: readonly T[]): T[][] => Array.from({ length: Math.ceil(items.length / BATCH) }, (_, i) => items.slice(i * BATCH, (i + 1) * BATCH));
56
90
  const bytesOf = (value: unknown): Uint8Array => new Uint8Array(value instanceof ArrayBuffer ? value : (value as Uint8Array));
@@ -99,33 +133,36 @@ export class EdgeMemory extends Memory {
99
133
  }
100
134
  }
101
135
 
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
-
136
+ // The seed, kept sealed under a key the worker's secret gives, so the
137
+ // storage alone opens nothing: the one a pack landed, or one drawn the
138
+ // first time it is asked for. A worker with no secret stands no harbor.
108
139
  export class EdgeCustody extends Custody {
109
140
  readonly #storage: EdgeStorage;
110
- readonly #keys: EdgeKeys;
141
+ readonly #secret: string | undefined;
111
142
  readonly #entropy: Entropy;
112
143
  readonly #key: string;
113
- constructor(storage: EdgeStorage, keys: EdgeKeys, entropy: Entropy = new CryptoEntropy(), root = '') {
144
+ constructor(storage: EdgeStorage, secret: string | undefined, entropy: Entropy = new CryptoEntropy(), root = '') {
114
145
  super();
115
146
  this.#storage = storage;
116
- this.#keys = keys;
147
+ this.#secret = secret;
117
148
  this.#entropy = entropy;
118
149
  this.#key = `${root}${SEED}`;
119
150
  }
120
151
 
152
+ // Whether a seed is kept, so a pack lands only where none is.
153
+ async kept(): Promise<boolean> {
154
+ return (await this.#storage.get(this.#key)) !== undefined;
155
+ }
156
+
157
+ // A packed seed, sealed as `sealSeed` sealed it, kept as it came.
158
+ async land(sealed: string, txn: EdgeStorage = this.#storage): Promise<void> {
159
+ const bytes = unhex(sealed);
160
+ await txn.put({ [this.#key]: { nonce: bytes.slice(0, NONCE), sealed: bytes.slice(NONCE) } });
161
+ }
162
+
121
163
  async seed(): Promise<Uint8Array> {
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);
164
+ if (!this.#secret) throw new Error('the worker holds no NERVUR_SECRET');
165
+ const key = await sealKey(this.#secret);
129
166
  return this.#storage.transaction(async (txn) => {
130
167
  const kept = (await txn.get(this.#key)) as { nonce: Uint8Array; sealed: Uint8Array } | undefined;
131
168
  if (kept) {
@@ -134,7 +171,7 @@ export class EdgeCustody extends Custody {
134
171
  return seed;
135
172
  }
136
173
  const seed = this.#entropy.draw(32);
137
- const nonce = this.#entropy.draw(12);
174
+ const nonce = this.#entropy.draw(NONCE);
138
175
  await txn.put({ [this.#key]: { nonce, sealed: await encrypt(key, nonce, new Uint8Array(0), seed) } });
139
176
  return seed.slice();
140
177
  });
@@ -303,9 +340,7 @@ export class EdgeTerrain extends PointerTerrain {
303
340
  super({
304
341
  entropy,
305
342
  loader: new HeldLoader(parts.modules ?? []),
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),
343
+ custody: new EdgeCustody(storage, parts.given.env.NERVUR_SECRET, entropy),
309
344
  memory: new EdgeMemory(storage),
310
345
  faculties: [edgeTcp, webFaculty(edgeServe), webPush],
311
346
  opens: [TCP_OPENING, WEB_OPENING],
@@ -313,13 +348,16 @@ export class EdgeTerrain extends PointerTerrain {
313
348
  this.given = parts.given;
314
349
  }
315
350
 
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`.
351
+ // A packed harbor lands where the object keeps no seed yet: its places
352
+ // first and its seed last, so a start cut short lands it whole again,
353
+ // and once the seed is kept the storage is the harbor and a new deploy
354
+ // leaves it be. A seed sealed under another secret does not open.
318
355
  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);
356
+ const packed = this.given.package;
357
+ const custody = this.custody as EdgeCustody;
358
+ if (!packed || (await custody.kept())) return;
359
+ await landPlaces(this.memory, { places: packed.places });
360
+ await custody.land(packed.sealed);
323
361
  }
324
362
 
325
363
  override stand(harbor: Harbor): Promise<void> {
@@ -358,52 +396,77 @@ export const edgeGround: GroundProbe = {
358
396
  terrain: ({ given, modules }) => new EdgeTerrain({ given: given as EdgeGiven, ...(modules ? { modules } : {}) }),
359
397
  };
360
398
 
399
+ // The harbor a request is for: the first segment of its path.
400
+ export const harborNameOf = (request: Request): string | null => {
401
+ const name = new URL(request.url).pathname.split('/')[1];
402
+ return isHarborName(name) ? name : null;
403
+ };
404
+
361
405
  // What a Durable Object class of harbors is made of:
362
406
  //
363
407
  // 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
408
+ // pack the harbors carried here, from `nervur edge pack`, each
409
+ // landed the first time its object starts; or a function of
410
+ // the worker's environment giving them
411
+ // born the names a harbor may be born under here, with a seed of
412
+ // its own, where the pack holds none; none unless named, so a
413
+ // stranger's request mints no harbor
414
+ // stood the worker's own code, the root, run once a harbor stands,
415
+ // with the harbor's name
370
416
  export type HarborObjectParts = {
371
417
  readonly modules: readonly Module[];
372
- readonly package?: Carried | ((env: EdgeEnv) => Carried | undefined);
373
- readonly path?: string;
374
- readonly stood?: (harbor: Harbor) => Promise<void>;
418
+ readonly pack?: Pack | ((env: EdgeEnv) => Pack | undefined);
419
+ readonly born?: (name: string) => boolean;
420
+ readonly stood?: (harbor: Harbor, name: string) => Promise<void>;
375
421
  };
376
- export type HarborObjectClass = new (state: EdgeState, env: EdgeEnv) => { readonly harbor: Promise<Harbor>; fetch(request: Request): Promise<Response> };
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 =>
382
- class HarborObject {
383
- readonly #opening: Promise<Harbor>;
384
-
385
- // The object's harbor, once it stands.
386
- get harbor(): Promise<Harbor> {
387
- return this.#opening;
388
- }
422
+ export type HarborObject = { harborFor(request: Request): Promise<Harbor | null>; fetch(request: Request): Promise<Response> };
423
+ export type HarborObjectClass = new (state: EdgeState, env: EdgeEnv) => HarborObject;
424
+
425
+ // A Durable Object class whose every instance is one harbor, by the name
426
+ // the worker reached it under: opened at its first request, the one its
427
+ // pack holds under that name or one `born` allows, its web carrier opened,
428
+ // and every request it receives answered by that carrier at
429
+ // `/<name>/quo`. An object answers the one name it opened under, and a
430
+ // name it may not open is 404, with nothing written.
431
+ export const harborObject = ({ modules, pack, born, stood }: HarborObjectParts): HarborObjectClass =>
432
+ class implements HarborObject {
433
+ readonly #state: EdgeState;
434
+ readonly #env: EdgeEnv;
435
+ #name: string | undefined;
436
+ #opening: Promise<Harbor> | undefined;
389
437
 
390
438
  constructor(state: EdgeState, env: EdgeEnv) {
391
439
  addGround(edgeGround);
392
- const held = typeof carried === 'function' ? carried(env) : carried;
393
- const given: EdgeGiven = { state, env, ...(held ? { package: held } : {}) };
440
+ this.#state = state;
441
+ this.#env = env;
442
+ }
443
+
444
+ // The harbor a request names, opened once, or null where it names
445
+ // another harbor or none.
446
+ harborFor(request: Request): Promise<Harbor | null> {
447
+ const name = harborNameOf(request);
448
+ if (name === null || (this.#name !== undefined && this.#name !== name)) return Promise.resolve(null);
449
+ const packed = typeof pack === 'function' ? pack(this.#env) : pack;
450
+ const held = packed && Object.hasOwn(packed, name) ? packed[name] : undefined;
451
+ if (this.#name === undefined && !held && !born?.(name)) return Promise.resolve(null);
452
+ this.#name = name;
394
453
  // Opened inside the object's block, so no request reaches it half
395
454
  // open and its storage answers the opening.
396
- this.#opening = state.blockConcurrencyWhile(async () => {
455
+ this.#opening ??= this.#state.blockConcurrencyWhile(async () => {
456
+ const given: EdgeGiven = { state: this.#state, env: this.#env, ...(held ? { package: held } : {}) };
397
457
  const harbor = await Harbor.open({ modules, given });
398
458
  if (!harbor.box.being(WEB_OPENING.key)) await harbor.root('open', { key: WEB_OPENING.key, class: WEB_OPENING.kind });
399
- await stood?.(harbor);
459
+ await stood?.(harbor, name);
400
460
  return harbor;
401
461
  });
462
+ return this.#opening;
402
463
  }
403
464
 
404
465
  async fetch(request: Request): Promise<Response> {
405
- const harbor = await this.#opening;
466
+ const harbor = await this.harborFor(request);
467
+ if (!harbor) return new Response(null, { status: 404 });
406
468
  const terrain = harbor.terrain as EdgeTerrain;
469
+ const path = `/${this.#name!}/quo`;
407
470
  if (terrain.listening?.path !== path) {
408
471
  const url = new URL(request.url);
409
472
  await harbor.root('ask', { being: WEB_OPENING.key, method: 'listen', args: { host: url.hostname, port: url.port === '' ? 0 : Number(url.port), path } });
@@ -411,3 +474,17 @@ export const harborObject = ({ modules, package: carried, path = '/quo', stood }
411
474
  return terrain.answer(request);
412
475
  }
413
476
  };
477
+
478
+ type Namespace = { idFromName(name: string): unknown; get(id: unknown): { fetch(request: Request): Promise<Response> } };
479
+
480
+ // A worker of many harbors: each request goes to the object of the
481
+ // harbor its path names, in the Durable Object namespace bound as
482
+ // `binding`, and a path naming none is 404.
483
+ export const harborWorker = (binding = 'HARBOR') => ({
484
+ fetch(request: Request, env: Record<string, unknown>): Promise<Response> {
485
+ const name = harborNameOf(request);
486
+ const objects = env[binding] as Namespace | undefined;
487
+ if (name === null || !objects) return Promise.resolve(new Response(null, { status: 404 }));
488
+ return objects.get(objects.idFromName(name)).fetch(request);
489
+ },
490
+ });
@@ -29,6 +29,8 @@ export type ModuleRow = { version: string; from: string; kinds: string[] };
29
29
  // What the catalogue asks of her harbor.
30
30
  export interface Cataloguing {
31
31
  load(from: string): Promise<Loaded | null>;
32
+ // Why nothing loads from a name, where the loader says more.
33
+ missing(from: string): string | null;
32
34
  // Why these modules cannot run together, or null.
33
35
  check(modules: readonly Loaded[]): string | null;
34
36
  // The modules run from now on, or why they cannot be.
@@ -72,7 +74,7 @@ export class Catalogue extends Faculty {
72
74
  loaded instanceof Error
73
75
  ? loaded.message
74
76
  : loaded === null
75
- ? `not found at ${row.from}`
77
+ ? (this.#harbor.missing(row.from) ?? `not found at ${row.from}`)
76
78
  : loaded.module !== module
77
79
  ? `${row.from} is module ${loaded.module}`
78
80
  : this.#harbor.check([...this.#running.values(), loaded]);
@@ -86,7 +88,7 @@ export class Catalogue extends Faculty {
86
88
  if (!text(args.from)) return { error: 'from is text' };
87
89
  const loaded = await this.#harbor.load(args.from).catch((e: Error) => e);
88
90
  if (loaded instanceof Error) return { error: loaded.message };
89
- if (loaded === null) return { error: 'no such module' };
91
+ if (loaded === null) return { error: this.#harbor.missing(args.from) ?? 'no such module' };
90
92
  const running = new Map(this.#running);
91
93
  running.set(loaded.module, loaded);
92
94
  const refused = this.#harbor.run([...running.values()]);
@@ -46,6 +46,8 @@ export interface Hosting {
46
46
  keep(): Promise<boolean>;
47
47
  // What did not stand when the harbor opened.
48
48
  failures(): Failures;
49
+ // Whether the harbor lives only as long as a screen.
50
+ readonly foreground: boolean;
49
51
  // Every place of the memory the dock does not name, gone.
50
52
  sweep(): Promise<string[]>;
51
53
  // Whether a kind is a carrier this harbor's ground ships.
@@ -92,10 +94,14 @@ export class Dock extends WardBeing implements Lender {
92
94
  this.#hosting = hosting;
93
95
  }
94
96
 
95
- // The census adds what did not stand, for whoever pilots.
97
+ // The census adds what did not stand, for whoever pilots, and whether
98
+ // the harbor lives only as long as a screen.
96
99
  override describe(asker: Asker): Blueprint {
97
100
  const blueprint = super.describe(asker);
98
- if (pilots(asker, this.notes(asker))) (blueprint.notes as JsonObject).failed = this.#hosting.failures();
101
+ if (!pilots(asker, this.notes(asker))) return blueprint;
102
+ const notes = blueprint.notes as JsonObject;
103
+ notes.failed = this.#hosting.failures();
104
+ if (this.#hosting.foreground) notes.foreground = true;
99
105
  return blueprint;
100
106
  }
101
107
 
@@ -175,6 +175,10 @@ export class Harbor extends Carrier implements Hosting, Cataloguing, Resolving {
175
175
  });
176
176
  }
177
177
 
178
+ get foreground(): boolean {
179
+ return this.terrain.foreground;
180
+ }
181
+
178
182
  // Hosting: what did not stand when the harbor opened, each alone.
179
183
  failures(): Failures {
180
184
  return { wards: { ...this.#failed.wards }, routes: { ...this.#failed.routes } };
@@ -246,6 +250,10 @@ export class Harbor extends Carrier implements Hosting, Cataloguing, Resolving {
246
250
  return this.terrain.loader.load(from);
247
251
  }
248
252
 
253
+ missing(from: string): string | null {
254
+ return this.terrain.loader.missing(from);
255
+ }
256
+
249
257
  check(modules: readonly Loaded[]): string | null {
250
258
  return Registry.refusal(this.#kit, modules);
251
259
  }
@@ -25,6 +25,12 @@ export abstract class Terrain {
25
25
  // Whether its carriers wake and listen where their cells say. A harbor
26
26
  // opened for one ask and closed again dials and hears nothing.
27
27
  readonly wakes: boolean = true;
28
+ // Whether the harbor lives only as long as a screen: a tab's, or a worker
29
+ // one tab owns. It is reached only while that screen is open, and hears
30
+ // no push, so its census says so.
31
+ get foreground(): boolean {
32
+ return false;
33
+ }
28
34
 
29
35
  // Before the harbor opens: a terrain whose ground another run holds
30
36
  // refuses here. Once it stands: whatever the ground serves beside it.
@@ -32,7 +32,9 @@ export class HeldCustody extends Custody {
32
32
  }
33
33
  }
34
34
 
35
- // Modules handed to this object, found by the name each declares.
35
+ // Modules handed to this object, found by the name each declares: the
36
+ // bundle a ground with no disk to load from carries, an edge's or a
37
+ // browser's. A path, a package or any other name is no module of it.
36
38
  export class HeldLoader extends Loader {
37
39
  readonly #modules = new Map<string, Module>();
38
40
  constructor(modules: readonly Module[] = []) {
@@ -43,6 +45,10 @@ export class HeldLoader extends Loader {
43
45
  const m = this.#modules.get(from);
44
46
  return Promise.resolve(m ? { module: m.module, version: m.version, classes: m.classes, from } : null);
45
47
  }
48
+ override missing(from: string): string {
49
+ const carried = [...this.#modules.keys()];
50
+ return `${from} is no module this bundle carries${carried.length === 0 ? ', and it carries none' : `: it carries ${carried.join(', ')}`}`;
51
+ }
46
52
  }
47
53
 
48
54
  // Entries that live as long as this object, copied in and out, which is
package/src/proof/law.ts CHANGED
@@ -203,7 +203,7 @@ export const lawScenes = (t: Terrain, expect: Expect): [string, () => Promise<vo
203
203
  expect.same(lost.names, ['w'], 'the rest of the harbor stands');
204
204
  expect.same(((await census(lost)) as { beings: Record<string, { absent: boolean }> }).beings.echo?.absent, true);
205
205
  expect.same(await lost.root('ask', { being: 'catalogue', method: 'modules' }), {
206
- answer: { [law.module]: { version: '1', running: null, from: law.module, failed: `not found at ${law.module}` } },
206
+ answer: { [law.module]: { version: '1', running: null, from: law.module, failed: `${law.module} is no module this bundle carries, and it carries none` } },
207
207
  });
208
208
  const again = await world.restart('h', { ...t.again(bodies), loader: new HeldLoader([law]) });
209
209
  expect.same(((await census(again)) as { beings: Record<string, { absent: boolean }> }).beings.echo?.absent, false);