@quo-systems/quo 0.2.8 → 0.2.10
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 +19 -10
- package/SPEC.md +161 -89
- package/dist/being/being.d.ts +2 -2
- package/dist/being/being.js +27 -11
- package/dist/being/digest.js +11 -7
- package/dist/being/types.d.ts +2 -2
- package/dist/conformance/estate.js +1 -3
- package/dist/conformance/index.js +1 -1
- package/dist/harbor/core.d.ts +2 -0
- package/dist/harbor/core.js +114 -25
- package/dist/harbor/dial.js +5 -3
- package/dist/harbor/memory.d.ts +1 -1
- package/dist/harbor/memory.js +3 -2
- package/dist/harbor/reach.js +32 -7
- package/dist/ward/allowance.js +8 -2
- package/dist/ward/arithmetic.js +11 -3
- package/dist/ward/cells.d.ts +3 -1
- package/dist/ward/cells.js +79 -21
- package/dist/ward/door.d.ts +1 -0
- package/dist/ward/door.js +24 -5
- package/dist/ward/ground.d.ts +1 -0
- package/dist/ward/heirs.d.ts +1 -1
- package/dist/ward/heirs.js +15 -8
- package/dist/ward/owner.d.ts +5 -26
- package/dist/ward/owner.js +24 -15
- package/dist/ward/seal.js +3 -0
- package/dist/ward/stance.d.ts +4 -1
- package/dist/ward/stance.js +116 -54
- package/dist/ward/ward.d.ts +10 -0
- package/dist/ward/ward.js +87 -36
- package/package.json +4 -2
- package/src/being/being.ts +26 -10
- package/src/being/digest.ts +13 -11
- package/src/being/types.ts +10 -4
- package/src/conformance/estate.ts +1 -3
- package/src/conformance/index.ts +1 -1
- package/src/harbor/core.ts +110 -26
- package/src/harbor/dial.ts +6 -4
- package/src/harbor/memory.ts +3 -2
- package/src/harbor/reach.ts +38 -11
- package/src/ward/allowance.ts +8 -2
- package/src/ward/arithmetic.ts +11 -3
- package/src/ward/cells.ts +76 -25
- package/src/ward/door.ts +22 -5
- package/src/ward/ground.ts +9 -2
- package/src/ward/heirs.ts +13 -6
- package/src/ward/owner.ts +24 -24
- package/src/ward/seal.ts +2 -0
- package/src/ward/stance.ts +117 -48
- package/src/ward/ward.ts +86 -35
- package/vectors/arithmetic.json +7 -0
- package/vectors/framing.json +14 -5
package/src/being/types.ts
CHANGED
|
@@ -79,7 +79,9 @@ export type Standings = {
|
|
|
79
79
|
} & { readonly [id: string]: Standing | undefined };
|
|
80
80
|
|
|
81
81
|
export type Occupants = {
|
|
82
|
-
|
|
82
|
+
// awaitable: a key is minted. The notes are the terms it is minted under,
|
|
83
|
+
// written on the occupant for her gate to read, and hers alone after that.
|
|
84
|
+
invite(id: string, notes?: JsonObject): Promise<Invitation | null>;
|
|
83
85
|
|
|
84
86
|
remove(id: string): void;
|
|
85
87
|
};
|
|
@@ -90,9 +92,13 @@ export type Stance = {
|
|
|
90
92
|
readonly occupants: Occupants;
|
|
91
93
|
readonly standings: Standings;
|
|
92
94
|
// A new being of her ward, by class name, under a key she chooses. The key
|
|
93
|
-
// back, or null: the key is taken
|
|
94
|
-
// being may make; only the owner reaches into
|
|
95
|
-
|
|
95
|
+
// back, or null: the key is taken, the harbor holds no such class, or the
|
|
96
|
+
// class threw at birth. A being may make; only the owner reaches into
|
|
97
|
+
// another. Naming an id asks for a way back too: the being made holds an
|
|
98
|
+
// occupant for her maker under the maker's own key, and the maker holds
|
|
99
|
+
// the standing under that id. A relation that could not be made is a boot
|
|
100
|
+
// that made nobody, and the being made goes with it.
|
|
101
|
+
boot(className: string, key: string, id?: string): Promise<string | null>;
|
|
96
102
|
};
|
|
97
103
|
|
|
98
104
|
// The raw shape of a being. Anything with these two is a being.
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// harbors, and that is where the promise of Quo is held: a being never learns
|
|
23
23
|
// where the other one is, so every answer, after every move, under every
|
|
24
24
|
// topology, is the answer the model wrote without knowing the topology
|
|
25
|
-
// either. Held
|
|
25
|
+
// either. Held after every one of sixty moves, it is the promise at a scale a scene
|
|
26
26
|
// cannot reach, and it is what makes a migration honest: if no answer can
|
|
27
27
|
// tell the topology apart, moving a ward between harbors cannot be felt.
|
|
28
28
|
// The model itself is the script's own bookkeeping and takes nothing from a
|
|
@@ -267,8 +267,6 @@ export function estate(label: string, make: () => Promise<World>, { canDown, tes
|
|
|
267
267
|
return `${who}'s ward moves harbor`;
|
|
268
268
|
});
|
|
269
269
|
|
|
270
|
-
// weather: the host's door is gone. unreached, never silence, and the
|
|
271
|
-
// relation is exactly where it was when it comes back.
|
|
272
270
|
// weather: the host's door is gone. unreached, never silence, and the
|
|
273
271
|
// relation is exactly where it was when it comes back. The move is on
|
|
274
272
|
// the list under every topology even where there is nothing to cut,
|
package/src/conformance/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// handle.being the object, as the test drives it
|
|
13
13
|
// handle.cells her cells, as the ward keeps them
|
|
14
14
|
// handle.down = true | false her door is unreachable (needs canDown)
|
|
15
|
-
// handle.minted.has(pk)
|
|
15
|
+
// handle.minted.has(pk) the last eight pks her side minted
|
|
16
16
|
// handle.forgeKnock(invitation) make her side knock with a key of its own, unannounced
|
|
17
17
|
// world.census() every partition in the world, as values
|
|
18
18
|
// world.migrate(key) her ward moves to another harbor, same seed, same pk
|
package/src/harbor/core.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { Ward } from '../ward/ward.ts';
|
|
20
20
|
import type { Ground, WardPointers } from '../ward/ground.ts';
|
|
21
21
|
import type { BeingClass, BeingLike } from '../being/types.ts';
|
|
22
|
+
import { silence } from '../being/silence.ts';
|
|
22
23
|
import { request, type Reach } from './reach.ts';
|
|
23
24
|
import { openReply, wardSignPk } from '../ward/seal.ts';
|
|
24
25
|
import { concat, sealingPair, KEY } from '../ward/arithmetic.ts';
|
|
@@ -26,6 +27,13 @@ import { within, LATE } from '../ward/allowance.ts';
|
|
|
26
27
|
import type { Kept, Store, WardRecord } from './store.ts';
|
|
27
28
|
|
|
28
29
|
export type Hosted = WardPointers & { pk: string; name: string; record: WardRecord; partition: Record<string, unknown>; being(key: string): BeingLike | undefined; save(): Promise<void> };
|
|
30
|
+
|
|
31
|
+
// One ward's saves, in a line: the ward says it wrote, the harbor writes the
|
|
32
|
+
// partition once the line is free, and never twice at once, so an older
|
|
33
|
+
// snapshot cannot land after a newer one. A save that fails is the harbor's
|
|
34
|
+
// to count and never a door's to answer: the door answered bytes, and a
|
|
35
|
+
// disk that is full is not a reason the far side may hear.
|
|
36
|
+
type Saving = { memory: Record<string, unknown>; dirty: boolean; running: Promise<void> | null; timer: ReturnType<typeof setTimeout> | undefined; gone: boolean };
|
|
29
37
|
// A reach in the directory, and whether this harbor holds it as a socket a
|
|
30
38
|
// dialer opened: only those, and its own doors, take bytes from the wire.
|
|
31
39
|
export type Bound = { reach: Reach; held: boolean };
|
|
@@ -37,6 +45,12 @@ export const DEFAULT_CODE = 'classes/index.ts';
|
|
|
37
45
|
// How long a probe waits for the door behind a claim. A claim that answers
|
|
38
46
|
// nothing in this time is not bound; the next announce is another chance.
|
|
39
47
|
const PROBE = 10_000;
|
|
48
|
+
// How many pks one announce may claim, and how many are proven at once.
|
|
49
|
+
const ANNOUNCE = 64;
|
|
50
|
+
const PROVING = 8;
|
|
51
|
+
// How many times each reach has announced: a proof from an older announce
|
|
52
|
+
// binds nothing when it lands.
|
|
53
|
+
const announces = new WeakMap<Reach, number>();
|
|
40
54
|
|
|
41
55
|
export class Harbor {
|
|
42
56
|
readonly store: Store;
|
|
@@ -46,7 +60,10 @@ export class Harbor {
|
|
|
46
60
|
readonly reaches = new Map<string, Bound>(); // the directory: foreign ward pk -> reach
|
|
47
61
|
readonly down = new Set<string>(); // pks this harbor will not reach right now: weather, for a test
|
|
48
62
|
readonly refused = new Map<string, number>(); // own ward pk -> arrivals its door would not admit. what a terrain does with it is its own
|
|
63
|
+
readonly faults = new Map<string, number>(); // ward name -> saves the store refused. what a terrain does with it is its own
|
|
64
|
+
readonly unbooted: string[] = []; // the wards the store keeps that would not host this run, by name
|
|
49
65
|
readonly classes: Record<string, BeingClass> = {}; // classes handed in-process, beside the loader's
|
|
66
|
+
readonly #saving = new Map<string, Saving>();
|
|
50
67
|
// The dialers' sockets, in the order they were dialed: where a pk nobody
|
|
51
68
|
// here knows is sent, because the listener a harbor dialed is a rendezvous
|
|
52
69
|
// and may hold that pk for someone else. It is a list because a harbor may
|
|
@@ -72,15 +89,56 @@ export class Harbor {
|
|
|
72
89
|
this.loader = loader;
|
|
73
90
|
}
|
|
74
91
|
|
|
75
|
-
// Boot every ward the store keeps, and learn the hints.
|
|
92
|
+
// Boot every ward the store keeps, and learn the hints. One ward that will
|
|
93
|
+
// not host, a partition this kit cannot read or a loader that throws, is
|
|
94
|
+
// named in `unbooted` and takes nobody else down with it.
|
|
76
95
|
async boot(): Promise<void> {
|
|
77
96
|
for (const name of await this.store.list()) {
|
|
78
|
-
|
|
79
|
-
|
|
97
|
+
try {
|
|
98
|
+
const kept = await this.store.load(name);
|
|
99
|
+
if (kept) await this.host(name, kept);
|
|
100
|
+
} catch {
|
|
101
|
+
this.unbooted.push(name);
|
|
102
|
+
}
|
|
80
103
|
}
|
|
81
104
|
for (const [pk, url] of Object.entries(await this.store.hints())) this.hint(pk, url);
|
|
82
105
|
}
|
|
83
106
|
|
|
107
|
+
// ---- saving
|
|
108
|
+
|
|
109
|
+
// The ward wrote. Its partition is saved once the line is free, and once
|
|
110
|
+
// for every burst of writes, in the order the writes came.
|
|
111
|
+
#wrote(name: string): void {
|
|
112
|
+
const s = this.#saving.get(name);
|
|
113
|
+
if (!s || s.gone) return;
|
|
114
|
+
s.dirty = true;
|
|
115
|
+
if (s.timer === undefined) s.timer = setTimeout(() => void this.#flush(name), 0);
|
|
116
|
+
}
|
|
117
|
+
async #flush(name: string): Promise<void> {
|
|
118
|
+
const s = this.#saving.get(name);
|
|
119
|
+
if (!s) return;
|
|
120
|
+
if (s.timer !== undefined) {
|
|
121
|
+
clearTimeout(s.timer);
|
|
122
|
+
s.timer = undefined;
|
|
123
|
+
}
|
|
124
|
+
if (s.running) return s.running;
|
|
125
|
+
s.running = (async () => {
|
|
126
|
+
while (s.dirty && !s.gone) {
|
|
127
|
+
s.dirty = false;
|
|
128
|
+
try {
|
|
129
|
+
await this.store.save(name, s.memory);
|
|
130
|
+
} catch {
|
|
131
|
+
this.faults.set(name, (this.faults.get(name) ?? 0) + 1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
})();
|
|
135
|
+
try {
|
|
136
|
+
await s.running;
|
|
137
|
+
} finally {
|
|
138
|
+
s.running = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
84
142
|
// ---- the directory
|
|
85
143
|
|
|
86
144
|
// Bytes to one of this harbor's own doors, and the one bit the door says
|
|
@@ -113,7 +171,7 @@ export class Harbor {
|
|
|
113
171
|
}
|
|
114
172
|
|
|
115
173
|
hint(pk: string, url: string): void {
|
|
116
|
-
if (this.reaches.
|
|
174
|
+
if (this.reaches.has(pk)) return; // a reach proven at a door beats a hint, held or reached through a dialed line
|
|
117
175
|
this.reaches.set(pk, { reach: request(url), held: false });
|
|
118
176
|
}
|
|
119
177
|
|
|
@@ -130,8 +188,8 @@ export class Harbor {
|
|
|
130
188
|
// signed by the ward key. Only the holder of that ward's seed can write
|
|
131
189
|
// that reply, the lid is fresh so nothing replays, and a box that does not
|
|
132
190
|
// open writes nothing at the ward. This is the one box a harbor ever opens,
|
|
133
|
-
// the one it sealed itself, and it reads nothing from it but that
|
|
134
|
-
//
|
|
191
|
+
// the one it sealed itself, and it reads nothing from it but that it is
|
|
192
|
+
// the silence a door owes such a box, signed by the claimed key.
|
|
135
193
|
async prove(pk: string, reach: Reach): Promise<boolean> {
|
|
136
194
|
if (!/^[0-9a-f]{128}$/.test(pk)) return false;
|
|
137
195
|
try {
|
|
@@ -148,16 +206,27 @@ export class Harbor {
|
|
|
148
206
|
|
|
149
207
|
// Bind what the far side claims, each pk proven at its door first. Both
|
|
150
208
|
// sides bind this way: the listener the dialer's claims, the dialer the
|
|
151
|
-
// listener's, since a claim is a claim whichever end made it.
|
|
209
|
+
// listener's, since a claim is a claim whichever end made it. An announce
|
|
210
|
+
// names at most this many pks, and they are proven a few at a time: a
|
|
211
|
+
// side that announced a thousand would otherwise cost a thousand keys, a
|
|
212
|
+
// thousand frames and a thousand waits at once, on its word alone.
|
|
152
213
|
async bind(pks: string[], reach: Reach, held: boolean): Promise<void> {
|
|
153
214
|
// An announce is the whole of what that side holds now, so a pk this
|
|
154
|
-
// reach was bound for and
|
|
215
|
+
// reach was bound for and does not claim now is unbound at once: a ward
|
|
155
216
|
// that left a dialer is not reachable through it, proof or no proof.
|
|
217
|
+
// And it is the newest word: a proof still in flight from an earlier
|
|
218
|
+
// announce on this reach binds nothing when it lands, since the side
|
|
219
|
+
// has spoken again since.
|
|
220
|
+
const said = (announces.get(reach) ?? 0) + 1;
|
|
221
|
+
announces.set(reach, said);
|
|
156
222
|
for (const [pk, b] of this.reaches) if (b.reach === reach && !pks.includes(pk)) this.reaches.delete(pk);
|
|
157
|
-
|
|
223
|
+
const claimed = [...new Set(pks)].slice(0, ANNOUNCE);
|
|
224
|
+
const prove = async (pk: string) => {
|
|
158
225
|
if (this.doors.has(pk) || !(await this.prove(pk, reach))) return;
|
|
226
|
+
if (announces.get(reach) !== said) return; // a newer announce has spoken since
|
|
159
227
|
this.reaches.set(pk, { reach, held });
|
|
160
|
-
}
|
|
228
|
+
};
|
|
229
|
+
for (let i = 0; i < claimed.length; i += PROVING) await Promise.all(claimed.slice(i, i + PROVING).map(prove));
|
|
161
230
|
}
|
|
162
231
|
unbind(reach: Reach): void {
|
|
163
232
|
for (const [pk, b] of this.reaches) if (b.reach === reach) this.reaches.delete(pk);
|
|
@@ -175,12 +244,15 @@ export class Harbor {
|
|
|
175
244
|
}
|
|
176
245
|
|
|
177
246
|
// Stop serving a ward and take it out of the store: what was kept comes
|
|
178
|
-
// back, for another harbor to put. The partition is written first,
|
|
179
|
-
//
|
|
247
|
+
// back, for another harbor to put. The partition is written first, and
|
|
248
|
+
// never again under this name: whoever still holds the old pointers holds
|
|
249
|
+
// a ward that saves nothing and answers its owner silence.
|
|
180
250
|
async drop(name: string): Promise<Kept | undefined> {
|
|
181
251
|
const h = this.wards.get(name);
|
|
182
252
|
if (h) {
|
|
183
253
|
await h.save();
|
|
254
|
+
const s = this.#saving.get(name);
|
|
255
|
+
if (s) s.gone = true;
|
|
184
256
|
this.wards.delete(name);
|
|
185
257
|
this.doors.delete(h.pk);
|
|
186
258
|
this.#announce(); // this harbor no longer claims it: a listener unbinds a pk an announce stops naming
|
|
@@ -198,12 +270,13 @@ export class Harbor {
|
|
|
198
270
|
async host(name: string, kept: Kept): Promise<Hosted> {
|
|
199
271
|
const { seed, partition: memory } = kept;
|
|
200
272
|
const classes = await this.loader(kept.record);
|
|
201
|
-
const objects = new
|
|
273
|
+
const objects = new WeakMap<object, BeingLike>(); // cells -> the object instantiate made. a side's hand, never the ward's; it follows the cells out when she is unbooted
|
|
202
274
|
const ground: Ground = {
|
|
203
275
|
seed,
|
|
204
276
|
memory,
|
|
205
277
|
instantiate: (className, stance) => {
|
|
206
|
-
|
|
278
|
+
// Own keys only: `constructor` is a name Object lends every registry.
|
|
279
|
+
const C = Object.hasOwn(classes, className) ? classes[className] : Object.hasOwn(this.classes, className) ? this.classes[className] : undefined;
|
|
207
280
|
if (!C) return null;
|
|
208
281
|
const obj = new C(stance);
|
|
209
282
|
objects.set(stance.cells, obj);
|
|
@@ -211,20 +284,31 @@ export class Harbor {
|
|
|
211
284
|
},
|
|
212
285
|
carry: (pk, bytes) => this.carry(pk, new Uint8Array(bytes)),
|
|
213
286
|
random: (n) => globalThis.crypto.getRandomValues(new Uint8Array(n)),
|
|
287
|
+
wrote: () => this.#wrote(name),
|
|
214
288
|
};
|
|
289
|
+
const saving: Saving = { memory, dirty: false, running: null, timer: undefined, gone: false };
|
|
290
|
+
this.#saving.set(name, saving);
|
|
215
291
|
const w = await Ward(ground);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
292
|
+
// A save the caller may wait for: what the ward wrote so far is in the
|
|
293
|
+
// store when this resolves, or the fault is counted. After a call through
|
|
294
|
+
// a pointer the store is current, so a restart right after it finds
|
|
295
|
+
// everything; a refusal at the door wrote nothing and saves nothing.
|
|
296
|
+
const save = async () => {
|
|
297
|
+
if (saving.gone) return;
|
|
298
|
+
saving.dirty = true;
|
|
299
|
+
await this.#flush(name);
|
|
300
|
+
};
|
|
301
|
+
const door = async (bytes: Uint8Array) => {
|
|
302
|
+
const r = await w.door(bytes);
|
|
303
|
+
if (r.heard) await save();
|
|
304
|
+
return r;
|
|
305
|
+
};
|
|
306
|
+
const ask = async (method?: string, args?: Record<string, unknown>) => {
|
|
307
|
+
if (saving.gone) return silence;
|
|
308
|
+
const out = await w.ask(method, args);
|
|
309
|
+
await save();
|
|
310
|
+
return out;
|
|
311
|
+
};
|
|
228
312
|
const bp = (await w.ask()) as { notes: { pk: string } }; // learned by asking, as anyone learns anything
|
|
229
313
|
const beings = memory.beings as Record<string, object> | undefined;
|
|
230
314
|
const being = (key: string) => {
|
package/src/harbor/dial.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// A harbor as a dialer: one held socket to a listener's
|
|
2
|
+
// A harbor as a dialer: one held socket to a listener's URL, its own
|
|
3
3
|
// pks announced, the listener's bound, and a reconnect with backoff from a
|
|
4
4
|
// second to thirty when the line drops. Seed, partition and keys are here;
|
|
5
5
|
// only the reach comes and goes. While the socket is open it is also the
|
|
@@ -23,11 +23,12 @@ export function dial(harbor: Harbor, url: string): Dialer {
|
|
|
23
23
|
const d: Dialer = { url, socket: null, wake: () => {}, close: () => {} };
|
|
24
24
|
let stopped = false,
|
|
25
25
|
wait = 1000,
|
|
26
|
-
timer: ReturnType<typeof setTimeout> | undefined
|
|
26
|
+
timer: ReturnType<typeof setTimeout> | undefined,
|
|
27
|
+
line: Line | undefined; // the line in hand, open or still opening: close() closes it either way
|
|
27
28
|
const connect = () => {
|
|
28
29
|
timer = undefined; // no wait pending: this is the dial it was waiting for
|
|
29
30
|
if (stopped) return;
|
|
30
|
-
|
|
31
|
+
line = new WebSocket(url.replace(/\/$/, '').replace(/^http/, 'ws')) as unknown as Line;
|
|
31
32
|
const s: Socket = new Socket(
|
|
32
33
|
line,
|
|
33
34
|
(pk, bytes) => harbor.deliver(pk, bytes),
|
|
@@ -59,6 +60,7 @@ export function dial(harbor: Harbor, url: string): Dialer {
|
|
|
59
60
|
// the line already in hand, and a dropped one stops being claimed at once.
|
|
60
61
|
const say = () => s.announce([...harbor.doors.keys()]);
|
|
61
62
|
line.addEventListener('open', () => {
|
|
63
|
+
if (stopped) return s.close(); // closed while it was opening: it opens into nothing
|
|
62
64
|
d.socket = s;
|
|
63
65
|
harbor.announcers.add(say);
|
|
64
66
|
say();
|
|
@@ -76,7 +78,7 @@ export function dial(harbor: Harbor, url: string): Dialer {
|
|
|
76
78
|
d.close = () => {
|
|
77
79
|
stopped = true;
|
|
78
80
|
clearTimeout(timer);
|
|
79
|
-
|
|
81
|
+
line?.close(); // open or opening: a line closed while connecting fires close and never open
|
|
80
82
|
};
|
|
81
83
|
connect();
|
|
82
84
|
return d;
|
package/src/harbor/memory.ts
CHANGED
|
@@ -20,7 +20,7 @@ export class MemoryHarbor {
|
|
|
20
20
|
cut = false;
|
|
21
21
|
readonly partitions = new Map<string, Record<string, unknown>>(); // seed -> memory. the harbor keeps it and reads nothing
|
|
22
22
|
readonly wards = new Map<string, Booted>(); // seed -> pointers
|
|
23
|
-
readonly objects = new
|
|
23
|
+
readonly objects = new WeakMap<object, unknown>(); // cells -> being object. what instantiate constructed. a hand for tests, never the ward's; it follows the cells out when she is unbooted
|
|
24
24
|
|
|
25
25
|
// the directory: its own doors, else a peer it is linked to. Quo says nothing about how.
|
|
26
26
|
async route(farPk: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
|
|
@@ -55,7 +55,8 @@ export class MemoryHarbor {
|
|
|
55
55
|
seed,
|
|
56
56
|
memory,
|
|
57
57
|
instantiate: (name: string, stance: Stance) => {
|
|
58
|
-
|
|
58
|
+
// Own keys only: `constructor` is a name Object lends every registry.
|
|
59
|
+
const C = Object.hasOwn(classes, name) ? classes[name] : undefined;
|
|
59
60
|
if (!C) return null;
|
|
60
61
|
const obj = new C(stance);
|
|
61
62
|
this.objects.set(stance.cells, obj);
|
package/src/harbor/reach.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Reach: carrying bytes to a pk off the device. One interface, two
|
|
3
|
-
// implementations
|
|
3
|
+
// implementations:
|
|
4
4
|
//
|
|
5
|
-
// request a URL
|
|
6
|
-
//
|
|
5
|
+
// request a URL. POST the bytes to `<url>/<pk>`, get bytes back.
|
|
6
|
+
// Listener to listener.
|
|
7
7
|
// socket a held WebSocket at `<url>`, opened by whichever side can
|
|
8
8
|
// dial, used in both directions with a frame id. Each side
|
|
9
9
|
// announces the ward pks it holds when the socket opens, and the
|
|
@@ -38,7 +38,11 @@ export function request(url: string): Reach {
|
|
|
38
38
|
} catch {
|
|
39
39
|
return undefined; // the connection never opened: nothing was delivered
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
// A status that says the listener did not take the bytes is nothing
|
|
42
|
+
// delivered: no reach for that pk, a request it refused on its face, a
|
|
43
|
+
// gateway with nobody behind it. Any other failure is a listener that
|
|
44
|
+
// took them and failed after, which is sent and no word since.
|
|
45
|
+
if (res.status === 404 || (res.status >= 400 && res.status < 500) || res.status === 502 || res.status === 503 || res.status === 504) return undefined;
|
|
42
46
|
if (!res.ok) return never();
|
|
43
47
|
return new Uint8Array(await res.arrayBuffer());
|
|
44
48
|
},
|
|
@@ -75,6 +79,11 @@ export const SUITE = 1;
|
|
|
75
79
|
// the refusal names itself on the way out, and the far side may read it.
|
|
76
80
|
export const REFUSED = 4001;
|
|
77
81
|
const OPEN = 1; // WebSocket.OPEN, on every terrain
|
|
82
|
+
// How many asks a socket keeps a resolver for. An ask the far side never
|
|
83
|
+
// answers is ended by the ward's bound, and its resolver stays until the
|
|
84
|
+
// line closes; a far side that answers nothing for long enough would grow
|
|
85
|
+
// the map for as long as this side asks, so the oldest goes when it is full.
|
|
86
|
+
const PENDING = 4096;
|
|
78
87
|
|
|
79
88
|
// The standard surface, and no more of it: what a browser WebSocket, Node's
|
|
80
89
|
// WebSocket and a `ws` server socket all have.
|
|
@@ -106,7 +115,17 @@ export class Socket implements Reach {
|
|
|
106
115
|
constructor(line: Line, deliver: Carry, onAnnounce: Announce, onClose: (why: { code?: number; reason?: string }) => void) {
|
|
107
116
|
this.line = line;
|
|
108
117
|
line.binaryType = 'arraybuffer';
|
|
109
|
-
|
|
118
|
+
// A line that opens is a reach; one that errors, or closes before it
|
|
119
|
+
// opened, is a reach that failed, and an ask waiting on the handshake is
|
|
120
|
+
// nothing delivered.
|
|
121
|
+
this.#open =
|
|
122
|
+
line.readyState === OPEN
|
|
123
|
+
? Promise.resolve()
|
|
124
|
+
: new Promise((ok, no) => {
|
|
125
|
+
line.addEventListener('open', ok);
|
|
126
|
+
line.addEventListener('error', no);
|
|
127
|
+
line.addEventListener('close', () => no(new Error('closed before it opened')));
|
|
128
|
+
});
|
|
110
129
|
this.#open.catch(() => {}); // a handshake refused is a reach that failed, not a process that dies
|
|
111
130
|
line.addEventListener('message', (e) => {
|
|
112
131
|
if (typeof e.data === 'string') {
|
|
@@ -133,12 +152,19 @@ export class Socket implements Reach {
|
|
|
133
152
|
id = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(1);
|
|
134
153
|
if (kind === ASK) {
|
|
135
154
|
const pk = hex(buf.subarray(5, 69));
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
155
|
+
// A deliver that rejects has delivered nothing this side can vouch
|
|
156
|
+
// for, and a listener does not die of it: the far side hears none.
|
|
157
|
+
void deliver(pk, new Uint8Array(buf.subarray(69)))
|
|
158
|
+
.then(
|
|
159
|
+
(back) => back,
|
|
160
|
+
() => undefined,
|
|
161
|
+
)
|
|
162
|
+
.then((back) => {
|
|
163
|
+
const head = new Uint8Array(5);
|
|
164
|
+
head[0] = back === undefined ? NONE : REPLY;
|
|
165
|
+
new DataView(head.buffer).setUint32(1, id);
|
|
166
|
+
if (line.readyState === OPEN) line.send(back === undefined ? head : concat(head, back));
|
|
167
|
+
});
|
|
142
168
|
return;
|
|
143
169
|
}
|
|
144
170
|
const waiting = this.pending.get(id);
|
|
@@ -171,6 +197,7 @@ export class Socket implements Reach {
|
|
|
171
197
|
new DataView(head.buffer).setUint32(1, id);
|
|
172
198
|
head.set(unhex(pk), 5);
|
|
173
199
|
return new Promise((ok) => {
|
|
200
|
+
if (this.pending.size >= PENDING) this.pending.delete(this.pending.keys().next().value!); // the oldest, which the ward's bound ended long ago
|
|
174
201
|
this.pending.set(id, ok);
|
|
175
202
|
this.line.send(concat(head, bytes));
|
|
176
203
|
});
|
package/src/ward/allowance.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// is held to the ward's ceiling. The door reads it before anything is done
|
|
6
6
|
// under it and refuses a budget already gone, as one silence like every other
|
|
7
7
|
// refusal there. The sender bounds the whole of her ask to the same number,
|
|
8
|
-
// and a wait that ran out is
|
|
8
|
+
// and a wait that ran out is the word late, never silence and never unreached.
|
|
9
9
|
//
|
|
10
10
|
// Each ask is bounded on its own. The time an arriving call has left does not
|
|
11
11
|
// bound the asks a being makes while answering it: attributing her onward ask
|
|
@@ -33,7 +33,13 @@ export const CEILING: Allowance = { time: 300_000 };
|
|
|
33
33
|
// is thirty seconds, silently, because the ceiling is not hers to know.
|
|
34
34
|
// Asking for nothing at all is the default, which is the whole point.
|
|
35
35
|
export function allow(wanted: Wanted | undefined, ceiling: Allowance = CEILING, base: Allowance = DEFAULT): Allowance {
|
|
36
|
-
|
|
36
|
+
// Floored first, then tested: a fraction of a millisecond is not a budget,
|
|
37
|
+
// and falls to the default like every other number that is not a positive
|
|
38
|
+
// whole one.
|
|
39
|
+
const whole = (n: unknown, fallback: number) => {
|
|
40
|
+
const w = typeof n === 'number' ? Math.floor(n) : NaN;
|
|
41
|
+
return Number.isFinite(w) && w > 0 ? w : fallback;
|
|
42
|
+
};
|
|
37
43
|
return { time: Math.min(whole(wanted?.time, base.time), ceiling.time) };
|
|
38
44
|
}
|
|
39
45
|
|
package/src/ward/arithmetic.ts
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
// runs on. `test/floor.test.ts` names the floor and probes for it. Subtle is
|
|
9
9
|
// asynchronous, so everything here is.
|
|
10
10
|
//
|
|
11
|
-
// Ported from an earlier kit's arithmetic. Same bytes, same vectors.
|
|
12
11
|
// `crypto.subtle` is read at every use and never captured at load. A browser
|
|
13
12
|
// on a plain http:// origin has `crypto` without `subtle`, and a terrain may
|
|
14
13
|
// install one after this module is first imported; a reference taken here
|
|
@@ -59,7 +58,12 @@ export function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
|
59
58
|
return diff === 0;
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
// The eight small-order points
|
|
61
|
+
// The eight small-order points of Ed25519, in their canonical encoding. A
|
|
62
|
+
// public key among them verifies nothing. An encoding whose y coordinate is
|
|
63
|
+
// not reduced, y at or above the field's prime p, names one of the same
|
|
64
|
+
// points under another spelling, and a terrain's verify may accept a zero
|
|
65
|
+
// signature under it; the field has room for the spelling, so it is refused
|
|
66
|
+
// before the list is read.
|
|
63
67
|
const SMALL_ORDER = [
|
|
64
68
|
'0100000000000000000000000000000000000000000000000000000000000000',
|
|
65
69
|
'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
|
|
@@ -70,7 +74,11 @@ const SMALL_ORDER = [
|
|
|
70
74
|
'26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85',
|
|
71
75
|
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa',
|
|
72
76
|
].map(unhex);
|
|
73
|
-
|
|
77
|
+
// y >= p, read little-endian with the sign bit masked off: the top byte is
|
|
78
|
+
// 0x7f under the mask, every middle byte is 0xff, and the low byte is at
|
|
79
|
+
// least 0xed, which is p's.
|
|
80
|
+
const unreduced = (pk: Uint8Array): boolean => (pk[31] & 0x7f) === 0x7f && pk[0] >= 0xed && pk.subarray(1, 31).every((b) => b === 0xff);
|
|
81
|
+
export const smallOrder = (pk: Uint8Array): boolean => unreduced(pk) || SMALL_ORDER.some((p) => sameBytes(p, pk));
|
|
74
82
|
|
|
75
83
|
const key32 = (value: Uint8Array, what: string): Uint8Array => {
|
|
76
84
|
if (!(value instanceof Uint8Array) || value.length !== KEY) throw new Error(`${what} is not a 32-byte key`);
|