@serfab/cadre-core 1.6.0 → 1.8.0
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 +8 -6
- package/dist/cadre-node.d.ts +548 -137
- package/dist/cadre-node.js +1208 -383
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +6 -0
- package/dist/control-cohort.js +6 -0
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +46 -8
- package/dist/control-database.js +97 -18
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +37 -14
- package/dist/control-formation-recorder.js +60 -10
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-read-retry.d.ts +13 -12
- package/dist/control-read-retry.js +14 -12
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +67 -5
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +9 -1
- package/dist/control-stream.js +24 -1
- package/dist/control-stream.js.map +1 -1
- package/dist/control-write-retry.d.ts +12 -2
- package/dist/control-write-retry.js +13 -2
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +11 -8
- package/dist/delegate-admission.js +19 -11
- package/dist/delegate-admission.js.map +1 -1
- package/dist/diagnostics/connection-path.d.ts +0 -7
- package/dist/diagnostics/connection-path.js +0 -7
- package/dist/diagnostics/connection-path.js.map +1 -1
- package/dist/formation-approval.d.ts +7 -0
- package/dist/formation-approval.js +9 -3
- package/dist/formation-approval.js.map +1 -1
- package/dist/fs-atomic.d.ts +11 -2
- package/dist/fs-atomic.js +13 -3
- package/dist/fs-atomic.js.map +1 -1
- package/dist/hibernation-manager.d.ts +111 -8
- package/dist/hibernation-manager.js +234 -49
- package/dist/hibernation-manager.js.map +1 -1
- package/dist/index.d.ts +13 -6
- package/dist/index.js +27 -5
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +177 -0
- package/dist/joined-strand-store.js +431 -0
- package/dist/joined-strand-store.js.map +1 -0
- package/dist/key-store-file.js +14 -5
- package/dist/key-store-file.js.map +1 -1
- package/dist/key-store.d.ts +6 -0
- package/dist/key-store.js.map +1 -1
- package/dist/link-budget.d.ts +367 -69
- package/dist/link-budget.js +375 -72
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +30 -43
- package/dist/membership-connection-gater.js +31 -42
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +5 -4
- package/dist/node-local-snapshot.js +6 -3
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-dial.d.ts +24 -4
- package/dist/peer-dial.js +74 -5
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +35 -15
- package/dist/peer-join-backfill.js +115 -55
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/push-fanout.d.ts +6 -0
- package/dist/push-fanout.js +6 -0
- package/dist/push-fanout.js.map +1 -1
- package/dist/relay-reservation.d.ts +3 -1
- package/dist/relay-reservation.js +3 -1
- package/dist/relay-reservation.js.map +1 -1
- package/dist/relay-server.d.ts +79 -0
- package/dist/relay-server.js +74 -0
- package/dist/relay-server.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +57 -4
- package/dist/seed-bootstrap.js +107 -83
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/storage-scope.d.ts +33 -17
- package/dist/storage-scope.js +44 -23
- package/dist/storage-scope.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +58 -21
- package/dist/strand-addr-protocol.js +133 -85
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-database.d.ts +20 -2
- package/dist/strand-database.js +42 -5
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +59 -21
- package/dist/strand-first-sync-gate.js +58 -19
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-deadlines.d.ts +151 -0
- package/dist/strand-formation-deadlines.js +171 -0
- package/dist/strand-formation-deadlines.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +63 -30
- package/dist/strand-formation-manager.js +67 -41
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +45 -35
- package/dist/strand-formation-protocol.js +59 -122
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +152 -6
- package/dist/strand-instance-manager.js +331 -42
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +6 -0
- package/dist/strand-membership-writer.js +11 -0
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-peer-book-file.d.ts +12 -0
- package/dist/strand-peer-book-file.js +37 -0
- package/dist/strand-peer-book-file.js.map +1 -0
- package/dist/strand-peer-book-protocol.d.ts +168 -0
- package/dist/strand-peer-book-protocol.js +356 -0
- package/dist/strand-peer-book-protocol.js.map +1 -0
- package/dist/strand-peer-book-swap.d.ts +104 -0
- package/dist/strand-peer-book-swap.js +284 -0
- package/dist/strand-peer-book-swap.js.map +1 -0
- package/dist/strand-peer-book.d.ts +159 -0
- package/dist/strand-peer-book.js +361 -0
- package/dist/strand-peer-book.js.map +1 -0
- package/dist/strand-peer-observer.d.ts +88 -0
- package/dist/strand-peer-observer.js +180 -0
- package/dist/strand-peer-observer.js.map +1 -0
- package/dist/strand-revocation-enforcer.js +2 -1
- package/dist/strand-revocation-enforcer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +36 -20
- package/dist/strand-solicitation.js +4 -4
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +80 -23
- package/dist/strand-wake-protocol.js +89 -64
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/types.d.ts +181 -33
- package/dist/types.js +4 -3
- package/dist/types.js.map +1 -1
- package/package.json +11 -7
- package/src/cadre-node.ts +1316 -398
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +6 -0
- package/src/control-database.ts +122 -18
- package/src/control-formation-recorder.ts +354 -305
- package/src/control-read-retry.ts +14 -12
- package/src/control-schema.ts +67 -5
- package/src/control-stream.ts +25 -1
- package/src/control-write-retry.ts +13 -2
- package/src/delegate-admission.ts +23 -13
- package/src/diagnostics/connection-path.ts +0 -7
- package/src/formation-approval.ts +610 -604
- package/src/fs-atomic.ts +14 -4
- package/src/hibernation-manager.ts +638 -437
- package/src/index.ts +91 -6
- package/src/joined-strand-store.ts +498 -0
- package/src/key-store-file.ts +14 -5
- package/src/key-store.ts +6 -0
- package/src/link-budget.ts +393 -74
- package/src/membership-connection-gater.ts +31 -45
- package/src/node-local-snapshot.ts +293 -289
- package/src/peer-dial.ts +81 -7
- package/src/peer-join-backfill.ts +120 -57
- package/src/push-fanout.ts +6 -0
- package/src/relay-reservation.ts +3 -1
- package/src/relay-server.ts +98 -0
- package/src/seed-bootstrap.ts +133 -91
- package/src/storage-scope.ts +44 -23
- package/src/strand-addr-protocol.ts +547 -458
- package/src/strand-database.ts +287 -242
- package/src/strand-first-sync-gate.ts +58 -19
- package/src/strand-formation-deadlines.ts +227 -0
- package/src/strand-formation-manager.ts +700 -647
- package/src/strand-formation-protocol.ts +103 -150
- package/src/strand-instance-manager.ts +387 -45
- package/src/strand-membership-writer.ts +12 -0
- package/src/strand-peer-book-file.ts +39 -0
- package/src/strand-peer-book-protocol.ts +466 -0
- package/src/strand-peer-book-swap.ts +338 -0
- package/src/strand-peer-book.ts +475 -0
- package/src/strand-peer-observer.ts +235 -0
- package/src/strand-revocation-enforcer.ts +2 -1
- package/src/strand-solicitation.ts +519 -500
- package/src/strand-wake-protocol.ts +401 -355
- package/src/types.ts +185 -33
package/src/seed-bootstrap.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-c
|
|
|
4
4
|
import type { Libp2p, Connection } from '@libp2p/interface';
|
|
5
5
|
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
|
6
6
|
import { peerIdFromString } from '@libp2p/peer-id';
|
|
7
|
-
import { type ControlStream,
|
|
8
|
-
import { dialPeerAddrs, DEFAULT_PEER_DIAL_BUDGET, type PeerDialBudget } from './peer-dial.js';
|
|
7
|
+
import { type ControlStream, withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
|
|
8
|
+
import { dialPeerAddrs, SelfRelayOnlyError, DEFAULT_PEER_DIAL_BUDGET, type PeerDialBudget } from './peer-dial.js';
|
|
9
|
+
import { relayedRequestBudgetMs } from './link-budget.js';
|
|
9
10
|
import type {
|
|
10
11
|
ControlNetworkSeed,
|
|
11
12
|
SeedPeer,
|
|
@@ -42,15 +43,16 @@ export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
|
|
|
42
43
|
/** Maximum seed message size (1MB) */
|
|
43
44
|
const MAX_SEED_SIZE = 1024 * 1024;
|
|
44
45
|
|
|
45
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Default time the receiver waits for an inbound seed frame before aborting (ms). It covers the
|
|
48
|
+
* read only: the trust decision and the peer-store merge run after it.
|
|
49
|
+
*/
|
|
50
|
+
// eslint-disable-next-line no-restricted-syntax -- link-independent: a receiver cap on one seed frame on a stream the peer already opened; it bounds a peer that opens a stream and never sends, not the dial
|
|
46
51
|
const DEFAULT_SEED_READ_TIMEOUT_MS = 10_000;
|
|
47
52
|
|
|
48
53
|
/** Default cap on concurrent inbound seed streams a single peer can pin open. */
|
|
49
54
|
const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
|
|
50
55
|
|
|
51
|
-
/** Default time the sender waits for a seed delivery (dial + ack read) before aborting (ms). */
|
|
52
|
-
const DEFAULT_SEED_DELIVER_TIMEOUT_MS = 10_000;
|
|
53
|
-
|
|
54
56
|
/**
|
|
55
57
|
* Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
|
|
56
58
|
*
|
|
@@ -122,6 +124,9 @@ function seedRejected(error: string): ApplySeedResult {
|
|
|
122
124
|
return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
/** What dialing a seed's owner peers produced — the owner-dial half of an {@link ApplySeedResult}. */
|
|
128
|
+
type OwnerDialCounts = Pick<ApplySeedResult, 'ownerDialsAttempted' | 'ownerDialsFailed'>;
|
|
129
|
+
|
|
125
130
|
/**
|
|
126
131
|
* Parse each address, dropping (and logging) any that is malformed, so one bad
|
|
127
132
|
* entry cannot keep a peer's valid addresses from being dialed.
|
|
@@ -192,13 +197,28 @@ export interface SeedBootstrapConfig {
|
|
|
192
197
|
* returned without applying any seed.
|
|
193
198
|
*/
|
|
194
199
|
maxConcurrentSeeds?: number;
|
|
200
|
+
/**
|
|
201
|
+
* The host's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
|
|
202
|
+
* {@link seedDeliverTimeoutMs}'s default is derived. Unset means the declared default.
|
|
203
|
+
*/
|
|
204
|
+
linkRoundTripMs?: number;
|
|
195
205
|
/**
|
|
196
206
|
* Time {@link SeedBootstrapService.deliverSeed} waits for the whole exchange —
|
|
197
|
-
* dial, write, ack read — before aborting (ms).
|
|
198
|
-
* {@link DEFAULT_SEED_DELIVER_TIMEOUT_MS}. Bounds the SENDER against a seed
|
|
207
|
+
* dial, write, ack read — before aborting (ms). Bounds the SENDER against a seed
|
|
199
208
|
* target that accepts the stream and then never replies; the target is a
|
|
200
209
|
* not-yet-trusted node during onboarding, so this is the more exposed
|
|
201
210
|
* direction than the receiver knobs above.
|
|
211
|
+
*
|
|
212
|
+
* Defaults to `relayedRequestBudgetMs(linkRoundTripMs)` (`link-budget.ts`; 23 s at the
|
|
213
|
+
* default declaration): one dial that may need a relay, then one request and its answer.
|
|
214
|
+
* Delivery does not set `runOnLimitedConnection`, so it does not use a limited relayed
|
|
215
|
+
* connection today; the relayed-dial count is the upper bound on the dial it can use, the
|
|
216
|
+
* same choice `CadreNode.controlDialBudget` makes for every address. It holds only link work
|
|
217
|
+
* because the receiver acks before its owner dials.
|
|
218
|
+
*
|
|
219
|
+
* NOTE: no transfer allowance — a seed is a peer list of a few KB, and `MAX_SEED_SIZE` (1 MiB)
|
|
220
|
+
* is a defensive cap. If seeds ever grow toward that cap, add an allowance the way
|
|
221
|
+
* `PUSH_TRANSFER_ALLOWANCE_MS` does.
|
|
202
222
|
*/
|
|
203
223
|
seedDeliverTimeoutMs?: number;
|
|
204
224
|
/**
|
|
@@ -256,7 +276,7 @@ export class SeedBootstrapService {
|
|
|
256
276
|
this.trustPolicy = config.trustPolicy ?? anchoredTrustPolicy();
|
|
257
277
|
this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
|
|
258
278
|
this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
|
|
259
|
-
this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ??
|
|
279
|
+
this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? relayedRequestBudgetMs(config.linkRoundTripMs);
|
|
260
280
|
this.dialBudget = config.dialBudget ?? DEFAULT_PEER_DIAL_BUDGET;
|
|
261
281
|
|
|
262
282
|
// Derive public key from private key if not provided
|
|
@@ -305,20 +325,23 @@ export class SeedBootstrapService {
|
|
|
305
325
|
* `controlDatabase` for dialing and known-key lookup, and must NOT bind a
|
|
306
326
|
* discarded closure to the shared node (a handler leak, and a second
|
|
307
327
|
* `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
|
|
328
|
+
*
|
|
329
|
+
* Rejects when libp2p refuses the registration; the node and database are
|
|
330
|
+
* kept only once the handler is in place, so a failed service holds nothing
|
|
331
|
+
* for {@link shutdown} to unhandle.
|
|
308
332
|
*/
|
|
309
|
-
initialize(
|
|
333
|
+
async initialize(
|
|
310
334
|
libp2pNode: Libp2p,
|
|
311
335
|
controlDatabase: ControlDatabase,
|
|
312
336
|
options?: { registerHandler?: boolean }
|
|
313
|
-
): void {
|
|
314
|
-
this.libp2pNode = libp2pNode;
|
|
315
|
-
this.controlDatabase = controlDatabase;
|
|
316
|
-
|
|
337
|
+
): Promise<void> {
|
|
317
338
|
// Register the seed protocol handler unless the caller opted out (temp services).
|
|
318
339
|
if (options?.registerHandler ?? true) {
|
|
319
|
-
this.registerProtocolHandler();
|
|
340
|
+
await this.registerProtocolHandler(libp2pNode);
|
|
320
341
|
}
|
|
321
342
|
|
|
343
|
+
this.libp2pNode = libp2pNode;
|
|
344
|
+
this.controlDatabase = controlDatabase;
|
|
322
345
|
log('SeedBootstrapService initialized');
|
|
323
346
|
}
|
|
324
347
|
|
|
@@ -738,6 +761,22 @@ export class SeedBootstrapService {
|
|
|
738
761
|
async applySeed(
|
|
739
762
|
seed: ControlNetworkSeed,
|
|
740
763
|
options?: { trustPolicy?: SeedTrustPolicy }
|
|
764
|
+
): Promise<ApplySeedResult> {
|
|
765
|
+
const merged = await this.verifyAndMergeSeed(seed, options);
|
|
766
|
+
if (!merged.success) {
|
|
767
|
+
return merged;
|
|
768
|
+
}
|
|
769
|
+
return { ...merged, ...await this.dialSeedOwners(seed) };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* The first half of {@link applySeed}: check the signature and the signer's trust, then merge
|
|
774
|
+
* the seed's peer addresses into the peer store. Every rejection happens here, so this result is
|
|
775
|
+
* what the inbound handler acks with; the owner dials that follow cannot change it.
|
|
776
|
+
*/
|
|
777
|
+
private async verifyAndMergeSeed(
|
|
778
|
+
seed: ControlNetworkSeed,
|
|
779
|
+
options?: { trustPolicy?: SeedTrustPolicy }
|
|
741
780
|
): Promise<ApplySeedResult> {
|
|
742
781
|
if (!this.libp2pNode) {
|
|
743
782
|
return seedRejected('Service not initialized');
|
|
@@ -796,12 +835,28 @@ export class SeedBootstrapService {
|
|
|
796
835
|
}
|
|
797
836
|
}
|
|
798
837
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
838
|
+
log('Merged seed: %d peers added', peersAdded);
|
|
839
|
+
return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The second half of {@link applySeed}: dial the seed's owner peers to establish connections.
|
|
844
|
+
*
|
|
845
|
+
* Best-effort and COUNTED: an owner that is momentarily down leaves this node seeded but
|
|
846
|
+
* unconnected, which the caller can only see if the outcome is reported (see
|
|
847
|
+
* `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
|
|
848
|
+
* `CadreNode.dialColdStartBootstrap` retries these same addresses on every control-cohort
|
|
849
|
+
* reconcile pass until the control database has siblings.
|
|
850
|
+
*
|
|
851
|
+
* The inbound handler runs this AFTER it has acked and closed the stream
|
|
852
|
+
* ({@link handleSeedStream}), because an unreachable owner can take `dialBudget.totalMs`.
|
|
853
|
+
*/
|
|
854
|
+
private async dialSeedOwners(seed: ControlNetworkSeed): Promise<OwnerDialCounts> {
|
|
855
|
+
const node = this.libp2pNode;
|
|
856
|
+
if (!node) {
|
|
857
|
+
log('Seed service shut down before its owner dials; none attempted');
|
|
858
|
+
return { ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
859
|
+
}
|
|
805
860
|
// `createSeed` projects every non-revoked CadrePeer row, so an owner applying
|
|
806
861
|
// a seed minted after it joined finds ITSELF in the owner list. Dialing self always
|
|
807
862
|
// throws, which would report a healthy owner as "seeded but stranded".
|
|
@@ -810,12 +865,7 @@ export class SeedBootstrapService {
|
|
|
810
865
|
// Every one of an owner's addresses is a candidate, each on its own time limit
|
|
811
866
|
// (`dialPeerAddrs`), so an owner whose first address never answers neither
|
|
812
867
|
// stalls seed application nor goes undialed at its other addresses.
|
|
813
|
-
|
|
814
|
-
// can hold the ack for up to `dialBudget.totalMs` each — 56 s at the default declared link
|
|
815
|
-
// round trip, more on a host that declared a slower one (`link-budget.ts`) — past a sender's
|
|
816
|
-
// 10 s `seedDeliverTimeoutMs`; the seed is still applied. If senders start
|
|
817
|
-
// reporting accepted seeds as timed out, ack before dialing.
|
|
818
|
-
const selfPeerId = this.libp2pNode.peerId?.toString();
|
|
868
|
+
const selfPeerId = node.peerId?.toString();
|
|
819
869
|
let ownerDialsAttempted = 0;
|
|
820
870
|
let ownerDialsFailed = 0;
|
|
821
871
|
for (const peer of seed.peers.filter(p => p.isOwner)) {
|
|
@@ -827,17 +877,22 @@ export class SeedBootstrapService {
|
|
|
827
877
|
const addrs = parseDialAddrs(peer.multiaddrs);
|
|
828
878
|
|
|
829
879
|
log('Dialing owner peer: %s (%d addr(s))', peer.peerId, addrs.length);
|
|
830
|
-
await dialPeerAddrs(
|
|
880
|
+
await dialPeerAddrs(node, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
|
|
831
881
|
} catch (error) {
|
|
882
|
+
// Counted even when the owner reaches us only through our own relay: this node is still
|
|
883
|
+
// not connected to it, and `ownerDialsFailed` is how the caller learns that.
|
|
832
884
|
ownerDialsFailed++;
|
|
833
|
-
|
|
885
|
+
if (error instanceof SelfRelayOnlyError) {
|
|
886
|
+
log('Owner peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
|
|
887
|
+
} else {
|
|
888
|
+
log('Failed to dial peer %s: %o', peer.peerId, error);
|
|
889
|
+
}
|
|
834
890
|
// Continue - not all peers need to be reachable
|
|
835
891
|
}
|
|
836
892
|
}
|
|
837
893
|
|
|
838
|
-
log('
|
|
839
|
-
|
|
840
|
-
return { success: true, peersAdded, ownerDialsAttempted, ownerDialsFailed };
|
|
894
|
+
log('Dialed seed owners: %d/%d owner dial(s) failed', ownerDialsFailed, ownerDialsAttempted);
|
|
895
|
+
return { ownerDialsAttempted, ownerDialsFailed };
|
|
841
896
|
}
|
|
842
897
|
|
|
843
898
|
/**
|
|
@@ -1069,10 +1124,8 @@ export class SeedBootstrapService {
|
|
|
1069
1124
|
* {@link handleSeedStream} — extracted as a method so it has a unit-test seam
|
|
1070
1125
|
* (mirroring wake's `handleStream`) the inline closure never had.
|
|
1071
1126
|
*/
|
|
1072
|
-
private registerProtocolHandler(): void {
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
void this.libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
|
|
1127
|
+
private async registerProtocolHandler(libp2pNode: Libp2p): Promise<void> {
|
|
1128
|
+
await libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
|
|
1076
1129
|
const remotePeerId = (rawConnection as Connection).remotePeer.toString();
|
|
1077
1130
|
await this.handleSeedStream(rawStream as ControlStream, remotePeerId);
|
|
1078
1131
|
});
|
|
@@ -1081,92 +1134,81 @@ export class SeedBootstrapService {
|
|
|
1081
1134
|
}
|
|
1082
1135
|
|
|
1083
1136
|
/**
|
|
1084
|
-
* Read one inbound seed frame,
|
|
1137
|
+
* Read one inbound seed frame, verify and merge it, write the ack and close the
|
|
1138
|
+
* stream, then dial the seed's owners.
|
|
1139
|
+
*
|
|
1140
|
+
* The ack and the close come BEFORE the owner dials: an unreachable owner can hold
|
|
1141
|
+
* a dial for `dialBudget.totalMs` (64 s at the default declared link), which is not
|
|
1142
|
+
* link time and does not belong inside the sender's delivery deadline. The close is
|
|
1143
|
+
* what releases the sender, which reads the ack to end-of-stream. The stream stays
|
|
1144
|
+
* counted in {@link activeStreams} through the dials, so {@link maxConcurrentSeeds}
|
|
1145
|
+
* still bounds concurrent owner-dial phases.
|
|
1085
1146
|
*
|
|
1086
1147
|
* Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
|
|
1087
1148
|
* {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
|
|
1088
1149
|
* that never half-closes is aborted inside `readStreamToEnd`), and the existing
|
|
1089
1150
|
* malformed/oversized-frame guard — all reported as a non-accepting
|
|
1090
1151
|
* {@link SeedAckMessage} rather than a dropped/hung stream.
|
|
1152
|
+
*
|
|
1153
|
+
* NOTE: a receiver configured with an interactive trust-on-first-use policy asks a
|
|
1154
|
+
* human before acking, and that wait sits inside the sender's delivery deadline. If
|
|
1155
|
+
* TOFU is ever used on this wire path, ack "pending" or move the confirmation out of
|
|
1156
|
+
* the exchange.
|
|
1091
1157
|
*/
|
|
1092
1158
|
private async handleSeedStream(stream: ControlStream, remotePeerId: string): Promise<void> {
|
|
1093
1159
|
log('Incoming seed delivery from: %s', remotePeerId);
|
|
1094
1160
|
|
|
1095
1161
|
if (this.activeStreams >= this.maxConcurrentSeeds) {
|
|
1096
1162
|
log('Rejecting seed from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrentSeeds);
|
|
1097
|
-
|
|
1098
|
-
try {
|
|
1099
|
-
writeFrame(stream, ack);
|
|
1100
|
-
} catch {
|
|
1101
|
-
// Ignore send errors on the reject path.
|
|
1102
|
-
}
|
|
1103
|
-
try {
|
|
1104
|
-
await stream.close();
|
|
1105
|
-
} catch {
|
|
1106
|
-
// Ignore close errors.
|
|
1107
|
-
}
|
|
1163
|
+
await replyAndClose(stream, { accepted: false, reason: 'Too many concurrent seed deliveries' } satisfies SeedAckMessage, 'Seed');
|
|
1108
1164
|
return;
|
|
1109
1165
|
}
|
|
1110
1166
|
|
|
1111
1167
|
this.activeStreams++;
|
|
1168
|
+
let acked = false;
|
|
1112
1169
|
try {
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
maxBytes: MAX_SEED_SIZE,
|
|
1116
|
-
timeoutMs: this.seedReadTimeoutMs,
|
|
1117
|
-
label: 'Seed',
|
|
1118
|
-
});
|
|
1119
|
-
|
|
1120
|
-
const messageBody = decodeLengthPrefixedFrame(data);
|
|
1121
|
-
const messageJson = new TextDecoder().decode(messageBody);
|
|
1122
|
-
const message = JSON.parse(messageJson) as SeedMessage;
|
|
1123
|
-
|
|
1124
|
-
// Emit seed received event
|
|
1125
|
-
this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
|
|
1126
|
-
|
|
1127
|
-
// Convert to seed and apply
|
|
1128
|
-
const seed: ControlNetworkSeed = {
|
|
1129
|
-
partyId: message.partyId,
|
|
1130
|
-
peers: message.peers,
|
|
1131
|
-
signature: message.signature,
|
|
1132
|
-
signerKey: message.signerKey,
|
|
1133
|
-
};
|
|
1170
|
+
const seed = await this.readSeedFrame(stream);
|
|
1171
|
+
this.eventCallbacks.onSeedReceived?.(seed.partyId, remotePeerId);
|
|
1134
1172
|
|
|
1135
|
-
const
|
|
1173
|
+
const merged = await this.verifyAndMergeSeed(seed);
|
|
1174
|
+
acked = true;
|
|
1175
|
+
await replyAndClose(stream, { accepted: merged.success, reason: merged.error } satisfies SeedAckMessage, 'Seed');
|
|
1136
1176
|
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
this.eventCallbacks.onSeedApplied?.(seed.partyId,
|
|
1177
|
+
if (merged.success) {
|
|
1178
|
+
await this.dialSeedOwners(seed);
|
|
1179
|
+
this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
|
|
1140
1180
|
} else {
|
|
1141
|
-
this.eventCallbacks.onSeedError?.(seed.partyId,
|
|
1181
|
+
this.eventCallbacks.onSeedError?.(seed.partyId, merged.error ?? 'Unknown error');
|
|
1142
1182
|
}
|
|
1143
|
-
|
|
1144
|
-
const ack: SeedAckMessage = { accepted: result.success, reason: result.error };
|
|
1145
|
-
writeFrame(stream, ack);
|
|
1146
1183
|
} catch (error) {
|
|
1147
1184
|
log('Error handling seed delivery: %o', error);
|
|
1148
1185
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1149
|
-
|
|
1150
|
-
// Emit error event
|
|
1151
1186
|
this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
try {
|
|
1156
|
-
writeFrame(stream, ack);
|
|
1157
|
-
} catch {
|
|
1158
|
-
// Ignore send errors
|
|
1187
|
+
// Once acked the stream is closed, so a later failure has no reply to make.
|
|
1188
|
+
if (!acked) {
|
|
1189
|
+
await replyAndClose(stream, { accepted: false, reason: errorMessage } satisfies SeedAckMessage, 'Seed');
|
|
1159
1190
|
}
|
|
1160
1191
|
} finally {
|
|
1161
1192
|
this.activeStreams--;
|
|
1162
|
-
try {
|
|
1163
|
-
await stream.close();
|
|
1164
|
-
} catch {
|
|
1165
|
-
// Ignore close errors.
|
|
1166
|
-
}
|
|
1167
1193
|
}
|
|
1168
1194
|
}
|
|
1169
1195
|
|
|
1196
|
+
/** Read the inbound seed frame to EOF (bounded and size-capped) and decode it. */
|
|
1197
|
+
private async readSeedFrame(stream: ControlStream): Promise<ControlNetworkSeed> {
|
|
1198
|
+
const data = await readStreamToEnd(stream, {
|
|
1199
|
+
maxBytes: MAX_SEED_SIZE,
|
|
1200
|
+
timeoutMs: this.seedReadTimeoutMs,
|
|
1201
|
+
label: 'Seed',
|
|
1202
|
+
});
|
|
1203
|
+
const message = JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data))) as SeedMessage;
|
|
1204
|
+
return {
|
|
1205
|
+
partyId: message.partyId,
|
|
1206
|
+
peers: message.peers,
|
|
1207
|
+
signature: message.signature,
|
|
1208
|
+
signerKey: message.signerKey,
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1170
1212
|
/**
|
|
1171
1213
|
* Shutdown the service.
|
|
1172
1214
|
*/
|
package/src/storage-scope.ts
CHANGED
|
@@ -18,18 +18,26 @@
|
|
|
18
18
|
* its shape, and the React Native reference app lets a user type one into Settings.
|
|
19
19
|
* Scope keys reach real namespaces unescaped: cadre-cli builds `${config.path}/${scope}`,
|
|
20
20
|
* React Native a LevelDB filename, the browser an IndexedDB database name. A party id
|
|
21
|
-
* containing `/` or `..` would escape the CLI's storage directory.
|
|
22
|
-
* key inside `[
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* containing `/` or `..` would escape the CLI's storage directory. Lowercase hex keeps
|
|
22
|
+
* every key inside `[a-z0-9._-]`, the charset embedders rely on.
|
|
23
|
+
*
|
|
24
|
+
* WHY THE CHARSET HAS NO UPPERCASE. Windows (NTFS) and macOS (APFS/HFS+ by default)
|
|
25
|
+
* compare file names without regard to case, while everything above the filesystem
|
|
26
|
+
* compares with it. Two keys differing only in case — `control-YWFA` and `control-YWFa`,
|
|
27
|
+
* the base64url keys of party ids `aa@` and `aaZ`, or strand ids `strand-ABC` and
|
|
28
|
+
* `strand-abc` — would be two stores in memory and one folder on disk. With no uppercase
|
|
29
|
+
* letter in the charset, two different keys are two different names on every
|
|
30
|
+
* filesystem.
|
|
25
31
|
*
|
|
26
32
|
* THE STRAND ARM OF THAT CHARSET RULE IS ENFORCED HERE. A strand's scope key is
|
|
27
33
|
* `StrandRow.Id`, and a strand row replicated into the control database by another
|
|
28
34
|
* node in the party carries whatever id THAT node wrote. `assertStrandScopeKey`
|
|
29
35
|
* (below) is the check that makes the charset true of strand keys too; it runs
|
|
30
36
|
* unconditionally at the top of `StrandInstanceManager.startStrand`, so no strand id
|
|
31
|
-
* reaches an embedder's provider — or a libp2p protocol prefix — unvalidated.
|
|
32
|
-
*
|
|
37
|
+
* reaches an embedder's provider — or a libp2p protocol prefix — unvalidated. A
|
|
38
|
+
* mixed-case id is refused, not lowercased: lowercasing would silently merge two
|
|
39
|
+
* distinct strands into one store. Ids cadre-core mints come from `strand-id.ts`, which
|
|
40
|
+
* is built on the same predicate.
|
|
33
41
|
*/
|
|
34
42
|
|
|
35
43
|
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
@@ -45,23 +53,29 @@ const CONTROL_SCOPE_PREFIX = 'control-';
|
|
|
45
53
|
/**
|
|
46
54
|
* The storage scope key for a party's control database.
|
|
47
55
|
*
|
|
48
|
-
* Returns `control-` followed by the
|
|
49
|
-
* so the whole key stays within `[
|
|
50
|
-
* file name, directory name or database name
|
|
51
|
-
*
|
|
56
|
+
* Returns `control-` followed by the lowercase hexadecimal encoding of `partyId`'s
|
|
57
|
+
* UTF-8 bytes, so the whole key stays within `[a-z0-9._-]` and is safe to use directly
|
|
58
|
+
* as a file name, directory name or database name — and two party ids give two names
|
|
59
|
+
* even on a filesystem that ignores case. See the module comment for why the encoding
|
|
60
|
+
* is load-bearing rather than decorative.
|
|
52
61
|
*
|
|
53
62
|
* To read a party id back off a device — from, say, a LevelDB file named
|
|
54
|
-
* `sereus-control-
|
|
55
|
-
*
|
|
63
|
+
* `sereus-control-7061727479` — in a browser or Node console, with no dependency on
|
|
64
|
+
* this package:
|
|
56
65
|
* ```js
|
|
57
|
-
* const
|
|
58
|
-
* new TextDecoder().decode(Uint8Array.from(
|
|
66
|
+
* const hex = key.slice(key.indexOf('control-') + 'control-'.length);
|
|
67
|
+
* new TextDecoder().decode(Uint8Array.from(hex.match(/../g) ?? [], (h) => parseInt(h, 16)))
|
|
59
68
|
* ```
|
|
60
|
-
* The `TextDecoder` step is not optional:
|
|
61
|
-
*
|
|
69
|
+
* The `TextDecoder` step is not optional: each hex pair is one BYTE, so a party id with
|
|
70
|
+
* any non-ASCII character in it decodes to mojibake without it.
|
|
71
|
+
*
|
|
72
|
+
* NOTE: the key is twice the party id's UTF-8 byte length plus 8, so a party id over
|
|
73
|
+
* ~120 bytes overflows the 255-byte limit filesystems put on one path component. Party
|
|
74
|
+
* ids today are UUIDs (an 80-character key); if long party ids ever appear, hash the
|
|
75
|
+
* party id rather than encoding it.
|
|
62
76
|
*/
|
|
63
77
|
export function controlStorageScope(partyId: string): string {
|
|
64
|
-
return CONTROL_SCOPE_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), '
|
|
78
|
+
return CONTROL_SCOPE_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base16');
|
|
65
79
|
}
|
|
66
80
|
|
|
67
81
|
/**
|
|
@@ -86,8 +100,11 @@ export function isControlStorageScope(scope: string): boolean {
|
|
|
86
100
|
*/
|
|
87
101
|
const MAX_STRAND_SCOPE_KEY_LENGTH = 128;
|
|
88
102
|
|
|
89
|
-
/**
|
|
90
|
-
|
|
103
|
+
/**
|
|
104
|
+
* The charset every scope key stays within — see the module comment. Lowercase only,
|
|
105
|
+
* so keys that differ as strings also differ as names on a filesystem that ignores case.
|
|
106
|
+
*/
|
|
107
|
+
const SCOPE_KEY_CHARSET = /^[a-z0-9._-]+$/;
|
|
91
108
|
|
|
92
109
|
/**
|
|
93
110
|
* Whether a strand id is usable as a storage scope key and as a libp2p network name.
|
|
@@ -100,7 +117,9 @@ const SCOPE_KEY_CHARSET = /^[A-Za-z0-9._-]+$/;
|
|
|
100
117
|
*
|
|
101
118
|
* Valid means all of:
|
|
102
119
|
* - non-empty, and at most {@link MAX_STRAND_SCOPE_KEY_LENGTH} characters;
|
|
103
|
-
* - within `[
|
|
120
|
+
* - within `[a-z0-9._-]`, so no separator, drive letter, NUL or non-ASCII text, and no
|
|
121
|
+
* uppercase letter, so no two valid ids name one folder on a filesystem that ignores
|
|
122
|
+
* case;
|
|
104
123
|
* - not `.` or `..`, which the charset admits but every filesystem reads as a
|
|
105
124
|
* directory rather than a name;
|
|
106
125
|
* - not `control-`-prefixed, which would let a strand's store masquerade as a
|
|
@@ -128,7 +147,8 @@ export class InvalidStrandIdError extends Error {
|
|
|
128
147
|
constructor(strandId: string) {
|
|
129
148
|
super(
|
|
130
149
|
`Strand id ${JSON.stringify(strandId)} cannot be used as a storage scope key: ` +
|
|
131
|
-
`a strand id must be 1-${MAX_STRAND_SCOPE_KEY_LENGTH} characters within [
|
|
150
|
+
`a strand id must be 1-${MAX_STRAND_SCOPE_KEY_LENGTH} characters within [a-z0-9._-] ` +
|
|
151
|
+
'(lowercase only, because Windows and macOS file names ignore case), ' +
|
|
132
152
|
"must not be '.' or '..', and must not begin 'control-'. " +
|
|
133
153
|
'This strand was created by a node that does not mint conforming ids; it cannot be ' +
|
|
134
154
|
'started here, because the id becomes a file, directory or database name.'
|
|
@@ -156,8 +176,9 @@ export function assertStrandScopeKey(strandId: string): void {
|
|
|
156
176
|
export function assertScopeKeyCharset(scope: string): void {
|
|
157
177
|
if (!SCOPE_KEY_CHARSET.test(scope)) {
|
|
158
178
|
throw new Error(
|
|
159
|
-
`Storage scope key ${JSON.stringify(scope)} leaves the [
|
|
160
|
-
'embedders rely on to use it directly as a file, directory or database name
|
|
179
|
+
`Storage scope key ${JSON.stringify(scope)} leaves the [a-z0-9._-] charset ` +
|
|
180
|
+
'embedders rely on to use it directly as a file, directory or database name ' +
|
|
181
|
+
'(lowercase only, so two keys never share one name on a filesystem that ignores case).'
|
|
161
182
|
);
|
|
162
183
|
}
|
|
163
184
|
}
|