nervur 0.20.4 → 0.21.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.
- package/README.md +61 -19
- package/dist/cli/command.js +18 -4
- package/dist/cli/edge.d.ts +7 -0
- package/dist/cli/edge.js +58 -0
- package/dist/contract/index.js +2 -1
- package/dist/harbor/catalogue.js +11 -3
- package/dist/harbor/dock.js +6 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node/index.js +12 -4
- package/dist/proof/contracts.d.ts +15 -0
- package/dist/proof/contracts.js +115 -0
- package/dist/proof/expect.d.ts +6 -0
- package/dist/proof/expect.js +18 -0
- package/dist/proof/index.d.ts +3 -0
- package/dist/proof/index.js +12 -0
- package/dist/proof/law.d.ts +52 -0
- package/dist/proof/law.js +345 -0
- package/package.json +9 -2
- package/src/cli/command.ts +17 -3
- package/src/cli/edge.ts +65 -0
- package/src/contract/index.ts +2 -1
- package/src/harbor/catalogue.ts +11 -4
- package/src/harbor/dock.ts +3 -1
- package/src/index.ts +1 -1
- package/src/node/index.ts +11 -4
- package/src/proof/contracts.ts +129 -0
- package/src/proof/expect.ts +25 -0
- package/src/proof/index.ts +12 -0
- package/src/proof/law.ts +352 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The unpacking law, as scenes any terrain's bodies run: a harbor is one
|
|
3
|
+
// package. Only the seed and the terrain's bodies stand outside it, the
|
|
4
|
+
// terrain keeps sealed bytes it cannot read, and the seed opens the memory,
|
|
5
|
+
// the dock stands the catalogue, the catalogue loads the modules her cells
|
|
6
|
+
// name, the faculties and every ward stand from cells, and the dock lends.
|
|
7
|
+
// It names no runtime, so it bundles.
|
|
8
|
+
import { Being, Faculty } from '../being/index.js';
|
|
9
|
+
import { Memory } from '../contract/index.js';
|
|
10
|
+
import { copyPackage, rootLine } from '../harbor/index.js';
|
|
11
|
+
import { CryptoEntropy, HeldLoader, World } from '../pointer/index.js';
|
|
12
|
+
class Echoes extends Faculty {
|
|
13
|
+
static kind = 'org.example.echoes';
|
|
14
|
+
}
|
|
15
|
+
class Echo extends Echoes {
|
|
16
|
+
static kind = 'org.example.echo';
|
|
17
|
+
static asks = { echo: {} };
|
|
18
|
+
echo(args, asker) {
|
|
19
|
+
return { echoed: args.text ?? null, to: asker.id ?? null };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
class Shop extends Being {
|
|
23
|
+
static kind = 'org.example.shop';
|
|
24
|
+
static cells = { sold: [] };
|
|
25
|
+
static asks = { buy: {} };
|
|
26
|
+
buy(args, asker) {
|
|
27
|
+
this.cells.sold.push(`${String(args.item)} to ${asker.id ?? 'nobody'}`);
|
|
28
|
+
return { bought: args.item ?? null };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
class Customer extends Being {
|
|
32
|
+
static kind = 'org.example.customer';
|
|
33
|
+
static asks = { join: {}, buy: {}, echo: {}, spoil: {} };
|
|
34
|
+
spoil() {
|
|
35
|
+
try {
|
|
36
|
+
this.cells.when = new Date();
|
|
37
|
+
return { kept: true };
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { refused: true };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async join(args) {
|
|
44
|
+
return { joined: await this.stance.standings.take('shop', args.invitation) };
|
|
45
|
+
}
|
|
46
|
+
async buy(args) {
|
|
47
|
+
return { said: ((await this.stance.standings.get('shop')?.ask('buy', args)) ?? null) };
|
|
48
|
+
}
|
|
49
|
+
async echo(args) {
|
|
50
|
+
if (!this.stance.standings.get('echo'))
|
|
51
|
+
await this.stance.lend(Echoes, 'echo');
|
|
52
|
+
return { said: ((await this.stance.standings.get('echo')?.ask('echo', args)) ?? null) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// One set of classes for every scene in a bundle, since a bundler renames
|
|
56
|
+
// a second class of the same name and a row resolves a class by its name.
|
|
57
|
+
export const classes = [Echo, Shop, Customer];
|
|
58
|
+
export const law = { module: 'org.example.law', version: '1', classes };
|
|
59
|
+
// A harbor of the world whose catalogue runs the law's module.
|
|
60
|
+
export const standOne = async (world, name, parts = {}) => {
|
|
61
|
+
const harbor = await world.harbor(name, [law], parts);
|
|
62
|
+
await harbor.root('ask', { being: 'catalogue', method: 'add', args: { from: law.module } });
|
|
63
|
+
return harbor;
|
|
64
|
+
};
|
|
65
|
+
const CATALOGUE = { catalogue: { class: 'org.nervur.catalogue', public: false, absent: false } };
|
|
66
|
+
const NONE = { wards: {}, routes: {} };
|
|
67
|
+
// Every byte a memory is handed, seen from outside it.
|
|
68
|
+
class Watched extends Memory {
|
|
69
|
+
inner;
|
|
70
|
+
seen = [];
|
|
71
|
+
constructor(inner) {
|
|
72
|
+
super();
|
|
73
|
+
this.inner = inner;
|
|
74
|
+
}
|
|
75
|
+
read(place) {
|
|
76
|
+
return this.inner.read(place);
|
|
77
|
+
}
|
|
78
|
+
write(place, entries) {
|
|
79
|
+
for (const [name, bytes] of entries)
|
|
80
|
+
this.seen.push({ place, name, bytes: bytes && bytes.slice() });
|
|
81
|
+
return this.inner.write(place, entries);
|
|
82
|
+
}
|
|
83
|
+
places() {
|
|
84
|
+
return this.inner.places();
|
|
85
|
+
}
|
|
86
|
+
forget(place) {
|
|
87
|
+
return this.inner.forget(place);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const utf8 = (text) => new TextEncoder().encode(text);
|
|
91
|
+
const holds = (bytes, part) => {
|
|
92
|
+
outer: for (let at = 0; at + part.length <= bytes.length; at += 1) {
|
|
93
|
+
for (let k = 0; k < part.length; k += 1)
|
|
94
|
+
if (bytes[at + k] !== part[k])
|
|
95
|
+
continue outer;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
};
|
|
100
|
+
const hex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
101
|
+
const census = async (harbor, ward) => {
|
|
102
|
+
const out = (await (ward === undefined ? harbor.root() : harbor.ward(ward).root()));
|
|
103
|
+
return out.notes;
|
|
104
|
+
};
|
|
105
|
+
// Acme hosts a shop; home opens an echo, hosts alice under a drawn seed and
|
|
106
|
+
// bob under a named one, and alice takes the shop and lends the echo.
|
|
107
|
+
const stand = async (world, t, bodies, seed, expect) => {
|
|
108
|
+
const acme = await standOne(world, 'acme', bodies.acme);
|
|
109
|
+
const home = await standOne(world, 'home', bodies.home);
|
|
110
|
+
expect.same(home.names, [], `${t.name}: a new harbor hosts nobody`);
|
|
111
|
+
expect.same(await home.root('open', { key: 'echo', class: 'org.example.echo' }), { opened: 'echo' });
|
|
112
|
+
expect.same(await home.root('host', { ward: 'alice' }), { hosted: 'alice' });
|
|
113
|
+
expect.same(await home.root('host', { ward: 'bob', seed }), { hosted: 'bob' });
|
|
114
|
+
expect.same(await home.ward('alice').root('boot', { key: 'me', class: 'org.example.customer' }), { booted: 'me' });
|
|
115
|
+
expect.same(await acme.root('host', { ward: 'shop' }), { hosted: 'shop' });
|
|
116
|
+
expect.same(await acme.ward('shop').root('boot', { key: 'shop', class: 'org.example.shop' }), { booted: 'shop' });
|
|
117
|
+
const invitation = (await acme.ward('shop').root('invite', { being: 'shop', id: 'alice' }));
|
|
118
|
+
const me = async (method, args = {}) => (await world.get('home').ward('alice').root('ask', { being: 'me', method, args })).answer;
|
|
119
|
+
expect.same(await me('join', { invitation }), { joined: 'shop' });
|
|
120
|
+
expect.same(await me('buy', { item: 'tea' }), { said: { bought: 'tea' } });
|
|
121
|
+
expect.same(await me('echo', { text: 'hi' }), { said: { echoed: 'hi', to: 'lent:1' } });
|
|
122
|
+
};
|
|
123
|
+
export const lawScenes = (t, expect) => [
|
|
124
|
+
[
|
|
125
|
+
`law on ${t.name}: empty memory unpacks a box ward holding the dock and her catalogue, and it wakes the same`,
|
|
126
|
+
async () => {
|
|
127
|
+
const bodies = t.make();
|
|
128
|
+
const world = new World();
|
|
129
|
+
const harbor = await world.harbor('h', [], bodies);
|
|
130
|
+
const pk = harbor.box.pk;
|
|
131
|
+
expect.same(await census(harbor), { pk, beings: CATALOGUE, failed: NONE });
|
|
132
|
+
const again = await world.restart('h', t.again(bodies));
|
|
133
|
+
expect.equal(again.box.pk, pk, 'the seed is the box ward');
|
|
134
|
+
expect.same(await census(again), { pk, beings: CATALOGUE, failed: NONE });
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
[
|
|
138
|
+
`law on ${t.name}: a restart without a module the catalogue names stands, its beings absent, and wakes them when it returns`,
|
|
139
|
+
async () => {
|
|
140
|
+
const bodies = t.make();
|
|
141
|
+
const world = new World();
|
|
142
|
+
const harbor = await standOne(world, 'h', bodies);
|
|
143
|
+
expect.same(await harbor.root('open', { key: 'echo', class: 'org.example.echo' }), { opened: 'echo' });
|
|
144
|
+
expect.same(await harbor.root('host', { ward: 'w' }), { hosted: 'w' });
|
|
145
|
+
const lost = await world.restart('h', { ...t.again(bodies), loader: new HeldLoader([]) });
|
|
146
|
+
expect.same(lost.names, ['w'], 'the rest of the harbor stands');
|
|
147
|
+
expect.same((await census(lost)).beings.echo?.absent, true);
|
|
148
|
+
expect.same(await lost.root('ask', { being: 'catalogue', method: 'modules' }), {
|
|
149
|
+
answer: { [law.module]: { version: '1', running: null, from: law.module, failed: `not found at ${law.module}` } },
|
|
150
|
+
});
|
|
151
|
+
const again = await world.restart('h', { ...t.again(bodies), loader: new HeldLoader([law]) });
|
|
152
|
+
expect.same((await census(again)).beings.echo?.absent, false);
|
|
153
|
+
expect.same(await again.root('ask', { being: 'catalogue', method: 'modules' }), { answer: { [law.module]: { version: '1', running: '1', from: law.module } } });
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
[
|
|
157
|
+
`law on ${t.name}: a module that does not load is removed only once no being stands on the kinds kept for it`,
|
|
158
|
+
async () => {
|
|
159
|
+
const bodies = t.make();
|
|
160
|
+
const world = new World();
|
|
161
|
+
const harbor = await standOne(world, 'h', bodies);
|
|
162
|
+
expect.same(await harbor.root('open', { key: 'echo', class: 'org.example.echo' }), { opened: 'echo' });
|
|
163
|
+
const lost = await world.restart('h', { ...t.again(bodies), loader: new HeldLoader([]) });
|
|
164
|
+
const remove = () => lost.root('ask', { being: 'catalogue', method: 'remove', args: { module: law.module } });
|
|
165
|
+
expect.same(await remove(), { answer: { error: 'a being stands on this module' } });
|
|
166
|
+
expect.same(await lost.root('unboot', { being: 'echo' }), { unbooted: 'echo' });
|
|
167
|
+
expect.same(await remove(), { answer: { removed: law.module } });
|
|
168
|
+
const after = await world.restart('h', t.again(bodies));
|
|
169
|
+
expect.same(await after.root('ask', { being: 'catalogue', method: 'modules' }), { answer: {} });
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
[
|
|
173
|
+
`law on ${t.name}: a restart where a module splits a kind runs the modules before it, and the one alone stays out`,
|
|
174
|
+
async () => {
|
|
175
|
+
const bodies = t.make();
|
|
176
|
+
const world = new World();
|
|
177
|
+
const twin = { module: 'org.example.twin', version: '1', classes: [] };
|
|
178
|
+
const harbor = await world.harbor('h', [law, twin], bodies);
|
|
179
|
+
for (const from of [law.module, twin.module])
|
|
180
|
+
await harbor.root('ask', { being: 'catalogue', method: 'add', args: { from } });
|
|
181
|
+
expect.same(await harbor.root('open', { key: 'echo', class: 'org.example.echo' }), { opened: 'echo' });
|
|
182
|
+
const split = await world.restart('h', { ...t.again(bodies), loader: new HeldLoader([law, { ...twin, version: '2', classes }]) });
|
|
183
|
+
expect.same((await census(split)).beings.echo?.absent, false, 'the module before it runs');
|
|
184
|
+
const modules = (await split.root('ask', { being: 'catalogue', method: 'modules' })).answer;
|
|
185
|
+
expect.same([modules[law.module]?.running, modules[twin.module]?.running], ['1', null]);
|
|
186
|
+
expect.equal(modules[twin.module]?.failed, `kind org.example.echo is in modules ${law.module} and ${twin.module}`);
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
[
|
|
190
|
+
`law on ${t.name}: a hosted ward whose place does not open fails alone, and a box ward whose place does not open fails the harbor`,
|
|
191
|
+
async () => {
|
|
192
|
+
const bodies = t.make();
|
|
193
|
+
const watched = new Watched(bodies.memory);
|
|
194
|
+
const world = new World();
|
|
195
|
+
const harbor = await standOne(world, 'h', { custody: bodies.custody, memory: watched });
|
|
196
|
+
expect.same(await harbor.root('host', { ward: 'good' }), { hosted: 'good' });
|
|
197
|
+
const from = watched.seen.length;
|
|
198
|
+
expect.same(await harbor.root('host', { ward: 'bad' }), { hosted: 'bad' });
|
|
199
|
+
const box = watched.seen[0].place;
|
|
200
|
+
const bad = watched.seen.slice(from).find((s) => s.place !== box).place;
|
|
201
|
+
const places = [bad, box];
|
|
202
|
+
const spoil = async (place) => {
|
|
203
|
+
const [[name, bytes]] = [...(await watched.inner.read(place))];
|
|
204
|
+
const changed = bytes.slice();
|
|
205
|
+
changed[0] = changed[0] ^ 1;
|
|
206
|
+
await watched.inner.write(place, new Map([[name, changed]]));
|
|
207
|
+
};
|
|
208
|
+
await spoil(places[0]);
|
|
209
|
+
const again = await world.restart('h', t.again(bodies));
|
|
210
|
+
expect.same(again.names, ['good']);
|
|
211
|
+
expect.same((await census(again)).failed, { wards: { bad: 'an entry the package did not seal' }, routes: {} });
|
|
212
|
+
expect.same(await rootLine(again, { ward: 'bad' }), { error: 'the ward did not stand', reason: 'an entry the package did not seal' });
|
|
213
|
+
expect.same(await again.root('host', { ward: 'bad' }), { error: 'bad is hosted already' }, 'its name and seed stay kept');
|
|
214
|
+
await spoil(places[1]);
|
|
215
|
+
let refused = '';
|
|
216
|
+
await world.restart('h', t.again(bodies)).catch((e) => (refused = e.message));
|
|
217
|
+
expect.equal(refused, 'an entry the package did not seal');
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
[
|
|
221
|
+
`law on ${t.name}: a package carried to another memory stands the same harbor under the same seed`,
|
|
222
|
+
async () => {
|
|
223
|
+
const here = t.make();
|
|
224
|
+
const there = t.make();
|
|
225
|
+
const world = new World();
|
|
226
|
+
const harbor = await standOne(world, 'here', here);
|
|
227
|
+
expect.same(await harbor.root('open', { key: 'echo', class: 'org.example.echo' }), { opened: 'echo' });
|
|
228
|
+
expect.same(await harbor.root('host', { ward: 'alice' }), { hosted: 'alice' });
|
|
229
|
+
expect.same(await harbor.ward('alice').root('boot', { key: 'me', class: 'org.example.customer' }), { booted: 'me' });
|
|
230
|
+
const pks = { box: harbor.box.pk, alice: harbor.ward('alice').pk };
|
|
231
|
+
const carried = await copyPackage(here.memory, there.memory);
|
|
232
|
+
expect.equal(carried.length, 2, 'the box ward and alice');
|
|
233
|
+
const moved = await world.harbor('there', [law], { custody: here.custody, memory: there.memory, loader: new HeldLoader([law]) });
|
|
234
|
+
expect.same({ box: moved.box.pk, alice: moved.ward('alice').pk }, pks);
|
|
235
|
+
expect.same((await census(moved, 'alice')).beings, { me: { class: 'org.example.customer', public: false, absent: false } });
|
|
236
|
+
expect.same((await moved.ward('alice').root('ask', { being: 'me', method: 'echo', args: { text: 'moved' } })).answer, { said: { echoed: 'moved', to: 'lent:1' } });
|
|
237
|
+
const stranger = await world.harbor('stranger', [law], { custody: t.make().custody, memory: there.memory, loader: new HeldLoader([law]) });
|
|
238
|
+
expect.same(stranger.names, [], 'the bytes carried say nothing without the seed');
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
[
|
|
242
|
+
`law on ${t.name}: the root sweeps every place the dock does not name, and nothing it does`,
|
|
243
|
+
async () => {
|
|
244
|
+
const bodies = t.make();
|
|
245
|
+
const world = new World();
|
|
246
|
+
const harbor = await standOne(world, 'h', bodies);
|
|
247
|
+
expect.same(await harbor.root('host', { ward: 'w' }), { hosted: 'w' });
|
|
248
|
+
const mine = (await bodies.memory.places()).sort();
|
|
249
|
+
expect.equal(mine.length, 2, 'the box ward and the ward it hosts');
|
|
250
|
+
await bodies.memory.write('ff'.repeat(16), new Map([['ab', new Uint8Array([1, 2, 3])]]));
|
|
251
|
+
expect.same(await harbor.root('sweep'), { swept: ['ff'.repeat(16)] });
|
|
252
|
+
expect.same((await bodies.memory.places()).sort(), mine, 'what the dock names stays');
|
|
253
|
+
expect.same(await harbor.root('sweep'), { swept: [] });
|
|
254
|
+
const again = await world.restart('h', t.again(bodies));
|
|
255
|
+
expect.same(again.names, ['w']);
|
|
256
|
+
expect.same(await again.ward('w').root('boot', { key: 'me', class: 'org.example.customer' }), { booted: 'me' });
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
[
|
|
260
|
+
`law on ${t.name}: a restart stands the dock, its faculties, every hosted ward and its beings, and lends, with nobody asking`,
|
|
261
|
+
async () => {
|
|
262
|
+
const bodies = { acme: t.make(), home: t.make() };
|
|
263
|
+
const world = new World();
|
|
264
|
+
const seed = hex(new CryptoEntropy().draw(32));
|
|
265
|
+
await stand(world, t, bodies, seed, expect);
|
|
266
|
+
const before = world.get('home');
|
|
267
|
+
const pks = { box: before.box.pk, alice: before.ward('alice').pk, bob: before.ward('bob').pk, shop: world.get('acme').ward('shop').pk };
|
|
268
|
+
const beings = await census(before, 'alice');
|
|
269
|
+
const home = await world.restart('home', t.again(bodies.home));
|
|
270
|
+
const acme = await world.restart('acme', t.again(bodies.acme));
|
|
271
|
+
expect.same(home.names, ['alice', 'bob']);
|
|
272
|
+
expect.same(acme.names, ['shop']);
|
|
273
|
+
expect.same({ box: home.box.pk, alice: home.ward('alice').pk, bob: home.ward('bob').pk, shop: acme.ward('shop').pk }, pks);
|
|
274
|
+
expect.same(await census(home, 'alice'), beings);
|
|
275
|
+
expect.equal(home.box.partition.being('echo')?.class, 'org.example.echo');
|
|
276
|
+
const me = async (method, args = {}) => (await home.ward('alice').root('ask', { being: 'me', method, args })).answer;
|
|
277
|
+
expect.same(await me('buy', { item: 'cake' }), { said: { bought: 'cake' } });
|
|
278
|
+
expect.same(await me('echo', { text: 'again' }), { said: { echoed: 'again', to: 'lent:1' } });
|
|
279
|
+
expect.same(acme.ward('shop').partition.being('shop')?.cells, { sold: ['tea to alice', 'cake to alice'] });
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
[
|
|
283
|
+
`law on ${t.name}: every root ask is a step a restart may follow, and each step wakes whole`,
|
|
284
|
+
async () => {
|
|
285
|
+
const bodies = t.make();
|
|
286
|
+
const world = new World();
|
|
287
|
+
await world.harbor('h', [law], bodies);
|
|
288
|
+
const step = async (ask, want) => {
|
|
289
|
+
expect.same(await ask(world.get('h')), want);
|
|
290
|
+
return world.restart('h', t.again(bodies));
|
|
291
|
+
};
|
|
292
|
+
await step((h) => h.root('ask', { being: 'catalogue', method: 'add', args: { from: law.module } }), { answer: { added: law.module, version: law.version } });
|
|
293
|
+
await step((h) => h.root('open', { key: 'echo', class: 'org.example.echo' }), { opened: 'echo' });
|
|
294
|
+
await step((h) => h.root('host', { ward: 'alice' }), { hosted: 'alice' });
|
|
295
|
+
await step((h) => h.ward('alice').root('boot', { key: 'me', class: 'org.example.customer' }), { booted: 'me' });
|
|
296
|
+
const h = await step(async (x) => (await x.ward('alice').root('ask', { being: 'me', method: 'echo', args: { text: 'one' } })).answer, { said: { echoed: 'one', to: 'lent:1' } });
|
|
297
|
+
expect.same((await h.ward('alice').root('ask', { being: 'me', method: 'echo', args: { text: 'two' } })).answer, { said: { echoed: 'two', to: 'lent:1' } });
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
[
|
|
301
|
+
`law on ${t.name}: the terrain keeps the seed and sealed bytes, and reads no name, cell or hosted seed`,
|
|
302
|
+
async () => {
|
|
303
|
+
const home = t.make();
|
|
304
|
+
const watched = new Watched(home.memory);
|
|
305
|
+
const world = new World();
|
|
306
|
+
const seed = hex(new CryptoEntropy().draw(32));
|
|
307
|
+
await stand(world, t, { acme: t.make(), home: { custody: home.custody, memory: watched } }, seed, expect);
|
|
308
|
+
const harbor = world.get('home');
|
|
309
|
+
const secrets = [seed, ...harbor.dock.hosted.map((h) => h.seed)].map(utf8);
|
|
310
|
+
// Names of five bytes or more: a shorter one turns up in random bytes
|
|
311
|
+
// of this size often enough to fail a run by chance.
|
|
312
|
+
const words = ['alice', 'org.example', 'hosted', 'lent:1', 'customer'].map(utf8);
|
|
313
|
+
expect.ok(watched.seen.length > 0, 'the harbor kept something');
|
|
314
|
+
for (const { place, name, bytes } of watched.seen) {
|
|
315
|
+
expect.ok(/^[0-9a-f]+$/.test(place) && /^[0-9a-f]+$/.test(name), 'names are the kit hex');
|
|
316
|
+
if (bytes === null)
|
|
317
|
+
continue;
|
|
318
|
+
for (const secret of secrets)
|
|
319
|
+
expect.ok(!holds(bytes, secret), 'no hosted seed in the clear');
|
|
320
|
+
for (const word of words)
|
|
321
|
+
expect.ok(!holds(bytes, word), 'no name or cell in the clear');
|
|
322
|
+
expect.ok(!holds(bytes, await home.custody.seed()), 'no harbor seed in memory');
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
[
|
|
327
|
+
`law on ${t.name}: another seed finds nothing of the package, and a byte changed refuses to unpack`,
|
|
328
|
+
async () => {
|
|
329
|
+
const bodies = t.make();
|
|
330
|
+
const watched = new Watched(bodies.memory);
|
|
331
|
+
const world = new World();
|
|
332
|
+
const harbor = await standOne(world, 'h', { custody: bodies.custody, memory: watched });
|
|
333
|
+
expect.same(await harbor.root('host', { ward: 'w' }), { hosted: 'w' });
|
|
334
|
+
const last = [...watched.seen].reverse().find((s) => s.bytes !== null && s.bytes.length > 0);
|
|
335
|
+
const stranger = await world.harbor('stranger', [law], { custody: t.make().custody, memory: watched });
|
|
336
|
+
expect.same(stranger.names, [], 'another seed opens another package');
|
|
337
|
+
const bytes = last.bytes.slice();
|
|
338
|
+
bytes[0] = bytes[0] ^ 1;
|
|
339
|
+
await watched.inner.write(last.place, new Map([[last.name, bytes]]));
|
|
340
|
+
let refused = false;
|
|
341
|
+
await world.restart('h', t.again(bodies)).catch(() => (refused = true));
|
|
342
|
+
expect.ok(refused, 'a changed byte does not unpack');
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nervur",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "Nervur's kit of Quo: an object asks another object and gets an answer, without knowing where it is.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"quo",
|
|
@@ -46,6 +46,10 @@
|
|
|
46
46
|
"./http": {
|
|
47
47
|
"types": "./dist/http/index.d.ts",
|
|
48
48
|
"default": "./dist/http/index.js"
|
|
49
|
+
},
|
|
50
|
+
"./proof": {
|
|
51
|
+
"types": "./dist/proof/index.d.ts",
|
|
52
|
+
"default": "./dist/proof/index.js"
|
|
49
53
|
}
|
|
50
54
|
},
|
|
51
55
|
"scripts": {
|
|
@@ -71,5 +75,8 @@
|
|
|
71
75
|
"README.md",
|
|
72
76
|
"LICENSE",
|
|
73
77
|
"NOTICE"
|
|
74
|
-
]
|
|
78
|
+
],
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"wrangler": "^4.134.0"
|
|
81
|
+
}
|
|
75
82
|
}
|
package/src/cli/command.ts
CHANGED
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
// printed, for the worker's
|
|
24
24
|
// NERVUR_SEED, and its sealed
|
|
25
25
|
// package written to the file
|
|
26
|
+
// nervur edge dev <file> [--port P] the harbor packed to the file and
|
|
27
|
+
// run by the worker's own wrangler
|
|
28
|
+
// dev, from this folder, at
|
|
29
|
+
// http://127.0.0.1:P/quo
|
|
26
30
|
// nervur open <key> <kind> a faculty stands in the box ward
|
|
27
31
|
// nervur host <ward> [--seed S] a hosted ward stands
|
|
28
32
|
// nervur route <ward pk> <address... | none> where a ward is reached, in order
|
|
@@ -53,8 +57,12 @@ import type { RootRequest } from '../harbor/index.ts';
|
|
|
53
57
|
import { WEB_OPENING } from '../line/index.ts';
|
|
54
58
|
import { harborDir, isFile } from '../node/index.ts';
|
|
55
59
|
import { readAt, webAddress } from '../quo/index.ts';
|
|
60
|
+
import { edgeDev } from './edge.ts';
|
|
56
61
|
import { carry, isServed, land, open, pack, request, serve } from './harbor.ts';
|
|
57
62
|
|
|
63
|
+
// Where `edge dev` answers unless --port names another, wrangler's own.
|
|
64
|
+
const EDGE_PORT = 8787;
|
|
65
|
+
|
|
58
66
|
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]';
|
|
59
67
|
const FLAGS = new Set(['dir', 'ward', 'via', 'seed', 'host', 'port']);
|
|
60
68
|
// The one flag that stands alone, since what it names is a yes.
|
|
@@ -182,10 +190,16 @@ export const main = async (argv: string[], env: Record<string, string | undefine
|
|
|
182
190
|
return out({ landed: await land(dir, JSON.parse(text)) });
|
|
183
191
|
}
|
|
184
192
|
if (command === 'edge') {
|
|
185
|
-
if (words[0] !== 'pack' || words.length !== 2) throw new Error('edge takes pack and a file');
|
|
186
|
-
if (flags.via !== undefined) throw new Error(
|
|
193
|
+
if ((words[0] !== 'pack' && words[0] !== 'dev') || words.length !== 2) throw new Error('edge takes pack or dev, and a file');
|
|
194
|
+
if (flags.via !== undefined) throw new Error(`edge ${words[0]} reads this folder, so it takes no --via`);
|
|
187
195
|
if (await isServed(dir)) throw new Error(`a harbor is served from ${dir}: stop it first`);
|
|
188
|
-
return out(await pack(dir, words[1]!));
|
|
196
|
+
if (words[0] === 'pack') return out(await pack(dir, words[1]!));
|
|
197
|
+
const dev = await edgeDev(dir, words[1]!, process.cwd(), flags.port === undefined ? EDGE_PORT : Number(flags.port));
|
|
198
|
+
const stop = (): void => void dev.close().then(() => process.exit(0));
|
|
199
|
+
process.once('SIGINT', stop);
|
|
200
|
+
process.once('SIGTERM', stop);
|
|
201
|
+
print(JSON.stringify({ at: dev.at, places: dev.places }));
|
|
202
|
+
return SERVING;
|
|
189
203
|
}
|
|
190
204
|
if (command === 'serve') {
|
|
191
205
|
if (words.length > 0) throw new Error('serve takes no argument');
|
package/src/cli/edge.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
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.
|
|
9
|
+
//
|
|
10
|
+
// wrangler is the worker's tool and never the kit's: the one who deploys
|
|
11
|
+
// an edge has it already, and `edge dev` finds it where npm put it.
|
|
12
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { pack } from './harbor.ts';
|
|
15
|
+
|
|
16
|
+
export const EDGE_STATE = '.nervur-edge';
|
|
17
|
+
const READY_TRIES = 240;
|
|
18
|
+
const READY_EVERY = 250;
|
|
19
|
+
|
|
20
|
+
export type EdgeDev = { readonly at: string; readonly places: number; close(): Promise<void> };
|
|
21
|
+
|
|
22
|
+
const answers = (url: string): Promise<boolean> =>
|
|
23
|
+
fetch(url).then(
|
|
24
|
+
() => true,
|
|
25
|
+
() => false,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Resolved once wrangler answers at `url`, rejected once it exits or never
|
|
29
|
+
// answers.
|
|
30
|
+
const ready = async (child: ChildProcess, url: string, said: () => string): Promise<void> => {
|
|
31
|
+
let gone = false;
|
|
32
|
+
child.once('exit', () => (gone = true));
|
|
33
|
+
for (let i = 0; i < READY_TRIES; i += 1) {
|
|
34
|
+
if (gone) throw new Error(`wrangler dev exited: ${said()}`);
|
|
35
|
+
if (await answers(url)) return;
|
|
36
|
+
await new Promise((r) => setTimeout(r, READY_EVERY));
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`wrangler dev never answered at ${url}: ${said()}`);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const edgeDev = async (dir: string, file: string, cwd: string, port: number): Promise<EdgeDev> => {
|
|
42
|
+
const { seed, places } = (await pack(dir, file)) as { seed: string; places: number };
|
|
43
|
+
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'], {
|
|
44
|
+
cwd,
|
|
45
|
+
env: { ...process.env, NERVUR_SEED: seed, CLOUDFLARE_INCLUDE_PROCESS_ENV: 'true' },
|
|
46
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
47
|
+
detached: true,
|
|
48
|
+
});
|
|
49
|
+
let err = '';
|
|
50
|
+
child.stderr!.on('data', (chunk: Buffer) => (err = `${err}${chunk.toString('utf8')}`.slice(-4000)));
|
|
51
|
+
const at = `http://127.0.0.1:${String(port)}/quo`;
|
|
52
|
+
const close = (): Promise<void> =>
|
|
53
|
+
new Promise((done) => {
|
|
54
|
+
if (child.exitCode !== null) return done();
|
|
55
|
+
child.once('exit', () => done());
|
|
56
|
+
process.kill(-child.pid!, 'SIGTERM');
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
await ready(child, at, () => err);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
await close();
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
return { at, places, close };
|
|
65
|
+
};
|
package/src/contract/index.ts
CHANGED
|
@@ -62,7 +62,8 @@ export type Module = { readonly module: string; readonly version: string; readon
|
|
|
62
62
|
export type Loaded = Module & { readonly from: string };
|
|
63
63
|
|
|
64
64
|
// Code, by the name a harbor keeps for it, or null where this loader has
|
|
65
|
-
// none. Code
|
|
65
|
+
// none. Code it has that does not load throws, saying why. Code never
|
|
66
|
+
// crosses a door and never lives in a cell, so it is the
|
|
66
67
|
// one thing beside the seed a terrain hands in: a map, an import, a bundle.
|
|
67
68
|
export abstract class Loader {
|
|
68
69
|
abstract load(from: string): Promise<Loaded | null>;
|
package/src/harbor/catalogue.ts
CHANGED
|
@@ -67,10 +67,16 @@ export class Catalogue extends Faculty {
|
|
|
67
67
|
// it. The rest stay out, each with its reason.
|
|
68
68
|
async wake(): Promise<void> {
|
|
69
69
|
for (const [module, row] of Object.entries(this.#rows)) {
|
|
70
|
-
const loaded = await this.#harbor.load(row.from).catch(() =>
|
|
70
|
+
const loaded = await this.#harbor.load(row.from).catch((e: Error) => e);
|
|
71
71
|
const refused =
|
|
72
|
-
loaded
|
|
73
|
-
|
|
72
|
+
loaded instanceof Error
|
|
73
|
+
? loaded.message
|
|
74
|
+
: loaded === null
|
|
75
|
+
? `not found at ${row.from}`
|
|
76
|
+
: loaded.module !== module
|
|
77
|
+
? `${row.from} is module ${loaded.module}`
|
|
78
|
+
: this.#harbor.check([...this.#running.values(), loaded]);
|
|
79
|
+
if (refused === null) this.#running.set(module, loaded as Loaded);
|
|
74
80
|
else this.#failed.set(module, refused);
|
|
75
81
|
}
|
|
76
82
|
this.#harbor.run([...this.#running.values()]);
|
|
@@ -78,7 +84,8 @@ export class Catalogue extends Faculty {
|
|
|
78
84
|
|
|
79
85
|
async add(args: JsonObject): Promise<JsonObject> {
|
|
80
86
|
if (!text(args.from)) return { error: 'from is text' };
|
|
81
|
-
const loaded = await this.#harbor.load(args.from);
|
|
87
|
+
const loaded = await this.#harbor.load(args.from).catch((e: Error) => e);
|
|
88
|
+
if (loaded instanceof Error) return { error: loaded.message };
|
|
82
89
|
if (loaded === null) return { error: 'no such module' };
|
|
83
90
|
const running = new Map(this.#running);
|
|
84
91
|
running.set(loaded.module, loaded);
|
package/src/harbor/dock.ts
CHANGED
|
@@ -158,7 +158,9 @@ export class Dock extends WardBeing implements Lender {
|
|
|
158
158
|
|
|
159
159
|
async host(args: JsonObject): Promise<JsonObject> {
|
|
160
160
|
const name = args.ward;
|
|
161
|
-
if (!text(name)
|
|
161
|
+
if (!text(name)) return { error: 'ward is text' };
|
|
162
|
+
if (name === BOX) return { error: 'box is the box ward' };
|
|
163
|
+
if (this.hosted.some((h) => h.ward === name)) return { error: `${name} is hosted already` };
|
|
162
164
|
if (args.seed !== undefined && !text(args.seed)) return { error: 'seed is text' };
|
|
163
165
|
const invitation = await this.stance.occupants.invite(name, { hosted: true });
|
|
164
166
|
if (invitation === null) return { error: 'not hosted' };
|
package/src/index.ts
CHANGED
|
@@ -47,7 +47,7 @@ export {
|
|
|
47
47
|
|
|
48
48
|
// The pointer dock: a whole world in one process.
|
|
49
49
|
export { CryptoEntropy, HeldCustody, HeldLoader, NoCarrier, PointerCarrier, PointerTerrain, SystemClock, VolatileMemory, World, type PointerParts } from './pointer/index.ts';
|
|
50
|
-
export { NEUTRAL, WEB, WEB_OPENING, WEB_PUSH, WebDialer, webFaculty, webPush, type WebListen, type WebListener, type WebServe } from './line/index.ts';
|
|
50
|
+
export { answerPost, NEUTRAL, WEB, WEB_OPENING, WEB_PUSH, WebDialer, webFaculty, webPush, type WebListen, type WebListener, type WebServe } from './line/index.ts';
|
|
51
51
|
|
|
52
52
|
// The ground that fits everywhere, tried after any a platform entry adds.
|
|
53
53
|
addGround(neutralGround);
|
package/src/node/index.ts
CHANGED
|
@@ -50,8 +50,10 @@ export const isFile = (from: string): boolean => /^(\.{1,2}[\\/]|[\\/]|[A-Za-z]:
|
|
|
50
50
|
// Code from a file or an installed package: a module that exports
|
|
51
51
|
// `module`, `version` and `classes`, found again by where it was found, a
|
|
52
52
|
// file by its absolute path and a package by its name, resolved from each
|
|
53
|
-
// folder of `bases` in order. What
|
|
54
|
-
//
|
|
53
|
+
// folder of `bases` in order. What is not there, or exports no module, is
|
|
54
|
+
// none. Code that is there and does not import throws Node's reason, so
|
|
55
|
+
// whoever added it learns why; an ES module read as CommonJS says which
|
|
56
|
+
// field its package.json lacks.
|
|
55
57
|
export class ImportLoader extends Loader {
|
|
56
58
|
readonly #bases: readonly string[];
|
|
57
59
|
constructor(bases: readonly string[] = [process.cwd()]) {
|
|
@@ -65,8 +67,13 @@ export class ImportLoader extends Loader {
|
|
|
65
67
|
let exported: { module?: unknown; version?: unknown; classes?: unknown };
|
|
66
68
|
try {
|
|
67
69
|
exported = (await import(pathToFileURL(found).href)) as typeof exported;
|
|
68
|
-
} catch {
|
|
69
|
-
|
|
70
|
+
} catch (e) {
|
|
71
|
+
const said = (e as Error).message;
|
|
72
|
+
// The name itself is nowhere, as against code there that needs
|
|
73
|
+
// something that is not.
|
|
74
|
+
if ((e as { code?: string }).code === 'ERR_MODULE_NOT_FOUND' && said.includes(`'${found}'`)) return null;
|
|
75
|
+
const commonjs = e instanceof SyntaxError && /\b(export|import)\b/.test(said);
|
|
76
|
+
throw new Error(`${found} did not load: ${said}${commonjs ? '; it is an ES module read as CommonJS, so set "type": "module" in its package.json' : ''}`, { cause: e });
|
|
70
77
|
}
|
|
71
78
|
const { module, version, classes } = exported;
|
|
72
79
|
if (typeof module !== 'string' || typeof version !== 'string' || !Array.isArray(classes)) return null;
|