@serfab/cadre-core 0.12.0 → 0.13.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 +3 -2
- package/dist/cadre-node.d.ts +328 -25
- package/dist/cadre-node.js +760 -59
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +19 -0
- package/dist/control-database.js +51 -9
- package/dist/control-database.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +30 -2
- package/dist/control-schema.js.map +1 -1
- package/dist/enrolled-machine-store-file.d.ts +13 -0
- package/dist/enrolled-machine-store-file.js +41 -0
- package/dist/enrolled-machine-store-file.js.map +1 -0
- package/dist/enrolled-machine-store.d.ts +81 -0
- package/dist/enrolled-machine-store.js +225 -0
- package/dist/enrolled-machine-store.js.map +1 -0
- package/dist/file-durable-slot.js +5 -3
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +23 -5
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +15 -1
- package/dist/membership-connection-gater.js +9 -3
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +6 -0
- package/dist/node-local-snapshot.js +7 -1
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
- package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
- package/dist/peer-join-backfill.js.map +1 -0
- package/dist/relay-addrs.d.ts +73 -0
- package/dist/relay-addrs.js +195 -0
- package/dist/relay-addrs.js.map +1 -1
- package/dist/strand-database.d.ts +12 -0
- package/dist/strand-database.js +16 -0
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +12 -0
- package/dist/strand-formation-manager.js +10 -3
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +76 -2
- package/dist/strand-formation-protocol.js +78 -2
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +147 -14
- package/dist/strand-instance-manager.js +225 -45
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +12 -0
- package/dist/strand-membership-writer.js +23 -1
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +84 -0
- package/dist/strand-network-config.js +136 -0
- package/dist/strand-network-config.js.map +1 -0
- package/dist/strand-revocation-enforcer.d.ts +392 -0
- package/dist/strand-revocation-enforcer.js +533 -0
- package/dist/strand-revocation-enforcer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +7 -0
- package/dist/strand-solicitation.js +8 -2
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-watcher.js +6 -1
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +210 -16
- package/dist/types.js +7 -1
- package/dist/types.js.map +1 -1
- package/package.json +16 -8
- package/src/cadre-node.ts +6353 -5603
- package/src/control-database.ts +53 -9
- package/src/control-schema.ts +30 -2
- package/src/enrolled-machine-store-file.ts +43 -0
- package/src/enrolled-machine-store.ts +280 -0
- package/src/file-durable-slot.ts +5 -3
- package/src/index.ts +52 -10
- package/src/membership-connection-gater.ts +9 -3
- package/src/node-local-snapshot.ts +13 -1
- package/src/peer-addr-book.ts +251 -250
- package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
- package/src/relay-addrs.ts +218 -0
- package/src/strand-database.ts +17 -0
- package/src/strand-formation-manager.ts +22 -3
- package/src/strand-formation-protocol.ts +808 -692
- package/src/strand-instance-manager.ts +317 -54
- package/src/strand-membership-writer.ts +23 -1
- package/src/strand-network-config.ts +172 -0
- package/src/strand-revocation-enforcer.ts +653 -0
- package/src/strand-solicitation.ts +484 -471
- package/src/strand-watcher.ts +319 -314
- package/src/types.ts +1554 -1354
- package/dist/strand-backfill.js.map +0 -1
package/dist/cadre-node.js
CHANGED
|
@@ -4,7 +4,7 @@ import { peerIdFromString, peerIdFromPrivateKey } from '@libp2p/peer-id';
|
|
|
4
4
|
import { createLibp2pNode } from '@optimystic/db-p2p';
|
|
5
5
|
import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
|
|
6
6
|
import { multiaddr } from '@multiformats/multiaddr';
|
|
7
|
-
import {
|
|
7
|
+
import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS } from './types.js';
|
|
8
8
|
import { sign } from '@optimystic/quereus-plugin-crypto';
|
|
9
9
|
import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
10
10
|
import { strandTransportKey } from './strand-transport-key.js';
|
|
@@ -12,6 +12,7 @@ import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
|
|
|
12
12
|
import { loadOrCreateIdentityKey } from './identity-key.js';
|
|
13
13
|
import { MemoryTrustedOwnerStore } from './trusted-owner-store.js';
|
|
14
14
|
import { MemoryBootstrapPeerStore } from './bootstrap-peer-store.js';
|
|
15
|
+
import { MemoryEnrolledMachineStore } from './enrolled-machine-store.js';
|
|
15
16
|
import { mergePeerAddrs, groupAddrsByPeerId } from './peer-addr-book.js';
|
|
16
17
|
import { verifyCadrePeerVoucher } from './peer-authorization.js';
|
|
17
18
|
import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
|
|
@@ -19,19 +20,21 @@ import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSign
|
|
|
19
20
|
import { signDeviceTokenRecord, verifyDeviceTokenSignature, isPushPlatform } from './device-token.js';
|
|
20
21
|
import { StrandWatcher } from './strand-watcher.js';
|
|
21
22
|
import { StrandInstanceManager } from './strand-instance-manager.js';
|
|
23
|
+
import { PeerJoinBackfill } from './peer-join-backfill.js';
|
|
22
24
|
import { deriveCohortMembers } from './strand-cohort.js';
|
|
23
25
|
import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
|
|
24
26
|
import { withDeadline } from './control-stream.js';
|
|
25
27
|
import { EnrollmentService } from './enrollment.js';
|
|
26
28
|
import { HibernationManager } from './hibernation-manager.js';
|
|
27
|
-
import { ControlDatabase } from './control-database.js';
|
|
29
|
+
import { ControlDatabase, isStrandIdConflict } from './control-database.js';
|
|
28
30
|
import { SeedBootstrapService } from './seed-bootstrap.js';
|
|
29
31
|
import { StrandSolicitationService } from './strand-solicitation.js';
|
|
32
|
+
import { MAX_STRAND_ADDRS } from './strand-formation-protocol.js';
|
|
30
33
|
import { createMembershipConnectionGater, DEFAULT_ENROLLMENT_WINDOW_MS, UnauthorizedReservationBudget } from './membership-connection-gater.js';
|
|
31
34
|
import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
|
|
32
35
|
import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
|
|
33
36
|
import { DelegateAdmissionStore, extractCircuitRelayTargets, dueRelayAnnounces, pruneStoppedStrandAnnounces, peerStrandKey } from './delegate-admission.js';
|
|
34
|
-
import { relayCircuitAddrs, resolveListenAddrs, RelayReservationFailedError } from './relay-addrs.js';
|
|
37
|
+
import { relayCircuitAddrs, resolveListenAddrs, resolveTransportOptions, RelayReservationFailedError } from './relay-addrs.js';
|
|
35
38
|
import { replacesAdvertisedAddrs, resolveAnnounceAddrs } from './announce-addrs.js';
|
|
36
39
|
import { superviseRelayReservation, resolveRelayReservationState } from './relay-reservation.js';
|
|
37
40
|
import { PushFanoutService } from './push-fanout.js';
|
|
@@ -64,6 +67,30 @@ export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
|
|
|
64
67
|
function relayStrandAddrPeer(relay) {
|
|
65
68
|
return { peerId: relay.relayPeerId, addrs: [multiaddr(relay.relayAddr)] };
|
|
66
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* `primary` followed by the entries of `extra` it does not already contain, de-duplicated
|
|
72
|
+
* and order-preserving.
|
|
73
|
+
*
|
|
74
|
+
* Used wherever a strand's discovery seed unions two sources of address strings — the
|
|
75
|
+
* freshly-RPC'd sibling answers and the cross-party addresses learned at formation. The
|
|
76
|
+
* caller passes the FRESHER source as `primary`, so a stale entry can only ever be
|
|
77
|
+
* appended, never promoted ahead of a current one, and each source keeps whatever
|
|
78
|
+
* signaling-first ordering it arrived with.
|
|
79
|
+
*/
|
|
80
|
+
function unionAddrs(primary, extra) {
|
|
81
|
+
if (extra.length === 0) {
|
|
82
|
+
return [...primary];
|
|
83
|
+
}
|
|
84
|
+
const out = [...primary];
|
|
85
|
+
const seen = new Set(primary);
|
|
86
|
+
for (const addr of extra) {
|
|
87
|
+
if (seen.has(addr))
|
|
88
|
+
continue;
|
|
89
|
+
seen.add(addr);
|
|
90
|
+
out.push(addr);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
67
94
|
/**
|
|
68
95
|
* Build the signing callback the `ControlDatabase` writers expect: they hand it the
|
|
69
96
|
* canonical row-bound message BYTES (see `buildAuthorizationMessage`), and it ed25519-signs
|
|
@@ -84,6 +111,56 @@ function requireNonBlank(value, label) {
|
|
|
84
111
|
}
|
|
85
112
|
return trimmed;
|
|
86
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Is the live `Strand` row the one a publish of `desired` would have produced?
|
|
116
|
+
*
|
|
117
|
+
* "Identical content" means `(Type, MemberPrivateKey)` and deliberately NOT
|
|
118
|
+
* `FounderOwnerKey`: that column is provenance (WHICH machine published the row), not
|
|
119
|
+
* content. Two machines of one party racing to found the same id must keep resolving as
|
|
120
|
+
* "the winner's row stands" — the loser adopts the row and, since the winner's key is on
|
|
121
|
+
* it, correctly comes up as a joiner (see `launchStrand`'s founder derivation). Comparing
|
|
122
|
+
* it here would turn that benign race into a hard throw. `Id` is the lookup key and
|
|
123
|
+
* `StampId` is a single-use nonce, so the two compared columns are all the content there
|
|
124
|
+
* is. A live row matching on both is, by construction, the state a repeat publish would
|
|
125
|
+
* have reached, whichever branch of `Strand.AuthorizedInsert` seated it (owner-signed, or
|
|
126
|
+
* the unsigned consent branch an invite redemption uses).
|
|
127
|
+
*
|
|
128
|
+
* Returns the mismatching column names, empty when the row matches. The KEY's value is
|
|
129
|
+
* deliberately never returned or logged — it is the closed strand's read-gating secret.
|
|
130
|
+
*/
|
|
131
|
+
function strandRowMismatches(live, desired) {
|
|
132
|
+
const mismatches = [];
|
|
133
|
+
if (live.Type !== desired.Type) {
|
|
134
|
+
mismatches.push(`Type is '${live.Type}', not the requested '${desired.Type}'`);
|
|
135
|
+
}
|
|
136
|
+
if ((live.MemberPrivateKey ?? null) !== (desired.MemberPrivateKey ?? null)) {
|
|
137
|
+
mismatches.push('MemberPrivateKey differs from the one supplied');
|
|
138
|
+
}
|
|
139
|
+
return mismatches;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The live row when it matches `desired` ({@link strandRowMismatches}); otherwise throw
|
|
143
|
+
* naming the columns that differ.
|
|
144
|
+
*
|
|
145
|
+
* A mismatch must stay a hard error: accepting one would let a retry reopen a strand the
|
|
146
|
+
* party closed, or swap the key that gates its reads — so the two cases the raw
|
|
147
|
+
* `UNIQUE constraint failed: Strand.Id` could not distinguish ("already done, carry on"
|
|
148
|
+
* vs. "genuine conflict, stop") are separated here rather than left to the caller.
|
|
149
|
+
*
|
|
150
|
+
* @param situation - How the row came to be there, as a verb phrase completing
|
|
151
|
+
* `Strand <id> …`, e.g. `'is already published'`.
|
|
152
|
+
*/
|
|
153
|
+
function requireMatchingStrandRow(live, desired, situation) {
|
|
154
|
+
const mismatches = strandRowMismatches(live, desired);
|
|
155
|
+
if (mismatches.length > 0) {
|
|
156
|
+
throw new Error(`Strand ${desired.Id} ${situation} with DIFFERENT content, so this publish is a ` +
|
|
157
|
+
`conflict, not a repeat: ${mismatches.join('; ')}. Reconcile deliberately — attach ` +
|
|
158
|
+
'the existing strand with addStrand/foundStrand to keep it, or unpublishStrand first ' +
|
|
159
|
+
'to re-seat it (destructive for a closed strand: its MemberPrivateKey is stored ' +
|
|
160
|
+
'nowhere else).');
|
|
161
|
+
}
|
|
162
|
+
return live;
|
|
163
|
+
}
|
|
87
164
|
/**
|
|
88
165
|
* CadreNode is the main entry point for a cadre member.
|
|
89
166
|
* It manages:
|
|
@@ -106,6 +183,14 @@ export class CadreNode {
|
|
|
106
183
|
this.trustedOwnerStore = null;
|
|
107
184
|
this.controlNode = null;
|
|
108
185
|
this.controlDatabase = null;
|
|
186
|
+
/**
|
|
187
|
+
* The CONTROL network's peer-join block catch-up — same module as the
|
|
188
|
+
* per-strand ones, but membership-gated (see {@link startControlBackfill}).
|
|
189
|
+
* Created in {@link start} after the control node and database are up, stopped
|
|
190
|
+
* and dropped in {@link cleanup} before either is torn down, so a stop()→
|
|
191
|
+
* start() cycle rebuilds it with a fresh caught-up-peer memo.
|
|
192
|
+
*/
|
|
193
|
+
this.controlBackfill = null;
|
|
109
194
|
this.strandWatcher = null;
|
|
110
195
|
this.seedBootstrapService = null;
|
|
111
196
|
this.strandSolicitationService = null;
|
|
@@ -272,6 +357,36 @@ export class CadreNode {
|
|
|
272
357
|
* stale stamp.
|
|
273
358
|
*/
|
|
274
359
|
this.strandPeerAddrRefreshAt = new Map();
|
|
360
|
+
/**
|
|
361
|
+
* CROSS-PARTY strand-network addresses learned at formation, keyed by strandId — the
|
|
362
|
+
* responder's live strand addrs, carried back on the formation result
|
|
363
|
+
* (`FormationResultMessage.strandAddrs`) and recorded by {@link formStrand}.
|
|
364
|
+
*
|
|
365
|
+
* This is the ONLY cross-party discovery input there is. The strand-addr RPC that
|
|
366
|
+
* resolves a strand's addresses is membership-gated and answers own-party siblings
|
|
367
|
+
* only, so without this map a joiner's cohort seed for a two-party strand is empty and
|
|
368
|
+
* the mesh never forms.
|
|
369
|
+
*
|
|
370
|
+
* Read by {@link resolveCohortSeed} (launch + hibernation resume) and re-merged by
|
|
371
|
+
* every {@link refreshStrandPeerAddrs} pass — the re-merge is what keeps the entries
|
|
372
|
+
* alive past the peerStore's one-hour address expiry, since nothing can re-resolve
|
|
373
|
+
* them.
|
|
374
|
+
*
|
|
375
|
+
* IN-MEMORY and ONE-SHOT, deliberately (see `docs/strands.md`): the addresses die with
|
|
376
|
+
* the process, and a restarted initiator with no sibling running the strand is back to
|
|
377
|
+
* an empty seed until the durability work lands
|
|
378
|
+
* (`backlog/feat-cross-party-strand-addr-durability`). They are also never refreshed,
|
|
379
|
+
* so a responder whose relay reservation rotates before the initiator dials leaves a
|
|
380
|
+
* dead entry behind; recovery today is a fresh invitation.
|
|
381
|
+
*
|
|
382
|
+
* NOTE: entries are kept for the node's lifetime, including across a
|
|
383
|
+
* {@link stopStrand} — a stopped strand may be rediscovered by the watcher and must
|
|
384
|
+
* still get its seed. Each strand's list is capped at `MAX_STRAND_ADDRS`, but the
|
|
385
|
+
* number of KEYS is bounded only by the strands this node has ever formed as an
|
|
386
|
+
* initiator (not by time); if a node ever forms strands at scale, evict on
|
|
387
|
+
* `unpublishStrand` or cap the map.
|
|
388
|
+
*/
|
|
389
|
+
this.crossPartyStrandAddrs = new Map();
|
|
275
390
|
/**
|
|
276
391
|
* Cold-start bootstrap dial targets: the owner-flagged peers of every seed
|
|
277
392
|
* this node has applied, keyed by peer id, valued by the seed's multiaddr
|
|
@@ -306,6 +421,16 @@ export class CadreNode {
|
|
|
306
421
|
* the party if it is ever stranded again.
|
|
307
422
|
*/
|
|
308
423
|
this.bootstrapPeerStore = null;
|
|
424
|
+
/**
|
|
425
|
+
* Node-local record of the party's enrolled-machine count (see
|
|
426
|
+
* `enrolled-machine-store.ts`), kept so the CONTROL node can declare a
|
|
427
|
+
* block-repair yardstick at bring-up. Constructed (or adopted from
|
|
428
|
+
* `config.enrolledMachines.store`) by {@link initializeEnrolledMachineStore};
|
|
429
|
+
* written by {@link refreshAuthorizedControlPeers}. Like
|
|
430
|
+
* {@link bootstrapPeerStore} it is deliberately NOT cleared by {@link cleanup},
|
|
431
|
+
* so it survives a stop()→start() cycle of the same node instance.
|
|
432
|
+
*/
|
|
433
|
+
this.enrolledMachineStore = null;
|
|
309
434
|
/** Initial self-registration timer (see {@link scheduleSelfRegistration}). */
|
|
310
435
|
this.selfRegistrationTimer = null;
|
|
311
436
|
/** TTL heartbeat that re-publishes the self record before it goes stale. */
|
|
@@ -547,6 +672,13 @@ export class CadreNode {
|
|
|
547
672
|
// closed before any network bring-up, and the retained dial targets are
|
|
548
673
|
// loaded before the first reconcile pass could consult them.
|
|
549
674
|
this.initializeBootstrapPeerStore();
|
|
675
|
+
// Read the party's enrolled-machine count out of its node-local record and
|
|
676
|
+
// capture it for buildControlNodeOptions below. This MUST precede
|
|
677
|
+
// createControlNode: Optimystic freezes the cluster policy when the node is
|
|
678
|
+
// built, and the ControlDatabase that could answer the question live does not
|
|
679
|
+
// exist until after that. Remembering the number across the restart is the
|
|
680
|
+
// only way to declare it at all — see `enrolled-machine-store.ts`.
|
|
681
|
+
this.initializeEnrolledMachineStore();
|
|
550
682
|
// Arm the connection gate's BRING-UP QUIET PERIOD before the libp2p node
|
|
551
683
|
// exists, so no connection can form ahead of it: `@libp2p/bootstrap` emits
|
|
552
684
|
// its discovery events ~1 s after `libp2p.start()` and the connection
|
|
@@ -660,6 +792,12 @@ export class CadreNode {
|
|
|
660
792
|
// delayed refresh path) so no early `connection:open` is missed — the
|
|
661
793
|
// write-while-alone re-replication drain must fire on the first 0→≥1 edge.
|
|
662
794
|
this.wireControlConnectionListeners();
|
|
795
|
+
// Arm the control network's peer-join block catch-up now that the control
|
|
796
|
+
// DB (which the push-time membership gate reads) is up. Armed even when
|
|
797
|
+
// this node is alone — like the strand one, it only does work when the
|
|
798
|
+
// control libp2p node reports a peer connection, and arming it at start is
|
|
799
|
+
// what closes the "genesised alone, headers never replicate" hole.
|
|
800
|
+
this.startControlBackfill();
|
|
663
801
|
// Seed the per-stream gate's materialized authorized set now that the
|
|
664
802
|
// control DB is up (non-blocking; the helper never rejects). Until it
|
|
665
803
|
// lands the snapshot is empty, which the stream gate treats as cold
|
|
@@ -846,6 +984,46 @@ export class CadreNode {
|
|
|
846
984
|
}
|
|
847
985
|
this.bootstrapPeerStore = store;
|
|
848
986
|
}
|
|
987
|
+
/**
|
|
988
|
+
* Construct (or adopt) the node-local enrolled-machine store and capture the
|
|
989
|
+
* count this run will declare into {@link declaredEnrolledMachines}.
|
|
990
|
+
*
|
|
991
|
+
* Synchronous for the same reason {@link initializeBootstrapPeerStore} is: an
|
|
992
|
+
* injected store has already loaded its persisted count by the time it is handed
|
|
993
|
+
* in (its `open` is the async part), and the in-memory default has nothing to
|
|
994
|
+
* load. Must run BEFORE {@link createControlNode} — that is the whole point of
|
|
995
|
+
* the record; see `enrolled-machine-store.ts`.
|
|
996
|
+
*
|
|
997
|
+
* The store instance is kept across stop()→start() (so a count recorded during
|
|
998
|
+
* the previous run is not lost with the ephemeral default), but the DECLARED
|
|
999
|
+
* count is re-read every time — which is how a restart applies a number the
|
|
1000
|
+
* previous run learned.
|
|
1001
|
+
*
|
|
1002
|
+
* A store scoped to a different party is a configuration error, fail closed. It
|
|
1003
|
+
* is only a repair hint, but a hint sized by a foreign party's membership is a
|
|
1004
|
+
* number nobody chose, and the mismatch always means a miswired embedder.
|
|
1005
|
+
*/
|
|
1006
|
+
initializeEnrolledMachineStore() {
|
|
1007
|
+
const partyId = this.config.controlNetwork.partyId;
|
|
1008
|
+
const store = this.enrolledMachineStore
|
|
1009
|
+
?? this.config.enrolledMachines?.store
|
|
1010
|
+
?? new MemoryEnrolledMachineStore(partyId);
|
|
1011
|
+
if (store.partyId !== partyId) {
|
|
1012
|
+
throw new Error(`CadreNodeConfig: enrolledMachines.store is scoped to party ${store.partyId}, ` +
|
|
1013
|
+
`but this node serves party ${partyId} — refusing to size a repair yardstick from a foreign party`);
|
|
1014
|
+
}
|
|
1015
|
+
this.enrolledMachineStore = store;
|
|
1016
|
+
this.declaredEnrolledMachines = store.count();
|
|
1017
|
+
log('control repair yardstick will be declared from %o enrolled machine(s)', this.declaredEnrolledMachines);
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* The node-local enrolled-machine store (null before {@link start}) — what this
|
|
1021
|
+
* node last knew about its party's size. Exposed for diagnostics and for a host
|
|
1022
|
+
* that wants to show which repair yardstick the next launch will declare.
|
|
1023
|
+
*/
|
|
1024
|
+
getEnrolledMachineStore() {
|
|
1025
|
+
return this.enrolledMachineStore;
|
|
1026
|
+
}
|
|
849
1027
|
/**
|
|
850
1028
|
* The node-local cold-start bootstrap-peer store (null before {@link start}) —
|
|
851
1029
|
* the retained owner addresses {@link reconcileControlCohort}'s cold-start
|
|
@@ -892,6 +1070,90 @@ export class CadreNode {
|
|
|
892
1070
|
async createControlNode() {
|
|
893
1071
|
return await createLibp2pNode(this.buildControlNodeOptions());
|
|
894
1072
|
}
|
|
1073
|
+
/**
|
|
1074
|
+
* The control libp2p node's Optimystic network name — ONE binding for every
|
|
1075
|
+
* derivation from it (`db-p2p` namespaces all of the node's protocol ids as
|
|
1076
|
+
* `/optimystic/<networkName>/...`), so the node options and the block-transfer
|
|
1077
|
+
* protocol prefix the control backfill dials can never drift apart.
|
|
1078
|
+
*/
|
|
1079
|
+
controlNetworkName() {
|
|
1080
|
+
return `control-${this.config.controlNetwork.partyId}`;
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Arm the CONTROL network's peer-join block catch-up: push every block in the
|
|
1084
|
+
* control database's own raw store to each newly connected AUTHORIZED member
|
|
1085
|
+
* this runtime has not yet caught up. This is what physically replicates
|
|
1086
|
+
* control blocks committed while the writer was alone — the named
|
|
1087
|
+
* collection-header blocks written once at genesis above all, whose revision
|
|
1088
|
+
* never moves again, so no later commit ever carries them to a member that
|
|
1089
|
+
* joined after them. Without it, such a member that restarts offline reads
|
|
1090
|
+
* the affected control tables as EMPTY, silently (`isMember()` answers false
|
|
1091
|
+
* for peers it knew about before the restart).
|
|
1092
|
+
*
|
|
1093
|
+
* Unlike the per-strand instances (see `strand-instance-manager.ts`), pushes
|
|
1094
|
+
* are gated on {@link isAuthorizedMember}, judged at push time: the control
|
|
1095
|
+
* network's inbound connection gate deliberately admits non-members in
|
|
1096
|
+
* several states (seed delivery to an un-enrolled node, an open enrollment
|
|
1097
|
+
* window, an outstanding invitation, configured bootstrap/relay peers), and
|
|
1098
|
+
* pushing the whole control store to such a peer would hand a stranger the
|
|
1099
|
+
* party's entire membership, addresses and strand list. The receiving side
|
|
1100
|
+
* needs no work of its own: `createLibp2pNode` registers the block-transfer
|
|
1101
|
+
* handler on every node it builds, control node included, and this node's
|
|
1102
|
+
* per-stream gate (`authorizeInboundControlStream`) covers the inbound
|
|
1103
|
+
* direction. A denied peer is retried on its next `connection:open`, and —
|
|
1104
|
+
* because the production join order is connect-then-authorize — on every
|
|
1105
|
+
* committed membership change via {@link refreshAuthorizedControlPeers}'s
|
|
1106
|
+
* `scheduleConnectedPeers()` call.
|
|
1107
|
+
*
|
|
1108
|
+
* No-ops (logged) when the embedder configured no control storage or the
|
|
1109
|
+
* node exposes no key network — the backfill would have nothing to read or
|
|
1110
|
+
* no way to dial.
|
|
1111
|
+
*/
|
|
1112
|
+
startControlBackfill() {
|
|
1113
|
+
if (this.config.controlBackfill?.enabled === false || !this.controlNode) {
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
// Through the resolver, not the {@link controlStorage} memo field: the resolver is
|
|
1117
|
+
// idempotent (it returns the same wrapped store `buildControlNodeOptions` already
|
|
1118
|
+
// resolved), so the catch-up cannot be silently disarmed by a future reordering that
|
|
1119
|
+
// moves this call ahead of the one that populated the field.
|
|
1120
|
+
const storage = this.resolveControlStorage();
|
|
1121
|
+
const keyNetwork = this.controlNode.keyNetwork;
|
|
1122
|
+
if (!storage || !keyNetwork) {
|
|
1123
|
+
log('Control peer-join block catch-up is inert: %s', !storage ? 'no control storage configured' : 'control node exposes no keyNetwork');
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
const networkName = this.controlNetworkName();
|
|
1127
|
+
this.controlBackfill = new PeerJoinBackfill({
|
|
1128
|
+
label: networkName,
|
|
1129
|
+
libp2p: this.controlNode,
|
|
1130
|
+
peerNetwork: keyNetwork,
|
|
1131
|
+
storage,
|
|
1132
|
+
// The same prefix the receiver registered its block-transfer handler
|
|
1133
|
+
// under — derived from the same networkName binding the node options
|
|
1134
|
+
// used, never re-spelled here.
|
|
1135
|
+
protocolPrefix: `/optimystic/${networkName}`,
|
|
1136
|
+
authorizePeer: (peerId) => this.isAuthorizedMember(peerId)
|
|
1137
|
+
}, {
|
|
1138
|
+
// A shorter settle window than the strand default (1000 ms): the control
|
|
1139
|
+
// store is small (a party's membership — dozens of blocks), a re-push is
|
|
1140
|
+
// idempotent, and the window a joiner is connected before going offline
|
|
1141
|
+
// can be short (converge one row, then stop) — a slow debounce is the
|
|
1142
|
+
// difference between that joiner holding the collection headers and
|
|
1143
|
+
// reading its own membership as empty after an offline restart.
|
|
1144
|
+
//
|
|
1145
|
+
// NOTE: 250 is reasoned, not swept — the delete-while-alone scenario's
|
|
1146
|
+
// phases 1-3 finish in about a second, so the 1000 ms strand default left
|
|
1147
|
+
// the push racing the joiner's stop, and 250 gives margin. Nobody measured
|
|
1148
|
+
// the shape of the curve either side of it. If the control gates start
|
|
1149
|
+
// flaking on a joiner that stopped before being caught up, lower this
|
|
1150
|
+
// before looking anywhere else; if catch-up pushes start showing up as
|
|
1151
|
+
// connection-churn noise, raise it.
|
|
1152
|
+
debounceMs: 250,
|
|
1153
|
+
...this.config.controlBackfill
|
|
1154
|
+
});
|
|
1155
|
+
this.controlBackfill.start();
|
|
1156
|
+
}
|
|
895
1157
|
/**
|
|
896
1158
|
* The control database's cache-wrapped raw storage, resolved from
|
|
897
1159
|
* `config.storage.provider` on first use and then held in {@link controlStorage}
|
|
@@ -974,6 +1236,11 @@ export class CadreNode {
|
|
|
974
1236
|
// the END of `start()`, so the control database is built while this node holds
|
|
975
1237
|
// zero control connections. See `relay-addrs.ts` and `start()`.
|
|
976
1238
|
const listenAddrs = resolveListenAddrs(network, 'search');
|
|
1239
|
+
// The transports those listen entries imply. A `/ws` entry switches db-p2p's
|
|
1240
|
+
// WebSocket transport on (without it the entry binds nothing and libp2p reports
|
|
1241
|
+
// nothing); anything outside {tcp, ws/wss, p2p-circuit} throws here rather than
|
|
1242
|
+
// being silently dropped at bring-up. See `relay-addrs.ts`.
|
|
1243
|
+
const transportOptions = resolveTransportOptions(network, listenAddrs);
|
|
977
1244
|
const enableRelay = this.relayServerEnabled();
|
|
978
1245
|
// The control node's own storage, resolved once per runtime (see
|
|
979
1246
|
// {@link resolveControlStorage}) rather than per call.
|
|
@@ -981,7 +1248,7 @@ export class CadreNode {
|
|
|
981
1248
|
const nodeOptions = {
|
|
982
1249
|
port: 0,
|
|
983
1250
|
bootstrapNodes: controlNetwork.bootstrapNodes,
|
|
984
|
-
networkName:
|
|
1251
|
+
networkName: this.controlNetworkName(),
|
|
985
1252
|
storage: controlStorageProvider,
|
|
986
1253
|
fretProfile: profile === 'storage' ? 'core' : 'edge',
|
|
987
1254
|
relay: enableRelay,
|
|
@@ -992,10 +1259,22 @@ export class CadreNode {
|
|
|
992
1259
|
// CONTROL_CLUSTER_POLICY: only the admission gate defaults to 2, while the read-repair
|
|
993
1260
|
// corroboration floor would otherwise fall back to this clusterSize of 16.
|
|
994
1261
|
clusterSize: CONTROL_REPLICATION_BREADTH,
|
|
995
|
-
|
|
1262
|
+
// The base control policy with the block-repair corroboration yardstick declared
|
|
1263
|
+
// from the machines this party had enrolled at this node's last look
|
|
1264
|
+
// ({@link declaredEnrolledMachines}, read in start() before this runs). Handed
|
|
1265
|
+
// `undefined` — a brand-new node, an unreadable record, or an embedder that
|
|
1266
|
+
// injected no store — this returns the frozen CONTROL_CLUSTER_POLICY object
|
|
1267
|
+
// itself, so the unknown case is provably byte-for-byte the old behaviour.
|
|
1268
|
+
// The yardstick moves ALONE: `assumedClusterSize` stays pinned at 2, because a
|
|
1269
|
+
// party of phones cannot promise three quarters of its machines are awake.
|
|
1270
|
+
clusterPolicy: controlClusterPolicy(this.declaredEnrolledMachines),
|
|
996
1271
|
arachnode: { enableRingZulu: profile === 'storage' },
|
|
997
1272
|
...(identityKey && { privateKey: identityKey }),
|
|
998
1273
|
...(network?.transports && { transports: network.transports }),
|
|
1274
|
+
// `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
|
|
1275
|
+
// `{}` when `network.transports` is set, since the embedder owns transport policy
|
|
1276
|
+
// then. Spread NEXT to `transports` because the two answer the same question.
|
|
1277
|
+
...transportOptions,
|
|
999
1278
|
// Configured `listenAddrs`, plus the bare `/p2p-circuit` search listener when
|
|
1000
1279
|
// `network.relayAddrs` is set — that listener registers the pending reservation
|
|
1001
1280
|
// `driveControlRelayReservation` fills after bring-up.
|
|
@@ -1325,6 +1604,68 @@ export class CadreNode {
|
|
|
1325
1604
|
const members = await this.listAuthorizedMembers(false);
|
|
1326
1605
|
this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
|
|
1327
1606
|
log('refreshAuthorizedControlPeers(%s): %d authorized peer(s)', reason, this.authorizedControlPeers.size);
|
|
1607
|
+
// Remember the party's size for the NEXT launch's control-node repair yardstick
|
|
1608
|
+
// (see `enrolled-machine-store.ts`): this is the only place the count is known,
|
|
1609
|
+
// and the control node that needs it was built long before this ran. Recorded
|
|
1610
|
+
// from the snapshot just materialized — no second membership query.
|
|
1611
|
+
//
|
|
1612
|
+
// CONTROL-NETWORK ONLY, and that scope is load-bearing. Every enrolled machine
|
|
1613
|
+
// runs the control node by construction, so here the party's machine count IS
|
|
1614
|
+
// the count of machines serving the network — the quantity
|
|
1615
|
+
// `resolveRepairYardstick` asks for. It is NOT that quantity for a strand, which
|
|
1616
|
+
// launches only on machines whose embedder registered its sApp config (see
|
|
1617
|
+
// {@link addStrand}), so this number must never be routed to a strand node:
|
|
1618
|
+
// over-declaring pins Optimystic's repair corroboration floor at two peers and a
|
|
1619
|
+
// strand that can field only one can then never repair. `launchStrand` therefore
|
|
1620
|
+
// declares nothing; see the NOTE there.
|
|
1621
|
+
//
|
|
1622
|
+
// `+ 1` for this node, which is not in its own authorized set. An EMPTY snapshot
|
|
1623
|
+
// is recorded as 1 rather than skipped. That is deliberate, and the reason
|
|
1624
|
+
// is a party that genuinely SHRANK: if empty meant "record nothing", a party
|
|
1625
|
+
// whose other machines were all revoked would keep declaring its old, larger
|
|
1626
|
+
// number forever, and over-declaring is the unsafe direction — at a yardstick of
|
|
1627
|
+
// 3 or more Optimystic pins the repair corroboration floor at two peers, so a
|
|
1628
|
+
// cohort that can only field one peer could never repair at all. Recording 1
|
|
1629
|
+
// lets the number come back down; the floor then turns it into 2, which is
|
|
1630
|
+
// exactly what declaring nothing resolves to.
|
|
1631
|
+
//
|
|
1632
|
+
// NOTE: the cost of that choice is that a TRANSIENT empty read overwrites a good
|
|
1633
|
+
// remembered count with 1 — a membership snapshot is legitimately empty early in
|
|
1634
|
+
// a run on a node whose rows have not replicated yet or whose trusted-owner
|
|
1635
|
+
// anchor is still unseeded (`listAuthorizedMembers` authorizes no one without an
|
|
1636
|
+
// anchor). Bounded and safe: the next refresh once rows land re-records the real
|
|
1637
|
+
// count, and the only node that loses anything is one stopped inside that window,
|
|
1638
|
+
// which then declares 2 — today's behaviour — on its next launch. If a node ever
|
|
1639
|
+
// needs to declare the right number on its FIRST post-restart launch, the fix is
|
|
1640
|
+
// to distinguish "no rows yet" from "no members" at this site, not to skip empty.
|
|
1641
|
+
//
|
|
1642
|
+
// A removed peer stops counting at REVOCATION, not at reap: `queryCadrePeers`
|
|
1643
|
+
// drops rows whose `StampId` is retired, and `listAuthorizedMembers` reads
|
|
1644
|
+
// through it. That is why there is no reap hook writing this record.
|
|
1645
|
+
//
|
|
1646
|
+
// `void`: the store never rejects and logs its own persist failures, and this
|
|
1647
|
+
// method's contract is never-rejects. A failed write leaves the in-memory count
|
|
1648
|
+
// correct for this session and re-lands on the next refresh.
|
|
1649
|
+
void this.enrolledMachineStore?.record(this.authorizedControlPeers.size + 1);
|
|
1650
|
+
// Membership moved: give the control backfill another look at every
|
|
1651
|
+
// connected peer. The production join order is connect-then-authorize, so
|
|
1652
|
+
// a joiner's first catch-up pass was denied at the gate while its
|
|
1653
|
+
// connection stays up — without this re-arm it would wait for a
|
|
1654
|
+
// reconnect. Cheap and idempotent: caught-up peers are skipped before any
|
|
1655
|
+
// timer is set, a peer whose pass is in flight is deferred to the end of
|
|
1656
|
+
// that pass (the pass may already be past its gate check — this very
|
|
1657
|
+
// commit is what would authorize it), and the gate re-judges at push time.
|
|
1658
|
+
//
|
|
1659
|
+
// NOTE: every refresh re-arms every connected peer that is not yet caught
|
|
1660
|
+
// up, and each re-armed pass costs one `CadrePeer` query at the gate. A
|
|
1661
|
+
// connection that is admitted but never authorized — a configured
|
|
1662
|
+
// bootstrap or relay peer, the steady state for a NAT'd node — is
|
|
1663
|
+
// therefore re-judged on every membership write and every timed reconcile
|
|
1664
|
+
// tick, forever. Negligible at a cadre's handful of connections; if a node
|
|
1665
|
+
// ever holds many such connections, or refreshes get frequent, skip
|
|
1666
|
+
// re-arming peers whose last pass was denied under the SAME membership
|
|
1667
|
+
// snapshot instead of re-arming unconditionally.
|
|
1668
|
+
this.controlBackfill?.scheduleConnectedPeers();
|
|
1328
1669
|
}
|
|
1329
1670
|
catch (error) {
|
|
1330
1671
|
log('refreshAuthorizedControlPeers(%s) failed — keeping previous snapshot: %o', reason, error);
|
|
@@ -1544,6 +1885,29 @@ export class CadreNode {
|
|
|
1544
1885
|
}
|
|
1545
1886
|
return null;
|
|
1546
1887
|
}
|
|
1888
|
+
/**
|
|
1889
|
+
* Does the `Strand` row name THIS machine as its founder — i.e. is its
|
|
1890
|
+
* `FounderOwnerKey` this node's own owner key (the key behind its PeerId)?
|
|
1891
|
+
* A null/absent column (a consent-seated strand, or a hand-built row) and a
|
|
1892
|
+
* node with no owner key both derive `false`: without a positive match this
|
|
1893
|
+
* machine must attach, never bootstrap. Pure key derivation, no I/O — cheap
|
|
1894
|
+
* enough for {@link launchStrand}'s tracked-instance early return.
|
|
1895
|
+
*
|
|
1896
|
+
* NOTE: reads "one owner key per machine" — the reference model, where the owner
|
|
1897
|
+
* key IS the key behind the PeerId. Two machines running the SAME identity key
|
|
1898
|
+
* would both derive `true` and each bootstrap on its own replica, which is the
|
|
1899
|
+
* double-`Header` hazard this derivation exists to avoid. Unreachable today: that
|
|
1900
|
+
* configuration also gives both machines one PeerId, which already breaks control
|
|
1901
|
+
* networking well before any strand launches. Revisit if machines ever share an
|
|
1902
|
+
* owner key while holding distinct transport identities — the derivation would
|
|
1903
|
+
* then need a per-machine discriminator (the `CadrePeer` PeerId) on the row.
|
|
1904
|
+
*/
|
|
1905
|
+
isSelfFoundedRow(strand) {
|
|
1906
|
+
if (strand.FounderOwnerKey == null) {
|
|
1907
|
+
return false;
|
|
1908
|
+
}
|
|
1909
|
+
return strand.FounderOwnerKey === this.getSelfSigningKey()?.publicKeyB64;
|
|
1910
|
+
}
|
|
1547
1911
|
/**
|
|
1548
1912
|
* The owner keypair (base64url Ed25519) derived from this node's resolved
|
|
1549
1913
|
* identity key. In the single-key reference model the owner signing key is
|
|
@@ -1788,21 +2152,26 @@ export class CadreNode {
|
|
|
1788
2152
|
* One address bound to `peerId`, or `null` when it cannot be — logged either
|
|
1789
2153
|
* way, never thrown, so every caller's list-shaping stays total.
|
|
1790
2154
|
*
|
|
2155
|
+
* Used both to normalize addresses this node DIALS (a `CadrePeer` row, a
|
|
2156
|
+
* seed-supplied bootstrap addr) and to normalize the ones it ANNOUNCES
|
|
2157
|
+
* ({@link getStrandMultiaddrs}); the rule is the same either way — an address
|
|
2158
|
+
* that does not reach `peerId` is not an address for `peerId`.
|
|
2159
|
+
*
|
|
1791
2160
|
* `withTrailingPeerId` encapsulates `/p2p/<peerId>`, which throws on a peer id
|
|
1792
2161
|
* that does not parse. Unreachable for a `CadrePeer` row (the binding gate
|
|
1793
|
-
* above parsed it already), reachable for a
|
|
1794
|
-
* ({@link bootstrapDialAddrs}).
|
|
2162
|
+
* above parsed it already) and for our own node's id, reachable for a
|
|
2163
|
+
* seed-supplied one ({@link bootstrapDialAddrs}).
|
|
1795
2164
|
*/
|
|
1796
2165
|
bindAddrToPeer(addr, peerId) {
|
|
1797
2166
|
try {
|
|
1798
2167
|
const bound = withTrailingPeerId(addr, peerId);
|
|
1799
2168
|
if (!bound) {
|
|
1800
|
-
log('
|
|
2169
|
+
log('bindAddrToPeer: dropping addr %s — it names a peer other than %s', addr.toString(), peerId);
|
|
1801
2170
|
}
|
|
1802
2171
|
return bound;
|
|
1803
2172
|
}
|
|
1804
2173
|
catch (error) {
|
|
1805
|
-
log('
|
|
2174
|
+
log('bindAddrToPeer: cannot bind %s to %s: %o', addr.toString(), peerId, error);
|
|
1806
2175
|
return null;
|
|
1807
2176
|
}
|
|
1808
2177
|
}
|
|
@@ -3052,6 +3421,14 @@ export class CadreNode {
|
|
|
3052
3421
|
// announce timestamps that would suppress the next session's refresh.
|
|
3053
3422
|
this.delegateAdmission.clear();
|
|
3054
3423
|
this.delegateAnnounceAt.clear();
|
|
3424
|
+
// Control backfill first — before the database closes and the control node
|
|
3425
|
+
// stops — so no NEW catch-up push is issued against a torn-down transport.
|
|
3426
|
+
// A push already in flight is not awaited; it fails into the module's own
|
|
3427
|
+
// per-chunk catch. (Mirrors releaseRuntime in strand-instance-manager.ts.)
|
|
3428
|
+
if (this.controlBackfill) {
|
|
3429
|
+
this.controlBackfill.stop();
|
|
3430
|
+
this.controlBackfill = null;
|
|
3431
|
+
}
|
|
3055
3432
|
// Close control database (this also shuts down the collection factory).
|
|
3056
3433
|
// Detach the membership hub first: nothing this teardown does should drive a
|
|
3057
3434
|
// gate refresh, and a notification arriving after this point is a no-op.
|
|
@@ -3152,8 +3529,8 @@ export class CadreNode {
|
|
|
3152
3529
|
/**
|
|
3153
3530
|
* Rebuild a quiesced strand's runtime, re-resolving the volatile cohort input
|
|
3154
3531
|
* first: the discovery seed may have grown since the strand last ran. Shared by
|
|
3155
|
-
* the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so
|
|
3156
|
-
*
|
|
3532
|
+
* the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so both
|
|
3533
|
+
* apply the same fresh resolution. `resumeStrand` is idempotent
|
|
3157
3534
|
* (returns the live instance unchanged) as a backstop against double-resume.
|
|
3158
3535
|
*/
|
|
3159
3536
|
async resumeStrandRuntime(strandId) {
|
|
@@ -3164,6 +3541,9 @@ export class CadreNode {
|
|
|
3164
3541
|
? peerIdFromPrivateKey(await strandTransportKey(this.identityKey, strandId)).toString()
|
|
3165
3542
|
: undefined;
|
|
3166
3543
|
const bootstrapNodes = await this.resolveCohortSeed(strandId, delegatePeerId);
|
|
3544
|
+
// NOTE: no `servingMachines` override — see `launchStrand` for why a strand node
|
|
3545
|
+
// declares no repair yardstick, and `StartStrandConfig.servingMachines` for the
|
|
3546
|
+
// count that would legitimately go here once one exists.
|
|
3167
3547
|
const instance = await this.strandManager.resumeStrand(strandId, { bootstrapNodes });
|
|
3168
3548
|
// Same reason as the launch path: `bootstrapNodes` only reaches the address
|
|
3169
3549
|
// book through @libp2p/bootstrap discovery, so merge it directly as well.
|
|
@@ -3297,6 +3677,14 @@ export class CadreNode {
|
|
|
3297
3677
|
* Add a strand with its sApp configuration.
|
|
3298
3678
|
* The hosting application must provide the sApp schema when creating a strand.
|
|
3299
3679
|
*
|
|
3680
|
+
* This is the ATTACH half only — it starts the local instance and never publishes the
|
|
3681
|
+
* `Strand` row. A joiner (the row arrived over the control network) wants exactly this; a
|
|
3682
|
+
* FOUNDER wants {@link foundStrand}, which publishes and attaches in one resumable call.
|
|
3683
|
+
* With no explicit `founder` flag, founder-ness is derived from the row's
|
|
3684
|
+
* `FounderOwnerKey` (see {@link StrandConfig.founder}) — so attaching a row THIS machine
|
|
3685
|
+
* published (e.g. re-attaching its own orphan after a restart) founds it, and attaching
|
|
3686
|
+
* anyone else's row joins, without the caller needing to know which it is.
|
|
3687
|
+
*
|
|
3300
3688
|
* A rejected call leaves nothing running but DOES leave the sApp config
|
|
3301
3689
|
* registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
|
|
3302
3690
|
* automatic relaunch need it. So once the strand's row is visible on the control
|
|
@@ -3312,9 +3700,11 @@ export class CadreNode {
|
|
|
3312
3700
|
const { strandRow, sAppConfig, founder } = config;
|
|
3313
3701
|
// Store sApp config for this strand
|
|
3314
3702
|
this.sAppConfigs.set(strandRow.Id, sAppConfig);
|
|
3315
|
-
log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)', strandRow.Id, sAppConfig.id, founder ??
|
|
3316
|
-
// `founder`
|
|
3317
|
-
//
|
|
3703
|
+
log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)', strandRow.Id, sAppConfig.id, founder ?? 'derived');
|
|
3704
|
+
// An unset `founder` is DERIVED from the row inside launchStrand (this node founds
|
|
3705
|
+
// iff the row's FounderOwnerKey is its own owner key); an explicit flag wins — the
|
|
3706
|
+
// formation/responder flows pass one deliberately, since their consent-seated rows
|
|
3707
|
+
// carry a null column. See StrandConfig.founder.
|
|
3318
3708
|
return await this.launchStrand(strandRow, sAppConfig, founder);
|
|
3319
3709
|
}
|
|
3320
3710
|
/**
|
|
@@ -3322,16 +3712,41 @@ export class CadreNode {
|
|
|
3322
3712
|
* owner identity, so other cadre members discover it via control-network
|
|
3323
3713
|
* sync (their {@link StrandWatcher} fires `strand:discovered`).
|
|
3324
3714
|
*
|
|
3325
|
-
*
|
|
3326
|
-
*
|
|
3327
|
-
*
|
|
3328
|
-
*
|
|
3329
|
-
* `addStrand` (the row already exists).
|
|
3715
|
+
* **Founding a strand is TWO steps** — this one (publish the row cadre-wide) and
|
|
3716
|
+
* {@link addStrand} (start the local instance, `founder: true` to write the strand's
|
|
3717
|
+
* `Header`/founding membership). `addStrand` deliberately omits the insert: it only
|
|
3718
|
+
* starts the LOCAL instance, whereas publishing makes the strand visible cadre-wide. A
|
|
3719
|
+
* discovering peer only does `addStrand` (the row already exists). Callers founding a
|
|
3720
|
+
* strand should use {@link foundStrand}, which performs both steps and is safe to
|
|
3721
|
+
* re-run — hand-rolling the pair is what left strands half-founded when an app was
|
|
3722
|
+
* killed between them.
|
|
3723
|
+
*
|
|
3724
|
+
* **Idempotent for identical content.** A live row whose `(Type, MemberPrivateKey)` match
|
|
3725
|
+
* the arguments is the state this call would have produced, so the call logs and returns
|
|
3726
|
+
* that row instead of writing — a publish interrupted after it committed can be repeated.
|
|
3727
|
+
* DIFFERENT content on the same id throws, naming the columns that differ, rather than
|
|
3728
|
+
* surfacing the raw `UNIQUE constraint failed: Strand.Id`: silently accepting it would
|
|
3729
|
+
* let a retry reopen a closed strand or swap the key gating its reads. The read and the
|
|
3730
|
+
* insert are not atomic, so a concurrent founder (two machines of one party, same id) is
|
|
3731
|
+
* caught on the insert's uniqueness rejection and resolved the same way — re-read, no-op
|
|
3732
|
+
* on a match, rethrow otherwise. Never an overwrite either way.
|
|
3733
|
+
*
|
|
3734
|
+
* A tombstoned strand is NOT resurrected here: {@link unpublishStrand} deletes the row, so
|
|
3735
|
+
* the idempotent branch is unreachable and the ordinary publish path re-seats it, exactly
|
|
3736
|
+
* as that method documents.
|
|
3737
|
+
*
|
|
3738
|
+
* **Already stuck on `UNIQUE constraint failed: Strand.Id`?** The row is already published
|
|
3739
|
+
* — attach, do NOT republish: `addStrand` (or {@link foundStrand}) with the id and, for a
|
|
3740
|
+
* closed strand, the `MemberPrivateKey` read back from the row. `unpublishStrand` + a
|
|
3741
|
+
* fresh publish also clears it for an OPEN strand, but is destructive for a closed one
|
|
3742
|
+
* (the key exists nowhere else). No app-data wipe is needed for either.
|
|
3330
3743
|
*
|
|
3331
3744
|
* The insert is signed with the ed25519 key behind this node's PeerId — which
|
|
3332
3745
|
* {@link ed25519KeyPairFromLibp2p} also exposes as the node's owner keypair,
|
|
3333
|
-
* so peer identity and owner key are one and the same. That key
|
|
3334
|
-
*
|
|
3746
|
+
* so peer identity and owner key are one and the same. That key is also persisted on the
|
|
3747
|
+
* row as `FounderOwnerKey` (the schema pins the column to the verified signer), which is
|
|
3748
|
+
* what later lets any launch derive "this machine is the founder" from the row alone.
|
|
3749
|
+
* The key must be enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
|
|
3335
3750
|
* at genesis) or the schema's `Strand.AuthorizedInsert` constraint rejects the write.
|
|
3336
3751
|
* Failing loudly here is intentional: a silently-unpublished strand would run
|
|
3337
3752
|
* as a local-only island that no peer could ever discover or join.
|
|
@@ -3340,16 +3755,148 @@ export class CadreNode {
|
|
|
3340
3755
|
* {@link addStrand}).
|
|
3341
3756
|
* @param type - `'o'` for open (default) or `'c'` for closed.
|
|
3342
3757
|
* @param memberPrivateKey - Optional membership key for a closed strand.
|
|
3343
|
-
* @
|
|
3344
|
-
*
|
|
3758
|
+
* @returns The live `Strand` row — the one just inserted, or the matching one already
|
|
3759
|
+
* there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
|
|
3760
|
+
* on a repeat it is the stored key, not the argument.
|
|
3761
|
+
* @throws if the node is not started, exposes no owner signing key, the id is blank, a
|
|
3762
|
+
* row with the same id holds different content, or the control DB rejects the
|
|
3763
|
+
* (unauthorized) insert.
|
|
3345
3764
|
*/
|
|
3346
3765
|
async publishStrand(strandId, type = 'o', memberPrivateKey) {
|
|
3347
3766
|
const signingKey = this.requireOwnerSigningKey(`publish strand ${strandId}`);
|
|
3348
3767
|
// Trim/reject here so the id that lands matches the one unpublishStrand looks up: it
|
|
3349
3768
|
// trims too, and an untrimmed row would be unreachable by the same string.
|
|
3350
3769
|
const trimmed = requireNonBlank(strandId, 'strand id');
|
|
3351
|
-
|
|
3770
|
+
// FounderOwnerKey records THIS machine as the row's publisher (the schema pins it to
|
|
3771
|
+
// the signing owner), which is what later lets a relaunch derive founder-ness.
|
|
3772
|
+
const desired = {
|
|
3773
|
+
Id: trimmed,
|
|
3774
|
+
Type: type,
|
|
3775
|
+
MemberPrivateKey: memberPrivateKey ?? null,
|
|
3776
|
+
FounderOwnerKey: signingKey.publicKeyB64,
|
|
3777
|
+
};
|
|
3778
|
+
// Read first rather than leaning on the insert's collision for BOTH readings. The
|
|
3779
|
+
// ordinary resume (an app killed after its own publish committed) then needs no error
|
|
3780
|
+
// matching at all: the collision path can only recognise itself by the engine's message
|
|
3781
|
+
// TEXT (see isStrandIdConflict), and an upstream reword there must degrade the RARE race,
|
|
3782
|
+
// not the common resume. It also spends no stamp or signature on a known no-op.
|
|
3783
|
+
const existing = await this.controlDatabase.queryStrand(trimmed);
|
|
3784
|
+
if (existing) {
|
|
3785
|
+
// NOTE: reads local converged state, and `queryStrand` does not filter rows whose
|
|
3786
|
+
// StampId a Revocation has retired. So on a sibling that still physically holds a row
|
|
3787
|
+
// deleted elsewhere while ALONE (the delete-while-alone gap in docs/architecture.md),
|
|
3788
|
+
// an owner-signed republish now no-ops onto that doomed row instead of re-seating the
|
|
3789
|
+
// id under a fresh stamp. Harmless while that gap is itself unresolved — both
|
|
3790
|
+
// behaviours end at the same unconverged state — but if delete-while-alone ever gains
|
|
3791
|
+
// real replay, filter retired stamps here (queryStrandStampId + the Revocation read)
|
|
3792
|
+
// so a re-seat is not mistaken for a repeat.
|
|
3793
|
+
const matched = requireMatchingStrandRow(existing, desired, 'is already published');
|
|
3794
|
+
log('publishStrand(%s): already published (type %s) with identical content — no-op', trimmed, type);
|
|
3795
|
+
return matched;
|
|
3796
|
+
}
|
|
3797
|
+
try {
|
|
3798
|
+
await this.controlDatabase.insertStrand(trimmed, type, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64), memberPrivateKey);
|
|
3799
|
+
}
|
|
3800
|
+
catch (error) {
|
|
3801
|
+
// Only the uniqueness collision is a candidate for the idempotent reading; every
|
|
3802
|
+
// other rejection (unauthorized signer, retired stamp) must keep surfacing.
|
|
3803
|
+
if (!isStrandIdConflict(error)) {
|
|
3804
|
+
throw error;
|
|
3805
|
+
}
|
|
3806
|
+
const landed = await this.controlDatabase.queryStrand(trimmed);
|
|
3807
|
+
if (!landed) {
|
|
3808
|
+
// Collided, yet no row is readable: not the race this branch handles (a row
|
|
3809
|
+
// deleted between the rejection and the re-read, or a stale local view). Surface
|
|
3810
|
+
// the original rather than guessing.
|
|
3811
|
+
throw error;
|
|
3812
|
+
}
|
|
3813
|
+
log('publishStrand(%s): lost a concurrent founding race; re-read the landed row', trimmed);
|
|
3814
|
+
return requireMatchingStrandRow(landed, desired, 'landed concurrently from another founder');
|
|
3815
|
+
}
|
|
3352
3816
|
log('Published strand %s (type %s) to control DB under owner %s', trimmed, type, signingKey.publicKeyB64);
|
|
3817
|
+
return desired;
|
|
3818
|
+
}
|
|
3819
|
+
/**
|
|
3820
|
+
* Found a strand — publish its row cadre-wide AND start the local instance as its
|
|
3821
|
+
* founder — in one resumable call. The single entry point for creating a strand; a
|
|
3822
|
+
* caller joining one someone else founded uses {@link addStrand} alone.
|
|
3823
|
+
*
|
|
3824
|
+
* Safe to re-run from any point of interruption, which hand-rolling
|
|
3825
|
+
* {@link publishStrand} + {@link addStrand} is not. Either half can already have
|
|
3826
|
+
* happened:
|
|
3827
|
+
*
|
|
3828
|
+
* - **row already published** → the stored row is adopted rather than re-published, so a
|
|
3829
|
+
* run killed after the insert committed no longer dies on
|
|
3830
|
+
* `UNIQUE constraint failed: Strand.Id`. For a closed strand the row's STORED
|
|
3831
|
+
* `MemberPrivateKey` wins over `config.memberPrivateKey`: a caller that mints a key per
|
|
3832
|
+
* attempt (as the reference apps do) would otherwise present a key that does not match
|
|
3833
|
+
* the membership already seated in the strand.
|
|
3834
|
+
* - **instance already running** → the tracked instance is returned, and — because the
|
|
3835
|
+
* `Strand` row records its publishing machine (`FounderOwnerKey`) — a founder request
|
|
3836
|
+
* against an instance that was first launched as a joiner now runs the (idempotent,
|
|
3837
|
+
* insert-if-absent) founder bootstrap on it rather than silently skipping it
|
|
3838
|
+
* ({@link StrandInstanceManager.foundExistingStrand}). So a strand that something else
|
|
3839
|
+
* attached first — the reference RN app's `strand:discovered` handler after a restart,
|
|
3840
|
+
* or this node's own {@link StrandWatcher} poll winning `launchStrand`'s
|
|
3841
|
+
* `resolveCohortSeed` window — still ends up with its `Strand.Header` written.
|
|
3842
|
+
*
|
|
3843
|
+
* Founder-ness is resolved FROM THE ROW, not assumed: this machine founds iff the
|
|
3844
|
+
* resolved row's `FounderOwnerKey` is its own owner key. A fresh publish records this
|
|
3845
|
+
* machine, so the common path founds; a machine that lost a concurrent founding race to
|
|
3846
|
+
* a sibling adopts the sibling's row and ATTACHES instead — deliberately, since two
|
|
3847
|
+
* machines bootstrapping the same strand on separate replicas is the double-`Header`
|
|
3848
|
+
* hazard. The returned {@link FoundStrandResult.founded} says which happened.
|
|
3849
|
+
*
|
|
3850
|
+
* `Type` is compared, not adopted: a stored row of the other type means the caller and
|
|
3851
|
+
* the control plane disagree about what this strand IS, so it throws.
|
|
3852
|
+
*
|
|
3853
|
+
* @returns The instance AND the row the strand actually runs under — read the membership
|
|
3854
|
+
* key from the returned row, not from a freshly minted one ({@link FoundStrandResult}).
|
|
3855
|
+
* @throws if the node is not started, the id is blank, a published row of the same id has
|
|
3856
|
+
* a different `Type`, or either half rejects.
|
|
3857
|
+
*/
|
|
3858
|
+
async foundStrand(config) {
|
|
3859
|
+
const { strandId, type = 'o', memberPrivateKey, sAppConfig } = config;
|
|
3860
|
+
if (!this._running || !this.controlDatabase) {
|
|
3861
|
+
throw new Error(`CadreNode must be started before attempting to found strand ${strandId}`);
|
|
3862
|
+
}
|
|
3863
|
+
const trimmed = requireNonBlank(strandId, 'strand id');
|
|
3864
|
+
const published = await this.controlDatabase.queryStrand(trimmed);
|
|
3865
|
+
const strandRow = published
|
|
3866
|
+
? this.adoptPublishedStrand(published, type, memberPrivateKey)
|
|
3867
|
+
: await this.publishStrand(trimmed, type, memberPrivateKey);
|
|
3868
|
+
// Derived, not hardcoded `true`: adopting a row another machine published means that
|
|
3869
|
+
// machine runs the bootstrap — this one must attach or it would write a second Header.
|
|
3870
|
+
const founded = this.isSelfFoundedRow(strandRow);
|
|
3871
|
+
if (!founded) {
|
|
3872
|
+
log('foundStrand(%s): the stored row was published by a different machine ' +
|
|
3873
|
+
'(FounderOwnerKey is not this node\'s owner key) — attaching as a joiner; ' +
|
|
3874
|
+
'the founder bootstrap runs on the publishing machine', trimmed);
|
|
3875
|
+
}
|
|
3876
|
+
const instance = await this.addStrand({ strandRow, sAppConfig, founder: founded });
|
|
3877
|
+
return { instance, strandRow, founded };
|
|
3878
|
+
}
|
|
3879
|
+
/**
|
|
3880
|
+
* Resume onto an already-published `Strand` row: keep its stored content, reject a type
|
|
3881
|
+
* disagreement. The stored `MemberPrivateKey` is authoritative — see {@link foundStrand}
|
|
3882
|
+
* for why a caller-minted key must lose here (and why that is NOT the same rule as
|
|
3883
|
+
* {@link publishStrand}'s, which throws on a key mismatch because it is being asked to
|
|
3884
|
+
* WRITE that key, not to resume onto what already exists).
|
|
3885
|
+
*/
|
|
3886
|
+
adoptPublishedStrand(published, type, memberPrivateKey) {
|
|
3887
|
+
if (published.Type !== type) {
|
|
3888
|
+
throw new Error(`Cannot found strand ${published.Id} as type '${type}': it is already published as ` +
|
|
3889
|
+
`type '${published.Type}'. Reconcile deliberately rather than re-founding — a ` +
|
|
3890
|
+
'published open strand cannot be closed, nor a closed one opened, by re-publishing.');
|
|
3891
|
+
}
|
|
3892
|
+
if (memberPrivateKey !== undefined && memberPrivateKey !== published.MemberPrivateKey) {
|
|
3893
|
+
log('foundStrand(%s): already published — resuming on the STORED MemberPrivateKey; ' +
|
|
3894
|
+
'the supplied key is discarded (read it back from the returned instance)', published.Id);
|
|
3895
|
+
}
|
|
3896
|
+
else {
|
|
3897
|
+
log('foundStrand(%s): already published (type %s) — adopting the stored row', published.Id, type);
|
|
3898
|
+
}
|
|
3899
|
+
return published;
|
|
3353
3900
|
}
|
|
3354
3901
|
/**
|
|
3355
3902
|
* Remove this party's `Strand` row from the shared control database — the owner-signed
|
|
@@ -3554,19 +4101,49 @@ export class CadreNode {
|
|
|
3554
4101
|
* seed, starts the strand, and registers it with the hibernation manager
|
|
3555
4102
|
* before emitting `strand:started`.
|
|
3556
4103
|
*
|
|
3557
|
-
*
|
|
3558
|
-
*
|
|
3559
|
-
*
|
|
3560
|
-
*
|
|
3561
|
-
*
|
|
4104
|
+
* Founder-ness: an explicit `founder` argument wins (the formation/responder flows pass
|
|
4105
|
+
* one deliberately — their consent-seated rows carry a null `FounderOwnerKey`); when
|
|
4106
|
+
* unset it is DERIVED from the row, so the control-discovered path
|
|
4107
|
+
* (`handleStrandAdded`) and a restart's re-attach found this machine's own strands
|
|
4108
|
+
* without the caller having to know. The derivation is pure key comparison — no I/O.
|
|
4109
|
+
*
|
|
4110
|
+
* Idempotent when the strand manager already tracks `strand.Id` — the watcher
|
|
4111
|
+
* rediscovers a row this node already started and calls this again via
|
|
4112
|
+
* `handleStrandAdded`; without the guard that re-entry would resolve a fresh
|
|
3562
4113
|
* (already-connected) cohort seed and re-emit `strand:started` for an
|
|
3563
4114
|
* instance that never stopped. The guard runs before the cohort-seed RPC
|
|
3564
|
-
* fan-out, so a rediscovery costs nothing beyond the map lookup
|
|
4115
|
+
* fan-out, so a rediscovery costs nothing beyond the map lookup plus the (pure)
|
|
4116
|
+
* founder derivation. NOT a silent no-op any more when the launch resolves as a
|
|
4117
|
+
* FOUNDER: the tracked instance may have been launched first as a joiner (an app
|
|
4118
|
+
* attach, or this node's own watcher poll winning the `resolveCohortSeed` window
|
|
4119
|
+
* below), which used to drop the founder request and leave the strand headerless —
|
|
4120
|
+
* now the tracked instance is founded in place
|
|
4121
|
+
* ({@link StrandInstanceManager.foundExistingStrand}), waking it first if quiesced
|
|
4122
|
+
* so the bootstrap actually runs before this resolves.
|
|
3565
4123
|
*/
|
|
3566
4124
|
async launchStrand(strand, sAppConfig, founder) {
|
|
4125
|
+
const resolvedFounder = founder ?? this.isSelfFoundedRow(strand);
|
|
3567
4126
|
const existing = this.strandManager.getInstance(strand.Id);
|
|
3568
4127
|
if (existing) {
|
|
3569
|
-
|
|
4128
|
+
if (resolvedFounder) {
|
|
4129
|
+
const outcome = await this.strandManager.foundExistingStrand(strand.Id);
|
|
4130
|
+
if (outcome === 'needs-resume') {
|
|
4131
|
+
// Quiesced instance: the retained config now founds, but founding promises
|
|
4132
|
+
// the bootstrap has RUN by the time the caller resolves — wake through the
|
|
4133
|
+
// hibernation manager (coalesced with any in-flight wake, timer-aware) so
|
|
4134
|
+
// the rebuild executes it now rather than at some eventual wake.
|
|
4135
|
+
await this.wakeStrand(strand.Id);
|
|
4136
|
+
// The wake's rebuild founds — UNLESS a wake was already in flight when the
|
|
4137
|
+
// config flipped, in which case it had already read the pre-flip config and
|
|
4138
|
+
// rebuilt as a joiner, and `wakeStrand` merely coalesced onto it. Re-run the
|
|
4139
|
+
// (insert-if-absent) bootstrap so founding never resolves headerless.
|
|
4140
|
+
await this.strandManager.ensureFounderBootstrap(strand.Id);
|
|
4141
|
+
}
|
|
4142
|
+
log('launchStrand: strand %s already tracked — founder request honored (%s)', strand.Id, outcome);
|
|
4143
|
+
}
|
|
4144
|
+
else {
|
|
4145
|
+
log('launchStrand: strand %s already tracked locally — skipping re-launch', strand.Id);
|
|
4146
|
+
}
|
|
3570
4147
|
return existing;
|
|
3571
4148
|
}
|
|
3572
4149
|
// Each strand node gets its own transport identity, derived from the cadre
|
|
@@ -3604,8 +4181,18 @@ export class CadreNode {
|
|
|
3604
4181
|
bootstrapNodes,
|
|
3605
4182
|
requireSignedSchemas: this.config.requireSignedSchemas,
|
|
3606
4183
|
clusterSize: this.config.strandClusterSize,
|
|
4184
|
+
// NOTE: deliberately NO `servingMachines`, so the strand node declares no repair
|
|
4185
|
+
// yardstick and runs the frozen STRAND_CLUSTER_POLICY. The only count this node holds
|
|
4186
|
+
// is the party's enrolled machines (`authorizedControlPeers`), and passing that here
|
|
4187
|
+
// is the regression `bug-strand-yardstick-counts-party-machines` removed — a strand
|
|
4188
|
+
// runs on a subset of the party, and over-declaring makes repair impossible rather
|
|
4189
|
+
// than merely weak. The full argument, and the count that will legitimately go here,
|
|
4190
|
+
// are on `StartStrandConfig.servingMachines`.
|
|
3607
4191
|
backfill: this.config.strandBackfill,
|
|
3608
|
-
|
|
4192
|
+
revocationEnforcement: this.config.strandRevocationEnforcement,
|
|
4193
|
+
onSelfRevoked: (revokedStrandId) => this.emit('strand:revoked', { strandId: revokedStrandId }),
|
|
4194
|
+
// The RESOLVED flag, never the raw argument — see the doc comment above.
|
|
4195
|
+
founder: resolvedFounder
|
|
3609
4196
|
});
|
|
3610
4197
|
// The seed reached the node as `bootstrapNodes`, which only enters the
|
|
3611
4198
|
// address book via @libp2p/bootstrap discovery. Merge it directly too, so a
|
|
@@ -3645,6 +4232,20 @@ export class CadreNode {
|
|
|
3645
4232
|
* for a relay we are not yet connected to.
|
|
3646
4233
|
*/
|
|
3647
4234
|
async resolveCohortSeed(strandId, delegatePeerId) {
|
|
4235
|
+
const siblings = await this.resolveSiblingSeed(strandId, delegatePeerId);
|
|
4236
|
+
// Sibling answers FIRST: they were resolved just now, while a contact-map entry is as
|
|
4237
|
+
// old as the formation that produced it. `unionAddrs` appends only what the siblings
|
|
4238
|
+
// did not already name, so a fresher answer is never displaced and each source keeps
|
|
4239
|
+
// its own signaling-first ordering.
|
|
4240
|
+
return unionAddrs(siblings, this.crossPartyStrandAddrs.get(strandId) ?? []);
|
|
4241
|
+
}
|
|
4242
|
+
/**
|
|
4243
|
+
* The own-party half of {@link resolveCohortSeed}: strand-network addresses resolved
|
|
4244
|
+
* from CONNECTED cohort siblings over the control-mesh strand-addr RPC, doubling as
|
|
4245
|
+
* the delegate announcement when `delegatePeerId` is given. Empty when the control DB
|
|
4246
|
+
* or node is absent (not yet started / torn down).
|
|
4247
|
+
*/
|
|
4248
|
+
async resolveSiblingSeed(strandId, delegatePeerId) {
|
|
3648
4249
|
if (!this.controlDatabase || !this.controlNode) {
|
|
3649
4250
|
return [];
|
|
3650
4251
|
}
|
|
@@ -3851,35 +4452,41 @@ export class CadreNode {
|
|
|
3851
4452
|
if (due.length === 0) {
|
|
3852
4453
|
return;
|
|
3853
4454
|
}
|
|
3854
|
-
// A due strand with nobody to ask leaves its throttle unstamped (see below),
|
|
3855
|
-
// so this pass re-enters on EVERY 15 s reconcile tick for as long as the node
|
|
3856
|
-
// is alone — which is the steady state of a solo node running a strand.
|
|
3857
4455
|
// `connectedSiblingTargets`' membership read is unbounded, and with zero
|
|
3858
|
-
// connections its answer is empty whatever the table holds, so decide it
|
|
3859
|
-
//
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
4456
|
+
// connections its answer is empty whatever the table holds, so decide it from the
|
|
4457
|
+
// connection list instead of paying for the read once a tick. Note this only skips
|
|
4458
|
+
// the RPC: a strand carrying cross-party contact addresses still refreshes below,
|
|
4459
|
+
// because those never came from a sibling in the first place.
|
|
4460
|
+
//
|
|
3863
4461
|
// NOTE: one strand-addr RPC per (due strand × connected sibling) per refresh
|
|
3864
4462
|
// interval — each a tiny request/response on an already-open control
|
|
3865
4463
|
// connection. If a node ever runs MANY strands at once, batch the RPC to
|
|
3866
4464
|
// carry several strand ids per request rather than one fan-out per strand.
|
|
3867
|
-
const targets =
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
//
|
|
3874
|
-
if (
|
|
4465
|
+
const targets = this.controlNode.getConnections().length === 0
|
|
4466
|
+
? []
|
|
4467
|
+
: await this.connectedSiblingTargets().catch((error) => {
|
|
4468
|
+
log('refreshStrandPeerAddrs: sibling enumeration failed (skipping pass): %o', error);
|
|
4469
|
+
return [];
|
|
4470
|
+
});
|
|
4471
|
+
// A shutdown landed mid-enumeration.
|
|
4472
|
+
if (!this._running || !this.controlNode) {
|
|
3875
4473
|
return;
|
|
3876
4474
|
}
|
|
3877
4475
|
await Promise.all(due.map((strandId) => this.refreshOneStrandPeerAddrs(strandId, targets, now)));
|
|
3878
4476
|
}
|
|
3879
4477
|
/**
|
|
3880
|
-
* One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings,
|
|
3881
|
-
*
|
|
3882
|
-
* swallowed so one strand's failure never
|
|
4478
|
+
* One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, union their
|
|
4479
|
+
* answers with this strand's cross-party contact addresses, and merge the lot into the
|
|
4480
|
+
* strand's address book. Errors are logged and swallowed so one strand's failure never
|
|
4481
|
+
* costs the others their refresh.
|
|
4482
|
+
*
|
|
4483
|
+
* `targets` may be EMPTY — a solo node, or one whose only control peers are strangers.
|
|
4484
|
+
* The cross-party addresses still have to be re-merged in that case: they were learned
|
|
4485
|
+
* at formation and can never be re-resolved, so this periodic re-merge is the ONLY
|
|
4486
|
+
* thing standing between them and the peerStore's one-hour expiry. A strand with
|
|
4487
|
+
* neither a sibling to ask nor a contact entry does nothing and leaves its throttle
|
|
4488
|
+
* unstamped, so the next reconcile tick retries rather than sitting out the whole
|
|
4489
|
+
* refresh interval having done nothing.
|
|
3883
4490
|
*/
|
|
3884
4491
|
async refreshOneStrandPeerAddrs(strandId, targets, now) {
|
|
3885
4492
|
const controlNode = this.controlNode;
|
|
@@ -3887,13 +4494,19 @@ export class CadreNode {
|
|
|
3887
4494
|
if (!controlNode || !strandNode) {
|
|
3888
4495
|
return;
|
|
3889
4496
|
}
|
|
4497
|
+
const contactAddrs = this.crossPartyStrandAddrs.get(strandId) ?? [];
|
|
4498
|
+
if (targets.length === 0 && contactAddrs.length === 0) {
|
|
4499
|
+
return;
|
|
4500
|
+
}
|
|
3890
4501
|
try {
|
|
3891
4502
|
// The running strand node's own peerId is the delegate to announce — see
|
|
3892
4503
|
// the relationship note on refreshStrandPeerAddrs.
|
|
3893
|
-
const
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
4504
|
+
const siblingAddrs = targets.length === 0
|
|
4505
|
+
? []
|
|
4506
|
+
: await collectStrandAddrs(controlNode, [...targets], strandId, {
|
|
4507
|
+
delegatePeerId: strandNode.peerId.toString()
|
|
4508
|
+
});
|
|
4509
|
+
// Stamped on the pass having happened, not on its answer: the fan-out is
|
|
3897
4510
|
// what the throttle exists to bound, and an all-empty round is a normal
|
|
3898
4511
|
// steady state for a strand no connected sibling currently runs.
|
|
3899
4512
|
this.strandPeerAddrRefreshAt.set(strandId, now);
|
|
@@ -3902,7 +4515,7 @@ export class CadreNode {
|
|
|
3902
4515
|
if (!this._running || this.strandManager.getInstance(strandId)?.libp2pNode !== strandNode) {
|
|
3903
4516
|
return;
|
|
3904
4517
|
}
|
|
3905
|
-
await this.mergeStrandPeerAddrs(strandNode,
|
|
4518
|
+
await this.mergeStrandPeerAddrs(strandNode, unionAddrs(siblingAddrs, contactAddrs), strandId);
|
|
3906
4519
|
}
|
|
3907
4520
|
catch (error) {
|
|
3908
4521
|
log('refreshStrandPeerAddrs: strand %s refresh failed (continuing): %o', strandId, error);
|
|
@@ -3926,6 +4539,15 @@ export class CadreNode {
|
|
|
3926
4539
|
* handshake, and a bad entry costs one failed dial that ages out at the
|
|
3927
4540
|
* peerStore's one-hour expiry. No new gating here — cross-party strand trust is
|
|
3928
4541
|
* `backlog/strand-network-nat-relay-reachability`.
|
|
4542
|
+
*
|
|
4543
|
+
* NOTE: one input does NOT age out — the cross-party addresses a formation
|
|
4544
|
+
* carried back ({@link crossPartyStrandAddrs}). Nothing can re-resolve them, so
|
|
4545
|
+
* {@link refreshStrandPeerAddrs} re-merges the same stored list every pass, which
|
|
4546
|
+
* also means a junk entry from a responder survives for the node's lifetime rather
|
|
4547
|
+
* than an hour. Bounded to {@link MAX_STRAND_ADDRS} per strand, from a party this
|
|
4548
|
+
* node deliberately formed a strand with, and still authority-free — so the
|
|
4549
|
+
* exposure is a handful of failed dials, not a trust hole. Evict on
|
|
4550
|
+
* `unpublishStrand` if that ever stops being an acceptable price.
|
|
3929
4551
|
*/
|
|
3930
4552
|
async mergeStrandPeerAddrs(strandNode, addrs, strandId) {
|
|
3931
4553
|
const counts = { merged: 0, restamped: 0, skipped: 0, failed: 0 };
|
|
@@ -3967,7 +4589,29 @@ export class CadreNode {
|
|
|
3967
4589
|
if (!node) {
|
|
3968
4590
|
return [];
|
|
3969
4591
|
}
|
|
3970
|
-
|
|
4592
|
+
// Every entry is bound to THIS strand node's id, because both consumers — the
|
|
4593
|
+
// strand-addr RPC answer and the formation result's `strandAddrs` — are attributed
|
|
4594
|
+
// per peer on arrival (`groupAddrsByPeerId`) and an entry naming no destination is
|
|
4595
|
+
// dropped there. libp2p already appends the id to its announced addresses, so this
|
|
4596
|
+
// is normally a no-op; it is the guard for a bare listen addr and for a relay hop
|
|
4597
|
+
// whose trailing `/p2p/` names the RELAY. An addr terminating in a DIFFERENT id is
|
|
4598
|
+
// dropped rather than announced — it does not reach this node, and announcing it
|
|
4599
|
+
// would file our address under someone else's id in the receiver's book.
|
|
4600
|
+
//
|
|
4601
|
+
// Re-parsed through this package's own `multiaddr` rather than passed straight in:
|
|
4602
|
+
// libp2p hands back its nested `@multiformats/multiaddr` copy, a structurally
|
|
4603
|
+
// different type from the one `withTrailingPeerId` takes (see
|
|
4604
|
+
// `backlog/reference-app-web-libp2p-interface-dedup`). Re-parsing a string libp2p
|
|
4605
|
+
// itself produced cannot fail.
|
|
4606
|
+
const selfId = node.peerId.toString();
|
|
4607
|
+
const addrs = [];
|
|
4608
|
+
for (const ma of node.getMultiaddrs()) {
|
|
4609
|
+
const bound = this.bindAddrToPeer(multiaddr(ma.toString()), selfId);
|
|
4610
|
+
if (bound !== null) {
|
|
4611
|
+
addrs.push(bound.toString());
|
|
4612
|
+
}
|
|
4613
|
+
}
|
|
4614
|
+
return orderSignalingFirst(addrs);
|
|
3971
4615
|
}
|
|
3972
4616
|
/**
|
|
3973
4617
|
* Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
|
|
@@ -4670,6 +5314,25 @@ export class CadreNode {
|
|
|
4670
5314
|
await this.membershipGateDrain;
|
|
4671
5315
|
}
|
|
4672
5316
|
}
|
|
5317
|
+
/**
|
|
5318
|
+
* Re-materialize a CLOSED strand's revoked-peer deny set now, and hang up any
|
|
5319
|
+
* connected peer it newly covers — the strand-side counterpart to
|
|
5320
|
+
* {@link refreshMembershipGate}.
|
|
5321
|
+
*
|
|
5322
|
+
* Unlike that one, NOTHING calls this automatically: strand membership is
|
|
5323
|
+
* written through the strand's own `Database` handle
|
|
5324
|
+
* (`strand-membership-writer.ts`), which raises no notification this runtime
|
|
5325
|
+
* can hook, and a revocation that arrives by REPLICATION raises none either.
|
|
5326
|
+
* The gate therefore polls (default 30 s). An app that has just called
|
|
5327
|
+
* `revokeMember` or `leaveStrand` should follow the write with this call so
|
|
5328
|
+
* the cut is immediate rather than up to one poll interval late.
|
|
5329
|
+
*
|
|
5330
|
+
* Quiet no-op for a strand that is not running, is quiesced, is open, or has
|
|
5331
|
+
* the gate disabled. Never rejects; resolves once the sweep has finished.
|
|
5332
|
+
*/
|
|
5333
|
+
async refreshRevocationEnforcement(strandId) {
|
|
5334
|
+
await this.strandManager.refreshRevocationEnforcement(strandId);
|
|
5335
|
+
}
|
|
4673
5336
|
/**
|
|
4674
5337
|
* The single in-flight refresh loop behind {@link refreshMembershipGate}: read
|
|
4675
5338
|
* until the stale flag stays clear, then release the slot.
|
|
@@ -4981,7 +5644,11 @@ export class CadreNode {
|
|
|
4981
5644
|
this.strandSolicitationService = new StrandSolicitationService({
|
|
4982
5645
|
...options,
|
|
4983
5646
|
partyId: this.config.controlNetwork.partyId,
|
|
4984
|
-
cadrePeerAddrs: this.getMultiaddrs()
|
|
5647
|
+
cadrePeerAddrs: this.getMultiaddrs(),
|
|
5648
|
+
// Overrides any caller-supplied hook, exactly like partyId/cadrePeerAddrs: only
|
|
5649
|
+
// this node can say which strand-network addresses it is actually listening on,
|
|
5650
|
+
// and a wrong answer here seeds a joiner's mesh with addresses that reach nobody.
|
|
5651
|
+
resolveStrandAddrs: (strandId) => this.getStrandMultiaddrs(strandId)
|
|
4985
5652
|
});
|
|
4986
5653
|
// Register as responder on the control node
|
|
4987
5654
|
this.strandSolicitationService.registerResponder(this.controlNode);
|
|
@@ -5026,7 +5693,41 @@ export class CadreNode {
|
|
|
5026
5693
|
if (!this.strandSolicitationService) {
|
|
5027
5694
|
this.initializeStrandSolicitation();
|
|
5028
5695
|
}
|
|
5029
|
-
|
|
5696
|
+
const result = await this.strandSolicitationService.formStrand(invitation, disclosure, this.controlNode);
|
|
5697
|
+
this.recordCrossPartyStrandAddrs(result.strandId, result.strandAddrs);
|
|
5698
|
+
return result;
|
|
5699
|
+
}
|
|
5700
|
+
/**
|
|
5701
|
+
* Remember the responder's strand-network addresses for a strand this node just
|
|
5702
|
+
* formed, so the strand's discovery seed has something to work with when the app
|
|
5703
|
+
* launches it (see {@link crossPartyStrandAddrs}).
|
|
5704
|
+
*
|
|
5705
|
+
* Scoped strictly to `strandId`: these addresses reach ONE strand node of ONE other
|
|
5706
|
+
* party and must never seed another strand's mesh or the control peerStore. An empty
|
|
5707
|
+
* list is recorded as no entry at all rather than an empty one, so a later formation
|
|
5708
|
+
* against the same strand that DOES disclose addresses is not shadowed.
|
|
5709
|
+
*/
|
|
5710
|
+
recordCrossPartyStrandAddrs(strandId, strandAddrs) {
|
|
5711
|
+
if (strandAddrs.length === 0) {
|
|
5712
|
+
log('formStrand: responder disclosed no strand addrs for %s — cross-party seed stays empty', strandId);
|
|
5713
|
+
return;
|
|
5714
|
+
}
|
|
5715
|
+
// Capped at the same `MAX_STRAND_ADDRS` one arriving list is, because this
|
|
5716
|
+
// ACCUMULATES: re-forming against the same strand (the documented recovery for a
|
|
5717
|
+
// responder whose relay reservation rotated) unions a fresh list onto the old one,
|
|
5718
|
+
// and nothing here can tell a dead entry from a live one. Without the cap, the seed
|
|
5719
|
+
// — and the peerStore rows every refresh pass re-merges from it — would grow by up
|
|
5720
|
+
// to 16 entries per redemption for the node's lifetime. Oldest entries fall off the
|
|
5721
|
+
// end, which is the right end to lose: `unionAddrs` keeps the freshest list first.
|
|
5722
|
+
const merged = unionAddrs(strandAddrs, this.crossPartyStrandAddrs.get(strandId) ?? [])
|
|
5723
|
+
.slice(0, MAX_STRAND_ADDRS);
|
|
5724
|
+
this.crossPartyStrandAddrs.set(strandId, merged);
|
|
5725
|
+
// A strand already running when this lands (a re-formation) would otherwise wait out
|
|
5726
|
+
// the rest of its ~10-minute refresh throttle before the new addresses reached its
|
|
5727
|
+
// address book. Clearing the stamp makes the next 15 s reconcile tick merge them,
|
|
5728
|
+
// which matters precisely because re-forming is the recovery path for a dead entry.
|
|
5729
|
+
this.strandPeerAddrRefreshAt.delete(strandId);
|
|
5730
|
+
log('formStrand: recorded %d cross-party strand addr(s) for %s', merged.length, strandId);
|
|
5030
5731
|
}
|
|
5031
5732
|
/**
|
|
5032
5733
|
* Encode an open invitation for out-of-band delivery (QR, link, etc.).
|