nervur 0.20.0 → 0.20.1

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.
Files changed (93) hide show
  1. package/README.md +107 -20
  2. package/dist/being/being.d.ts +1 -0
  3. package/dist/being/being.js +5 -1
  4. package/dist/being/faculty.d.ts +1 -0
  5. package/dist/being/faculty.js +13 -7
  6. package/dist/being/index.d.ts +1 -0
  7. package/dist/being/index.js +1 -0
  8. package/dist/being/kind.d.ts +4 -0
  9. package/dist/being/kind.js +28 -0
  10. package/dist/being/types.d.ts +3 -4
  11. package/dist/cli/command.d.ts +13 -0
  12. package/dist/cli/command.js +194 -0
  13. package/dist/cli/harbor.d.ts +21 -0
  14. package/dist/cli/harbor.js +141 -0
  15. package/dist/cli/main.d.ts +2 -0
  16. package/dist/cli/main.js +14 -0
  17. package/dist/contract/index.d.ts +15 -2
  18. package/dist/contract/index.js +16 -2
  19. package/dist/folder/index.d.ts +5 -4
  20. package/dist/folder/index.js +45 -83
  21. package/dist/harbor/catalogue.d.ts +25 -0
  22. package/dist/harbor/catalogue.js +93 -0
  23. package/dist/harbor/dock.d.ts +12 -2
  24. package/dist/harbor/dock.js +39 -9
  25. package/dist/harbor/harbor.d.ts +15 -6
  26. package/dist/harbor/harbor.js +83 -21
  27. package/dist/harbor/index.d.ts +4 -1
  28. package/dist/harbor/index.js +5 -1
  29. package/dist/harbor/package.d.ts +10 -0
  30. package/dist/harbor/package.js +115 -0
  31. package/dist/harbor/registry.d.ts +5 -3
  32. package/dist/harbor/registry.js +72 -7
  33. package/dist/harbor/root-line.d.ts +9 -0
  34. package/dist/harbor/root-line.js +36 -0
  35. package/dist/harbor/terrain.d.ts +2 -1
  36. package/dist/index.d.ts +4 -4
  37. package/dist/index.js +5 -5
  38. package/dist/pointer/bodies.d.ts +9 -3
  39. package/dist/pointer/bodies.js +37 -18
  40. package/dist/pointer/index.d.ts +1 -1
  41. package/dist/pointer/index.js +1 -1
  42. package/dist/pointer/world.d.ts +5 -4
  43. package/dist/pointer/world.js +23 -14
  44. package/dist/quo/door.js +2 -2
  45. package/dist/quo/keys.js +1 -1
  46. package/dist/tcp/frame.d.ts +29 -0
  47. package/dist/tcp/frame.js +74 -0
  48. package/dist/tcp/index.d.ts +18 -0
  49. package/dist/tcp/index.js +164 -0
  50. package/dist/ward/index.d.ts +1 -0
  51. package/dist/ward/index.js +1 -0
  52. package/dist/ward/pilot.d.ts +8 -0
  53. package/dist/ward/pilot.js +50 -0
  54. package/dist/ward/stance.d.ts +2 -4
  55. package/dist/ward/stance.js +7 -6
  56. package/dist/ward/ward-being.d.ts +2 -0
  57. package/dist/ward/ward-being.js +7 -1
  58. package/dist/ward/ward.d.ts +4 -2
  59. package/dist/ward/ward.js +35 -5
  60. package/package.json +8 -1
  61. package/src/being/being.ts +5 -1
  62. package/src/being/faculty.ts +14 -7
  63. package/src/being/index.ts +1 -0
  64. package/src/being/kind.ts +30 -0
  65. package/src/being/types.ts +8 -6
  66. package/src/cli/command.ts +188 -0
  67. package/src/cli/harbor.ts +137 -0
  68. package/src/cli/main.ts +13 -0
  69. package/src/contract/index.ts +38 -9
  70. package/src/folder/index.ts +44 -86
  71. package/src/harbor/catalogue.ts +110 -0
  72. package/src/harbor/dock.ts +48 -12
  73. package/src/harbor/harbor.ts +104 -29
  74. package/src/harbor/index.ts +6 -2
  75. package/src/harbor/package.ts +131 -0
  76. package/src/harbor/registry.ts +62 -9
  77. package/src/harbor/root-line.ts +36 -0
  78. package/src/harbor/terrain.ts +3 -1
  79. package/src/index.ts +5 -5
  80. package/src/pointer/bodies.ts +34 -16
  81. package/src/pointer/index.ts +1 -1
  82. package/src/pointer/world.ts +21 -18
  83. package/src/quo/door.ts +2 -2
  84. package/src/quo/keys.ts +1 -1
  85. package/src/stand/main.ts +1 -0
  86. package/src/stand/stand.ts +46 -4
  87. package/src/tcp/frame.ts +78 -0
  88. package/src/tcp/index.ts +173 -0
  89. package/src/ward/index.ts +1 -0
  90. package/src/ward/pilot.ts +50 -0
  91. package/src/ward/stance.ts +8 -7
  92. package/src/ward/ward-being.ts +7 -1
  93. package/src/ward/ward.ts +37 -6
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The package: a harbor's memory, sealed under the harbor's seed. The
3
+ // terrain keeps its bytes and reads neither a ward's name, nor a being's
4
+ // key, nor anything a row holds.
5
+ //
6
+ // key HKDF-SHA-256 of the seed under `nervur-package`
7
+ // place 16 bytes under ["place", ward], as hex
8
+ // entry 16 bytes under ["entry", ward, row], as hex
9
+ // seal 32 bytes under ["seal", ward]
10
+ // entry bytes nonce (12) || AES-256-GCM( JSON { row, value } ),
11
+ // the entry's name as additional data
12
+ //
13
+ // A row is the head, under the empty name, or one being, under her key. An
14
+ // entry that does not open refuses the whole ward, so nothing unpacks from
15
+ // bytes the package did not seal.
16
+ import { concat, decrypt, encrypt, hex, hkdf, utf8 } from '../crypto/index.ts';
17
+ import type { Entropy, Memory, Rows } from '../contract/index.ts';
18
+ import { HEAD_ROW } from '../ward/index.ts';
19
+
20
+ const NONCE = 12;
21
+ const LABEL = utf8('nervur-package');
22
+
23
+ type Sealed = { row: string; value: unknown };
24
+
25
+ class PackageRows implements Rows {
26
+ readonly rows: Record<string, unknown>;
27
+ readonly #ward: Ward;
28
+ readonly #told = new Set<string>();
29
+ #line: Promise<boolean> = Promise.resolve(true);
30
+
31
+ constructor(ward: Ward, rows: Record<string, unknown>) {
32
+ this.#ward = ward;
33
+ this.rows = rows;
34
+ }
35
+
36
+ told(row: string): void {
37
+ this.#told.add(row);
38
+ }
39
+
40
+ // Every row told since the last keep, sealed and written as one keep, one
41
+ // keep at a time.
42
+ kept(): Promise<boolean> {
43
+ const rows = [...this.#told];
44
+ this.#told.clear();
45
+ this.#line = this.#line.then(() => this.#write(rows)).catch(() => false);
46
+ return this.#line;
47
+ }
48
+
49
+ async #write(told: string[]): Promise<boolean> {
50
+ const beings = (this.rows.beings ?? {}) as Record<string, unknown>;
51
+ const entries = new Map<string, Uint8Array | null>();
52
+ for (const row of told) {
53
+ const value = row === HEAD_ROW ? (this.rows.ward ?? null) : Object.hasOwn(beings, row) ? beings[row] : undefined;
54
+ entries.set(await this.#ward.entry(row), value === undefined ? null : await this.#ward.seal(row, value));
55
+ }
56
+ await this.#ward.write(entries);
57
+ return true;
58
+ }
59
+ }
60
+
61
+ class Ward {
62
+ readonly #pack: Package;
63
+ readonly #name: string;
64
+ readonly #seal: Uint8Array;
65
+ readonly place: string;
66
+
67
+ constructor(pack: Package, name: string, seal: Uint8Array, place: string) {
68
+ this.#pack = pack;
69
+ this.#name = name;
70
+ this.#seal = seal;
71
+ this.place = place;
72
+ }
73
+
74
+ entry(row: string): Promise<string> {
75
+ return this.#pack.derive(['entry', this.#name, row], 16).then(hex);
76
+ }
77
+
78
+ async seal(row: string, value: unknown): Promise<Uint8Array> {
79
+ const nonce = this.#pack.entropy.draw(NONCE);
80
+ const text = utf8(JSON.stringify({ row, value } satisfies Sealed));
81
+ return concat(nonce, await encrypt(this.#seal, nonce, utf8(await this.entry(row)), text));
82
+ }
83
+
84
+ // The name is the additional data, so an entry moved under another name
85
+ // does not open either.
86
+ async open(name: string, bytes: Uint8Array): Promise<Sealed> {
87
+ const text = bytes.length > NONCE ? await decrypt(this.#seal, bytes.subarray(0, NONCE), utf8(name), bytes.subarray(NONCE)) : null;
88
+ if (text === null) throw new Error('an entry the package did not seal');
89
+ return JSON.parse(new TextDecoder().decode(text)) as Sealed;
90
+ }
91
+
92
+ write(entries: ReadonlyMap<string, Uint8Array | null>): Promise<void> {
93
+ return this.#pack.memory.write(this.place, entries);
94
+ }
95
+ }
96
+
97
+ export class Package {
98
+ readonly memory: Memory;
99
+ readonly entropy: Entropy;
100
+ readonly #key: Uint8Array;
101
+
102
+ private constructor(memory: Memory, entropy: Entropy, key: Uint8Array) {
103
+ this.memory = memory;
104
+ this.entropy = entropy;
105
+ this.#key = key;
106
+ }
107
+
108
+ static async open(memory: Memory, entropy: Entropy, seed: Uint8Array): Promise<Package> {
109
+ return new Package(memory, entropy, await hkdf(seed, LABEL, 32));
110
+ }
111
+
112
+ derive(info: string[], length: number): Promise<Uint8Array> {
113
+ return hkdf(this.#key, utf8(JSON.stringify(info)), length);
114
+ }
115
+
116
+ // One ward's rows, as the package kept them.
117
+ async rows(name: string): Promise<Rows> {
118
+ const ward = new Ward(this, name, await this.derive(['seal', name], 32), hex(await this.derive(['place', name], 16)));
119
+ const rows: Record<string, unknown> = {};
120
+ const beings: Record<string, unknown> = {};
121
+ let any = false;
122
+ for (const [entry, bytes] of await this.memory.read(ward.place)) {
123
+ const { row, value } = await ward.open(entry, bytes);
124
+ any = true;
125
+ if (row === HEAD_ROW) rows.ward = value;
126
+ else beings[row] = value;
127
+ }
128
+ if (any) rows.beings = beings;
129
+ return new PackageRows(ward, rows);
130
+ }
131
+ }
@@ -1,20 +1,73 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The catalogue a program hands its harbor, indexed by class name. It
3
- // resolves a name a row keeps, and decides nothing about what stands.
4
- import type { BeingClass } from '../being/index.ts';
2
+ // The catalogue's index: every kind the harbor runs, from the kit's own
3
+ // classes and the modules the catalogue loaded. It resolves a kind a row
4
+ // keeps, and decides nothing about what stands. It refuses any set of
5
+ // modules two readings could split:
6
+ //
7
+ // a module whose name is no kind, or is the kit's, or two of one name
8
+ // a class with no kind of its own, or one outside its module's domain
9
+ // two classes of one kind, or one kind in two modules
10
+ // a contract with no kind of its own, or one kind for two contracts
11
+ // a kind that is a class and a contract
12
+ //
13
+ // A module's domain is its name's first two segments: `com.acme.shop`
14
+ // stands classes under `com.acme.` alone. A contract may be any domain's,
15
+ // so a faculty fulfils a contract another module declares. Two harbors hold
16
+ // two registries, and nothing between them names a class.
17
+ import { Faculty, isKind, KIT, kindOf, type BeingClass } from '../being/index.ts';
18
+ import type { Module } from '../contract/index.ts';
5
19
  import type { Catalogue } from '../ward/index.ts';
6
20
 
21
+ const domainOf = (name: string): string => `${name.split('.').slice(0, 2).join('.')}.`;
22
+
7
23
  export class Registry implements Catalogue {
8
24
  readonly #classes = new Map<string, BeingClass>();
25
+ readonly #modules = new Map<string, string[]>();
9
26
 
10
- constructor(classes: readonly BeingClass[]) {
11
- for (const C of classes) {
12
- if (this.#classes.has(C.name) && this.#classes.get(C.name) !== C) throw new Error(`two classes are named ${C.name}`);
13
- this.#classes.set(C.name, C);
27
+ // `kit` holds the kit's own classes, which alone may be of its kinds.
28
+ constructor(kit: readonly BeingClass[], modules: readonly Module[]) {
29
+ const contracts = new Map<string, unknown>();
30
+ const owners = new Map<string, string>();
31
+ const add = (C: BeingClass, kind: string): void => {
32
+ if (this.#classes.has(kind) && this.#classes.get(kind) !== C) throw new Error(`two classes are of kind ${kind}`);
33
+ this.#classes.set(kind, C);
34
+ };
35
+ for (const C of kit) add(C, kindOf(C));
36
+ for (const m of modules) {
37
+ if (!isKind(m.module) || m.module.startsWith(KIT)) throw new Error(`module ${String(m.module)} is no name a module may take`);
38
+ if (this.#modules.has(m.module)) throw new Error(`two modules are named ${m.module}`);
39
+ if (typeof m.version !== 'string' || m.version === '') throw new Error(`module ${m.module} declares no version`);
40
+ const kinds: string[] = [];
41
+ for (const C of m.classes) {
42
+ if (typeof C !== 'function') throw new Error(`module ${m.module} holds something that is no class`);
43
+ const kind = kindOf(C);
44
+ if (!kind.startsWith(domainOf(m.module))) throw new Error(`kind ${kind} is outside module ${m.module}`);
45
+ const owner = owners.get(kind);
46
+ if (owner !== undefined && owner !== m.module) throw new Error(`kind ${kind} is in modules ${owner} and ${m.module}`);
47
+ owners.set(kind, m.module);
48
+ add(C as BeingClass, kind);
49
+ kinds.push(kind);
50
+ if (!Faculty.fulfils(C)) continue;
51
+ for (let c: unknown = Object.getPrototypeOf(C); c !== Faculty; c = Object.getPrototypeOf(c)) {
52
+ const contract = kindOf(c);
53
+ if (contract.startsWith(KIT)) throw new Error(`kind ${contract} is the kit's`);
54
+ if (contracts.has(contract) && contracts.get(contract) !== c) throw new Error(`two contracts are of kind ${contract}`);
55
+ contracts.set(contract, c);
56
+ }
57
+ }
58
+ this.#modules.set(m.module, kinds);
59
+ }
60
+ for (const kind of contracts.keys()) {
61
+ if (this.#classes.has(kind)) throw new Error(`kind ${kind} is a class and a contract`);
14
62
  }
15
63
  }
16
64
 
17
- classOf(name: string): BeingClass | undefined {
18
- return this.#classes.get(name);
65
+ classOf(kind: string): BeingClass | undefined {
66
+ return this.#classes.get(kind);
67
+ }
68
+
69
+ // The kinds a module stands, or none.
70
+ kindsOf(module: string): string[] {
71
+ return [...(this.#modules.get(module) ?? [])];
19
72
  }
20
73
  }
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The root line: one request, one answer, both plain JSON, for whatever
3
+ // carries the root's asks to a harbor in its own process. The command line
4
+ // carries it over a local socket, and an edge route carries it the same way.
5
+ //
6
+ // request { ward?, method?, args? } no ward is the box ward, no
7
+ // method the census
8
+ // answer { answer } what the ward-being said
9
+ // { silence: true } she said nothing
10
+ // { word } a kit word: unreached, late, ...
11
+ // { error } the request was not one, or the
12
+ // ward is none this harbor holds
13
+ import { isSilence, isWord, told, type Json, type JsonObject } from '../being/index.ts';
14
+ import type { Harbor } from './harbor.ts';
15
+
16
+ export type RootRequest = { ward?: string; method?: string; args?: JsonObject };
17
+
18
+ const object = (v: unknown): v is JsonObject => typeof v === 'object' && v !== null && !Array.isArray(v);
19
+
20
+ export const readRootRequest = (value: unknown): RootRequest | null => {
21
+ if (!object(value)) return null;
22
+ const { ward, method, args } = value;
23
+ if ((ward !== undefined && typeof ward !== 'string') || (method !== undefined && typeof method !== 'string') || (args !== undefined && !object(args))) return null;
24
+ return { ...(ward === undefined ? {} : { ward }), ...(method === undefined ? {} : { method }), ...(args === undefined ? {} : { args }) };
25
+ };
26
+
27
+ export const rootLine = async (harbor: Harbor, value: unknown): Promise<JsonObject> => {
28
+ const request = readRootRequest(value);
29
+ if (request === null) return { error: 'not a root request' };
30
+ const ward = request.ward === undefined ? harbor.box : harbor.ward(request.ward);
31
+ if (ward === undefined) return { error: 'no such ward' };
32
+ const out = await ward.root(request.method, request.args);
33
+ if (isSilence(out)) return { silence: true };
34
+ if (isWord(out)) return { word: told(out) as string };
35
+ return { answer: out as Json };
36
+ };
@@ -1,13 +1,15 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Where a harbor runs. A terrain hands the harbor one body for each
3
3
  // contract the onion cannot unpack from cells.
4
- import type { Carrier, Clock, Custody, Entropy, Memory } from '../contract/index.ts';
4
+ import type { Carrier, Clock, Custody, Entropy, Loader, Memory } from '../contract/index.ts';
5
5
 
6
6
  export abstract class Terrain {
7
7
  abstract readonly entropy: Entropy;
8
8
  abstract readonly clock: Clock;
9
9
  abstract readonly custody: Custody;
10
10
  abstract readonly memory: Memory;
11
+ // The code the catalogue's cells name.
12
+ abstract readonly loader: Loader;
11
13
  // What reaches a ward pk this harbor does not hold.
12
14
  abstract readonly carrier: Carrier;
13
15
  }
package/src/index.ts CHANGED
@@ -8,13 +8,13 @@ export type { Answer, Ask, Asker, AskSpec, BeingClass, BeingLike, Blueprint, Inv
8
8
  export { readInvitation } from './quo/index.ts';
9
9
 
10
10
  // What a terrain gives a harbor.
11
- export { Carrier, Clock, Custody, Entropy, Memory, type Rows } from './contract/index.ts';
11
+ export { Carrier, Clock, Custody, Entropy, Loader, Memory, type Loaded, type Module, type Rows } from './contract/index.ts';
12
12
 
13
13
  // Wards and the ward-being.
14
- export { CEILING, DEFAULT, DEPTH, ownerAsk, pilots, WardBeing, type Steward, type WardBeingStance } from './ward/index.ts';
14
+ export { CEILING, DEFAULT, DEPTH, ownerAsk, Pilot, pilots, WardBeing, type Steward, type WardBeingStance } from './ward/index.ts';
15
15
 
16
- // The onion.
17
- export { BOX, Dock, Harbor, Registry, Terrain } from './harbor/index.ts';
16
+ // The onion, and the one line its root's asks cross from any front.
17
+ export { BOX, Catalogue, Dock, Harbor, readRootRequest, Registry, rootLine, Terrain, type RootRequest } from './harbor/index.ts';
18
18
 
19
19
  // The pointer dock: a whole world in one process.
20
- export { CryptoEntropy, HeldCustody, NoCarrier, PointerCarrier, PointerTerrain, SystemClock, VolatileMemory, World, type PointerParts } from './pointer/index.ts';
20
+ export { CryptoEntropy, HeldCustody, HeldLoader, NoCarrier, PointerCarrier, PointerTerrain, SystemClock, VolatileMemory, World, type PointerParts } from './pointer/index.ts';
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // The pointer terrain's bodies: each contract fulfilled in one process,
3
3
  // with nothing beyond JavaScript.
4
- import { Clock, Custody, Entropy, Memory, type Rows } from '../contract/index.ts';
4
+ import { Clock, Custody, Entropy, Loader, Memory, type Loaded, type Module } from '../contract/index.ts';
5
5
 
6
6
  export class CryptoEntropy extends Entropy {
7
7
  draw(length: number): Uint8Array {
@@ -20,28 +20,46 @@ export class SystemClock extends Clock {
20
20
  }
21
21
  }
22
22
 
23
- // Seeds drawn once per name and held by this object.
23
+ // The harbor's seed, drawn once and held by this object.
24
24
  export class HeldCustody extends Custody {
25
- readonly #entropy: Entropy;
26
- readonly #seeds = new Map<string, Uint8Array>();
25
+ readonly #seed: Uint8Array;
27
26
  constructor(entropy: Entropy) {
28
27
  super();
29
- this.#entropy = entropy;
28
+ this.#seed = entropy.draw(32);
30
29
  }
31
- seed(name: string): Promise<Uint8Array> {
32
- let seed = this.#seeds.get(name);
33
- if (!seed) this.#seeds.set(name, (seed = this.#entropy.draw(32)));
34
- return Promise.resolve(seed);
30
+ seed(): Promise<Uint8Array> {
31
+ return Promise.resolve(this.#seed.slice());
35
32
  }
36
33
  }
37
34
 
38
- // Rows that live as long as this object: opening a name again gives the
39
- // same rows, which is what a restart in one process finds.
35
+ // Modules handed to this object, found by the name each declares.
36
+ export class HeldLoader extends Loader {
37
+ readonly #modules = new Map<string, Module>();
38
+ constructor(modules: readonly Module[] = []) {
39
+ super();
40
+ for (const m of modules) this.#modules.set(m.module, m);
41
+ }
42
+ load(from: string): Promise<Loaded | null> {
43
+ const m = this.#modules.get(from);
44
+ return Promise.resolve(m ? { module: m.module, version: m.version, classes: m.classes, from } : null);
45
+ }
46
+ }
47
+
48
+ // Entries that live as long as this object, copied in and out, which is
49
+ // what a restart in one process finds.
40
50
  export class VolatileMemory extends Memory {
41
- readonly #wards = new Map<string, Rows>();
42
- open(name: string): Promise<Rows> {
43
- let rows = this.#wards.get(name);
44
- if (!rows) this.#wards.set(name, (rows = { rows: {}, told: () => {}, kept: () => Promise.resolve(true) }));
45
- return Promise.resolve(rows);
51
+ readonly #places = new Map<string, Map<string, Uint8Array>>();
52
+ read(place: string): Promise<Map<string, Uint8Array>> {
53
+ const entries = this.#places.get(place) ?? new Map<string, Uint8Array>();
54
+ return Promise.resolve(new Map([...entries].map(([name, bytes]) => [name, bytes.slice()])));
55
+ }
56
+ write(place: string, entries: ReadonlyMap<string, Uint8Array | null>): Promise<void> {
57
+ let kept = this.#places.get(place);
58
+ if (!kept) this.#places.set(place, (kept = new Map()));
59
+ for (const [name, bytes] of entries) {
60
+ if (bytes === null) kept.delete(name);
61
+ else kept.set(name, bytes.slice());
62
+ }
63
+ return Promise.resolve();
46
64
  }
47
65
  }
@@ -1,4 +1,4 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // The pointer dock: a whole world with `nervur` alone.
3
- export { CryptoEntropy, HeldCustody, SystemClock, VolatileMemory } from './bodies.ts';
3
+ export { CryptoEntropy, HeldCustody, HeldLoader, SystemClock, VolatileMemory } from './bodies.ts';
4
4
  export { NoCarrier, PointerCarrier, PointerTerrain, World, type PointerParts } from './world.ts';
@@ -3,10 +3,9 @@
3
3
  // to harbor that stands in for the internet, and weather between them. A
4
4
  // restart drops a harbor and opens a new one on the terrain the world
5
5
  // keeps for it, so only what its memory kept comes back.
6
- import type { BeingClass } from '../being/index.ts';
7
- import { Carrier, type Clock, type Custody, type Entropy, type Memory } from '../contract/index.ts';
6
+ import { Carrier, type Clock, type Custody, type Entropy, type Loader, type Memory, type Module } from '../contract/index.ts';
8
7
  import { Harbor, Terrain } from '../harbor/index.ts';
9
- import { CryptoEntropy, HeldCustody, SystemClock, VolatileMemory } from './bodies.ts';
8
+ import { CryptoEntropy, HeldCustody, HeldLoader, SystemClock, VolatileMemory } from './bodies.ts';
10
9
 
11
10
  // A carrier that reaches no one: a harbor alone.
12
11
  export class NoCarrier extends Carrier {
@@ -27,7 +26,7 @@ export class PointerCarrier extends Carrier {
27
26
  }
28
27
  }
29
28
 
30
- export type PointerParts = { entropy?: Entropy; clock?: Clock; custody?: Custody; memory?: Memory; carrier?: Carrier };
29
+ export type PointerParts = { entropy?: Entropy; clock?: Clock; custody?: Custody; memory?: Memory; carrier?: Carrier; loader?: Loader };
31
30
 
32
31
  export class PointerTerrain extends Terrain {
33
32
  readonly entropy: Entropy;
@@ -35,6 +34,7 @@ export class PointerTerrain extends Terrain {
35
34
  readonly custody: Custody;
36
35
  readonly memory: Memory;
37
36
  readonly carrier: Carrier;
37
+ readonly loader: Loader;
38
38
 
39
39
  constructor(parts: PointerParts = {}) {
40
40
  super();
@@ -43,23 +43,26 @@ export class PointerTerrain extends Terrain {
43
43
  this.custody = parts.custody ?? new HeldCustody(this.entropy);
44
44
  this.memory = parts.memory ?? new VolatileMemory();
45
45
  this.carrier = parts.carrier ?? new NoCarrier();
46
+ this.loader = parts.loader ?? new HeldLoader();
46
47
  }
47
48
  }
48
49
 
49
- type Place = { harbor: Harbor; terrain: Terrain; classes: readonly BeingClass[] };
50
+ type Place = { harbor: Harbor; terrain: Terrain };
50
51
 
51
52
  export class World {
52
53
  readonly #places = new Map<string, Place>();
53
54
  // Harbors the world reaches no one in, nor anyone out of.
54
55
  readonly cut = new Set<string>();
55
56
 
56
- // A harbor of this world. `parts` replaces the pointer bodies, a folder's
57
- // memory and custody among them.
58
- async harbor(name: string, classes: readonly BeingClass[], parts: Omit<PointerParts, 'carrier'> = {}): Promise<Harbor> {
57
+ // A harbor of this world, whose loader holds `modules` unless `parts`
58
+ // names a loader. `parts` replaces the pointer bodies, a folder's memory
59
+ // and custody among them. The harbor runs only the modules its catalogue
60
+ // was told to add.
61
+ async harbor(name: string, modules: readonly Module[] = [], parts: Omit<PointerParts, 'carrier'> = {}): Promise<Harbor> {
59
62
  if (this.#places.has(name)) throw new Error(`a harbor named ${name} stands`);
60
- const terrain = new PointerTerrain({ ...parts, carrier: new PointerCarrier(this) });
61
- const harbor = await Harbor.open(terrain, classes);
62
- this.#places.set(name, { harbor, terrain, classes });
63
+ const terrain = new PointerTerrain({ loader: new HeldLoader(modules), ...parts, carrier: new PointerCarrier(this) });
64
+ const harbor = await Harbor.open(terrain);
65
+ this.#places.set(name, { harbor, terrain });
63
66
  return harbor;
64
67
  }
65
68
 
@@ -69,16 +72,16 @@ export class World {
69
72
  return place.harbor;
70
73
  }
71
74
 
72
- // The harbor opened again on its own terrain, with the classes it was
73
- // made with unless others are named. `parts` replaces bodies of the
74
- // terrain, as a new process would build them over the same disk.
75
- async restart(name: string, classes?: readonly BeingClass[], parts?: Omit<PointerParts, 'carrier'>): Promise<Harbor> {
75
+ // The harbor opened again on its own terrain. `parts` replaces bodies of
76
+ // the terrain, as a new process would build them over the same disk, or
77
+ // with other code.
78
+ async restart(name: string, parts?: Omit<PointerParts, 'carrier'>): Promise<Harbor> {
76
79
  const place = this.#places.get(name);
77
80
  if (!place) throw new Error(`no harbor named ${name}`);
78
81
  const t = place.terrain;
79
- const terrain = parts ? new PointerTerrain({ entropy: t.entropy, clock: t.clock, custody: t.custody, memory: t.memory, ...parts, carrier: t.carrier }) : t;
80
- const harbor = await Harbor.open(terrain, classes ?? place.classes);
81
- this.#places.set(name, { ...place, harbor, terrain });
82
+ const terrain = parts ? new PointerTerrain({ entropy: t.entropy, clock: t.clock, custody: t.custody, memory: t.memory, loader: t.loader, ...parts, carrier: t.carrier }) : t;
83
+ const harbor = await Harbor.open(terrain);
84
+ this.#places.set(name, { harbor, terrain });
82
85
  return harbor;
83
86
  }
84
87
 
package/src/quo/door.ts CHANGED
@@ -42,7 +42,7 @@ export class Door {
42
42
  }
43
43
 
44
44
  // A new heir, held fresh, and its invitation. The lock is drawn at the
45
- // first invitation that names a heir, before the heir's secret.
45
+ // first invitation that names an heir, before the heir's secret.
46
46
  async invite(): Promise<{ heir: string; invitation: Invitation }> {
47
47
  let lock = this.relations.lock();
48
48
  if (!lock) this.relations.setLock((lock = drawLock(this.draw)));
@@ -51,7 +51,7 @@ export class Door {
51
51
  return { heir: heir.id, invitation: { ward: this.key.pk, heir: heir.id, secret: hex(heir.secret), lock: hex(lock.ek) } };
52
52
  }
53
53
 
54
- // The door stops holding a heir. A fresh one leaves nothing, a spent one
54
+ // The door stops holding an heir. A fresh one leaves nothing, a spent one
55
55
  // leaves the keys kept at removal.
56
56
  remove(heir: string): boolean {
57
57
  const record = this.relations.get(heir);
package/src/quo/keys.ts CHANGED
@@ -15,7 +15,7 @@ export const LABEL = {
15
15
  wardSeal: utf8('quo-ward-seal'),
16
16
  } as const;
17
17
 
18
- // An Ed25519 pair: a heir, or a standing's own key.
18
+ // An Ed25519 pair: an heir, or a standing's own key.
19
19
  export class SigningKey {
20
20
  readonly secret: Uint8Array;
21
21
  readonly pk: Uint8Array;
package/src/stand/main.ts CHANGED
@@ -17,3 +17,4 @@ for await (const line of createInterface({ input: process.stdin, crlfDelay: Infi
17
17
  );
18
18
  }
19
19
  await Promise.all(pending);
20
+ await stand.close();
@@ -1,9 +1,10 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The stand of `quo/vectors/HARNESS.md`, part one, over `quo/` alone. Part
3
- // two carries bytes over TCP, which is the dock's, so its three requests
4
- // are `bad request`.
2
+ // The stand of `quo/vectors/HARNESS.md`, over `quo/` and `nervur/tcp`:
3
+ // part one judged, and part two carried, its wards answering on one
4
+ // listener and asking through one dialer.
5
5
  import { hex, isHex, unhex } from '../crypto/index.ts';
6
6
  import { Door, freshStanding, MemoryRelations, readInvitation, Standing, WardKey, type Behind, type Choice, type Draw, type Invitation, type Payload, type Read, type Sent } from '../quo/index.ts';
7
+ import { TcpCarrier, TcpListener } from '../tcp/index.ts';
7
8
 
8
9
  type Answer = Record<string, unknown>;
9
10
  const ERRORS = ['bad request', 'no such op', 'no such ward', 'not reached', 'ward stood', 'name held'] as const;
@@ -51,11 +52,18 @@ const OPS = new Set(['ward', 'invite', 'release', 'arrive', 'ask', 'read', 'list
51
52
  export class Stand {
52
53
  readonly #wards = new Map<string, StandWard>();
53
54
  readonly #draw: Draw;
55
+ readonly #carrier = new TcpCarrier();
56
+ #listener: Promise<TcpListener> | undefined;
54
57
 
55
58
  constructor(draw: Draw) {
56
59
  this.#draw = draw;
57
60
  }
58
61
 
62
+ async close(): Promise<void> {
63
+ await this.#carrier.close();
64
+ await (await this.#listener)?.close();
65
+ }
66
+
59
67
  async handle(line: string): Promise<Answer> {
60
68
  let request: unknown;
61
69
  try {
@@ -90,11 +98,45 @@ export class Stand {
90
98
  return this.#ask(r);
91
99
  case 'read':
92
100
  return this.#read(r);
101
+ case 'listen':
102
+ return this.#listen();
103
+ case 'route':
104
+ return this.#route(r);
93
105
  default:
94
- return refuse('bad request');
106
+ return this.#send(r);
95
107
  }
96
108
  }
97
109
 
110
+ // One listener, answering for every ward this program stands.
111
+ async #listen(): Promise<Answer> {
112
+ this.#listener ??= TcpListener.listen({
113
+ arrive: async (pk, bytes) => {
114
+ const ward = this.#wards.get(pk);
115
+ return ward ? (await ward.door.arrive(bytes)).bytes : null;
116
+ },
117
+ });
118
+ return { at: (await this.#listener).at };
119
+ }
120
+
121
+ #route(r: Record<string, unknown>): Answer {
122
+ const far = r.far;
123
+ if (!isHex(far, 64) || !this.#carrier.route(far, text(r.at))) return refuse('bad request');
124
+ return { routed: far };
125
+ }
126
+
127
+ async #send(r: Record<string, unknown>): Promise<Answer> {
128
+ const invitation = inviteOf(r.invitation);
129
+ const method = r.method === undefined ? undefined : text(r.method);
130
+ const args = r.args === undefined ? undefined : objectText(r.args);
131
+ const ward = this.#ward_(r.ward);
132
+ const held = this.#standing(ward, invitation);
133
+ const sent = await held.standing.ask(method, args);
134
+ if (sent === null) return refuse('bad request');
135
+ held.last = sent;
136
+ const reply = await this.#carrier.carry(invitation.ward, sent.bytes);
137
+ return { read: readOf(await held.standing.read(sent, reply)) };
138
+ }
139
+
98
140
  async #ward(seed: string, reach: string | undefined): Promise<Answer> {
99
141
  reached(reach);
100
142
  const key = await WardKey.from(seed);
@@ -0,0 +1,78 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The frames of Quo over TCP, `quo/CARRIER-TCP.md`:
3
+ //
4
+ // frame = length (4, big-endian) || kind (1) || id (4, big-endian) || rest
5
+ // 00 ask rest = ward pk (64) || box
6
+ // 01 reply rest = box
7
+ // 02 nothing rest = empty
8
+ //
9
+ // A reader says a stream is not a frame as soon as its bytes say so, and
10
+ // reads nothing after it.
11
+ import { concat } from '../crypto/index.ts';
12
+
13
+ export const ASK = 0;
14
+ export const REPLY = 1;
15
+ export const NOTHING = 2;
16
+ export const MAX_BODY = 1_048_645;
17
+ const PK = 64;
18
+
19
+ export type Frame = { kind: 'ask'; id: number; pk: Uint8Array; box: Uint8Array } | { kind: 'reply'; id: number; box: Uint8Array } | { kind: 'nothing'; id: number };
20
+
21
+ const frame = (kind: number, id: number, ...rest: Uint8Array[]): Uint8Array => {
22
+ const head = new Uint8Array(9);
23
+ const view = new DataView(head.buffer);
24
+ view.setUint32(0, 5 + rest.reduce((n, r) => n + r.length, 0));
25
+ head[4] = kind;
26
+ view.setUint32(5, id >>> 0);
27
+ return concat(head, ...rest);
28
+ };
29
+
30
+ export const askFrame = (id: number, pk: Uint8Array, box: Uint8Array): Uint8Array => frame(ASK, id, pk, box);
31
+ export const replyFrame = (id: number, box: Uint8Array): Uint8Array => frame(REPLY, id, box);
32
+ export const nothingFrame = (id: number): Uint8Array => frame(NOTHING, id);
33
+
34
+ // Whether a body of this length, and this first byte when known, is no frame.
35
+ const notAFrame = (length: number, kind: number | undefined): boolean =>
36
+ length > MAX_BODY || length < 5 || (kind !== undefined && (kind > NOTHING || (kind === ASK && length < 5 + PK) || (kind === NOTHING && length !== 5)));
37
+
38
+ export class FrameReader {
39
+ #buffer: Uint8Array = new Uint8Array(0);
40
+ #bad = false;
41
+
42
+ // True once bytes that are not a frame arrived; nothing is read after.
43
+ get bad(): boolean {
44
+ return this.#bad;
45
+ }
46
+
47
+ push(chunk: Uint8Array): Frame[] {
48
+ if (this.#bad) return [];
49
+ this.#buffer = this.#buffer.length === 0 ? chunk.slice() : concat(this.#buffer, chunk);
50
+ const out: Frame[] = [];
51
+ while (this.#buffer.length >= 4) {
52
+ const view = new DataView(this.#buffer.buffer, this.#buffer.byteOffset);
53
+ const length = view.getUint32(0);
54
+ if (notAFrame(length, this.#buffer.length > 4 ? this.#buffer[4] : undefined)) {
55
+ this.#bad = true;
56
+ this.#buffer = new Uint8Array(0);
57
+ return out;
58
+ }
59
+ if (this.#buffer.length < 4 + length) break;
60
+ const body = this.#buffer.subarray(4, 4 + length);
61
+ this.#buffer = this.#buffer.slice(4 + length);
62
+ const id = new DataView(body.buffer, body.byteOffset).getUint32(1);
63
+ if (body[0] === ASK) out.push({ kind: 'ask', id, pk: body.slice(5, 5 + PK), box: body.slice(5 + PK) });
64
+ else if (body[0] === REPLY) out.push({ kind: 'reply', id, box: body.slice(5) });
65
+ else out.push({ kind: 'nothing', id });
66
+ }
67
+ return out;
68
+ }
69
+ }
70
+
71
+ // "host:port" to its parts, or null. A host in brackets is an IPv6 address.
72
+ export const parseAt = (at: unknown): { host: string; port: number } | null => {
73
+ if (typeof at !== 'string') return null;
74
+ const m = /^(?:\[([^\]]+)\]|([^:[\]]+)):(\d{1,5})$/.exec(at);
75
+ if (!m) return null;
76
+ const port = Number(m[3]);
77
+ return port > 0 && port < 65_536 ? { host: m[1] ?? m[2]!, port } : null;
78
+ };