@serfab/cadre-core 1.6.0 → 1.7.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/dist/cadre-node.d.ts +204 -65
- package/dist/cadre-node.js +392 -95
- package/dist/cadre-node.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +112 -0
- package/dist/joined-strand-store.js +263 -0
- package/dist/joined-strand-store.js.map +1 -0
- package/dist/key-store.d.ts +6 -0
- package/dist/key-store.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-join-backfill.d.ts +33 -14
- package/dist/peer-join-backfill.js +109 -50
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +39 -0
- package/dist/strand-instance-manager.js +74 -1
- package/dist/strand-instance-manager.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 +159 -0
- package/dist/strand-peer-book-protocol.js +360 -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 +354 -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/types.d.ts +58 -0
- package/dist/types.js.map +1 -1
- package/package.json +6 -2
- package/src/cadre-node.ts +7776 -7449
- package/src/index.ts +67 -0
- package/src/joined-strand-store.ts +308 -0
- package/src/key-store.ts +6 -0
- package/src/node-local-snapshot.ts +293 -289
- package/src/peer-join-backfill.ts +112 -51
- package/src/strand-instance-manager.ts +100 -1
- package/src/strand-peer-book-file.ts +39 -0
- package/src/strand-peer-book-protocol.ts +463 -0
- package/src/strand-peer-book-swap.ts +338 -0
- package/src/strand-peer-book.ts +468 -0
- package/src/strand-peer-observer.ts +235 -0
- package/src/types.ts +60 -0
package/dist/cadre-node.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
2
|
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
3
3
|
import { peerIdFromString, peerIdFromPrivateKey } from '@libp2p/peer-id';
|
|
4
|
+
import { generateKeyPair } from '@libp2p/crypto/keys';
|
|
4
5
|
import { createLibp2pNode } from '@optimystic/db-p2p';
|
|
5
6
|
import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
|
|
6
7
|
import { multiaddr } from '@multiformats/multiaddr';
|
|
@@ -16,7 +17,9 @@ import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
|
|
|
16
17
|
import { loadOrCreateIdentityKey } from './identity-key.js';
|
|
17
18
|
import { MemoryTrustedOwnerStore } from './trusted-owner-store.js';
|
|
18
19
|
import { MemoryBootstrapPeerStore } from './bootstrap-peer-store.js';
|
|
20
|
+
import { MemoryStrandPeerBookStore } from './strand-peer-book.js';
|
|
19
21
|
import { MemoryEnrolledMachineStore } from './enrolled-machine-store.js';
|
|
22
|
+
import { JoinedStrandRows, KeyStoreJoinedStrandStore, MemoryJoinedStrandStore } from './joined-strand-store.js';
|
|
20
23
|
import { mergePeerAddrs, groupAddrsByPeerId } from './peer-addr-book.js';
|
|
21
24
|
import { verifyCadrePeerVoucher } from './peer-authorization.js';
|
|
22
25
|
import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
|
|
@@ -34,7 +37,6 @@ import { HibernationManager } from './hibernation-manager.js';
|
|
|
34
37
|
import { ControlDatabase, isStrandIdConflict } from './control-database.js';
|
|
35
38
|
import { SeedBootstrapService } from './seed-bootstrap.js';
|
|
36
39
|
import { StrandSolicitationService } from './strand-solicitation.js';
|
|
37
|
-
import { MAX_STRAND_ADDRS } from './strand-formation-protocol.js';
|
|
38
40
|
import { createMembershipConnectionGater, DEFAULT_ENROLLMENT_WINDOW_MS, UnauthorizedReservationBudget } from './membership-connection-gater.js';
|
|
39
41
|
import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
|
|
40
42
|
import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
|
|
@@ -78,7 +80,7 @@ function relayStrandAddrPeer(relay) {
|
|
|
78
80
|
* and order-preserving.
|
|
79
81
|
*
|
|
80
82
|
* Used wherever a strand's discovery seed unions two sources of address strings — the
|
|
81
|
-
* freshly-RPC'd sibling answers and the
|
|
83
|
+
* freshly-RPC'd sibling answers and the strand peer book's last-known addresses. The
|
|
82
84
|
* caller passes the FRESHER source as `primary`, so a stale entry can only ever be
|
|
83
85
|
* appended, never promoted ahead of a current one, and each source keeps whatever
|
|
84
86
|
* signaling-first ordering it arrived with.
|
|
@@ -373,37 +375,36 @@ export class CadreNode {
|
|
|
373
375
|
*/
|
|
374
376
|
this.strandPeerAddrRefreshAt = new Map();
|
|
375
377
|
/**
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
378
|
+
* Node-local strand peer book (see `strand-peer-book.ts`): per strand, the strand
|
|
379
|
+
* peers this node has met and their last-known strand-network addresses. Three
|
|
380
|
+
* writers: {@link recordFormationStrandPeers} files the responder's live strand
|
|
381
|
+
* addrs a formation result carried back (`FormationResultMessage.strandAddrs`),
|
|
382
|
+
* {@link observeStrandPeer} files every strand peer a running strand node
|
|
383
|
+
* identifies, and the per-strand book swap (`strand-peer-book-swap.ts`, handed the
|
|
384
|
+
* store as `StartStrandConfig.strandPeerBook`) files the node's own signed entry
|
|
385
|
+
* plus every signed entry a strand peer sends it — the only writer whose entries
|
|
386
|
+
* carry a signature, and the only one that can refresh a peer's addresses without
|
|
387
|
+
* meeting that peer. Read by {@link resolveCohortSeed} (launch + hibernation resume) and
|
|
388
|
+
* re-merged by every {@link refreshStrandPeerAddrs} pass, which is what keeps the
|
|
389
|
+
* entries alive past the peerStore's one-hour address expiry.
|
|
379
390
|
*
|
|
380
391
|
* This is the ONLY cross-party discovery input there is. The strand-addr RPC that
|
|
381
392
|
* resolves a strand's addresses is membership-gated and answers own-party siblings
|
|
382
|
-
* only, so without
|
|
383
|
-
* the mesh never forms
|
|
393
|
+
* only, so without the book a joiner's cohort seed for a two-party strand is empty
|
|
394
|
+
* and the mesh never forms — and, with an in-memory book, never RE-forms after a
|
|
395
|
+
* restart (gotchoices/sereus#18). A STORE rather than a map so an embedder can make
|
|
396
|
+
* it outlive the process; constructed (or adopted from `config.strandPeers.store`)
|
|
397
|
+
* by {@link initializeStrandPeerBookStore} during {@link start}, deliberately NOT
|
|
398
|
+
* cleared by {@link cleanup}, so it survives a stop()→start() cycle of the same node
|
|
399
|
+
* instance — same lifecycle as {@link bootstrapPeerStore}.
|
|
384
400
|
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* IN-MEMORY and ONE-SHOT, deliberately (see `docs/strands.md`): the addresses die with
|
|
391
|
-
* the process, and a restarted initiator with no sibling running the strand is back to
|
|
392
|
-
* an empty seed until the durability work lands
|
|
393
|
-
* (`backlog/feat-cross-party-strand-addr-durability`). They are also never refreshed,
|
|
394
|
-
* so a responder whose relay reservation rotates before the initiator dials leaves a
|
|
395
|
-
* dead entry behind; recovery today is a fresh invitation.
|
|
396
|
-
*
|
|
397
|
-
* NOTE: entries are kept for the node's lifetime, including across a
|
|
398
|
-
* {@link stopStrand} — a stopped strand may be claimed again with {@link addStrand}
|
|
399
|
-
* (the watcher itself will not re-offer it: the stop suppresses the id for the rest of
|
|
400
|
-
* the session) and must still get its seed. Each strand's list is capped at
|
|
401
|
-
* `MAX_STRAND_ADDRS`, but the
|
|
402
|
-
* number of KEYS is bounded only by the strands this node has ever formed as an
|
|
403
|
-
* initiator (not by time); if a node ever forms strands at scale, evict on
|
|
404
|
-
* `unpublishStrand` or cap the map.
|
|
401
|
+
* Entries survive a {@link stopStrand} (a stopped strand may be claimed again with
|
|
402
|
+
* {@link addStrand}, and is rediscovered on restart) and are forgotten by
|
|
403
|
+
* {@link unpublishStrand}, {@link forgetJoinedStrand} and self-revocation; the store
|
|
404
|
+
* ages the rest out after 14 days and caps each strand at 16 peers, so the book is
|
|
405
|
+
* bounded by time and by the strands this node runs rather than by its history.
|
|
405
406
|
*/
|
|
406
|
-
this.
|
|
407
|
+
this.strandPeerBookStore = null;
|
|
407
408
|
/**
|
|
408
409
|
* PENDING strand membership invitations learned at formation, keyed by strandId — the
|
|
409
410
|
* single-use `Strand.Invite` credential a closed-strand formation result carried back
|
|
@@ -411,10 +412,9 @@ export class CadreNode {
|
|
|
411
412
|
* to redeem (`consumeInvite` seats the `Strand.Member` row under this party's own key
|
|
412
413
|
* — the `strand-node-binds-member-peer` half of the party-identity chain).
|
|
413
414
|
*
|
|
414
|
-
* IN-MEMORY, deliberately
|
|
415
|
-
* re-forms from scratch
|
|
416
|
-
*
|
|
417
|
-
* a fresh invitation. A re-formation against the same strand REPLACES the entry — the
|
|
415
|
+
* IN-MEMORY, deliberately: an invitation is single-use and short-lived, a restarted
|
|
416
|
+
* joiner that never redeemed it re-forms from scratch, and re-forming issues a fresh
|
|
417
|
+
* one. A re-formation against the same strand REPLACES the entry — the
|
|
418
418
|
* fresh invitation supersedes one that may have expired. The party key the invitation
|
|
419
419
|
* admits, by contrast, IS persisted (`StrandPartyKey`, seated by
|
|
420
420
|
* {@link adoptFormationMembershipInvite} before the entry lands here), so a lost
|
|
@@ -430,8 +430,9 @@ export class CadreNode {
|
|
|
430
430
|
* just waits; once launched, staging notifies the manager, which re-arms a finished loop.
|
|
431
431
|
*
|
|
432
432
|
* NOTE: entries live for the node's lifetime (one small pair per formed closed
|
|
433
|
-
* strand)
|
|
434
|
-
* if a node ever forms strands at scale, evict
|
|
433
|
+
* strand); the number of keys is bounded only by the strands this node has ever
|
|
434
|
+
* formed, not by time — if a node ever forms strands at scale, evict on
|
|
435
|
+
* `unpublishStrand` / `forgetJoinedStrand` as the strand peer book does.
|
|
435
436
|
*/
|
|
436
437
|
this.pendingMembershipInvites = new Map();
|
|
437
438
|
/**
|
|
@@ -502,6 +503,23 @@ export class CadreNode {
|
|
|
502
503
|
* so it survives a stop()→start() cycle of the same node instance.
|
|
503
504
|
*/
|
|
504
505
|
this.enrolledMachineStore = null;
|
|
506
|
+
/**
|
|
507
|
+
* Node-local record of the strands this node joined from ANOTHER party (see
|
|
508
|
+
* `joined-strand-store.ts`) — the only thing that names them after a restart, since
|
|
509
|
+
* this party's control database holds no row for them. Written by {@link formStrand}
|
|
510
|
+
* and by an {@link addStrand} of a foreign row; forgotten by {@link forgetJoinedStrand}
|
|
511
|
+
* and on self-revocation. Adopted from `config.joinedStrands.store`, else built over
|
|
512
|
+
* `config.keyStore`, else in-memory, by {@link initializeJoinedStrandStore}. Like
|
|
513
|
+
* {@link bootstrapPeerStore} it is deliberately NOT cleared by {@link cleanup}.
|
|
514
|
+
*/
|
|
515
|
+
this.joinedStrandStore = null;
|
|
516
|
+
/**
|
|
517
|
+
* The strand watcher's view of {@link joinedStrandStore}: its records unioned with the
|
|
518
|
+
* control rows each poll. Per SESSION, unlike the store — rebuilt at every
|
|
519
|
+
* {@link start}, which is what ends a revoked join's "keep offering until this session
|
|
520
|
+
* ends" (see `JoinedStrandRows.forgetAfterThisSession`).
|
|
521
|
+
*/
|
|
522
|
+
this.joinedStrandRows = null;
|
|
505
523
|
/** Initial self-registration timer (see {@link scheduleSelfRegistration}). */
|
|
506
524
|
this.selfRegistrationTimer = null;
|
|
507
525
|
/** TTL heartbeat that re-publishes the self record before it goes stale. */
|
|
@@ -788,6 +806,12 @@ export class CadreNode {
|
|
|
788
806
|
// closed before any network bring-up, and the retained dial targets are
|
|
789
807
|
// loaded before the first reconcile pass could consult them.
|
|
790
808
|
this.initializeBootstrapPeerStore();
|
|
809
|
+
// The remembered cross-party joins, which the strand watcher built below polls
|
|
810
|
+
// beside the control rows. Before network bring-up, so a mis-scoped injected store
|
|
811
|
+
// fails closed like the two above.
|
|
812
|
+
this.initializeJoinedStrandStore();
|
|
813
|
+
// The strand peer book those joins are dialed from. Same placement, same reason.
|
|
814
|
+
this.initializeStrandPeerBookStore();
|
|
791
815
|
// Read the party's enrolled-machine count out of its node-local record and
|
|
792
816
|
// capture it for buildControlNodeOptions below. This MUST precede
|
|
793
817
|
// createControlNode: Optimystic freezes the cluster policy when the node is
|
|
@@ -1135,6 +1159,48 @@ export class CadreNode {
|
|
|
1135
1159
|
this.declaredEnrolledMachines = store.count();
|
|
1136
1160
|
log('control repair yardstick will be declared from %o enrolled machine(s)', this.declaredEnrolledMachines);
|
|
1137
1161
|
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Construct (or adopt) the node-local joined-strand store (see
|
|
1164
|
+
* {@link joinedStrandStore}) and this session's watcher view over it.
|
|
1165
|
+
*
|
|
1166
|
+
* The store instance is kept across stop()→start(); the view is rebuilt. A store
|
|
1167
|
+
* scoped to a different party is a configuration error, fail closed: its joins
|
|
1168
|
+
* would be offered to this party's app as if this party had made them.
|
|
1169
|
+
*/
|
|
1170
|
+
initializeJoinedStrandStore() {
|
|
1171
|
+
const partyId = this.config.controlNetwork.partyId;
|
|
1172
|
+
const { keyStore } = this.config;
|
|
1173
|
+
const store = this.joinedStrandStore
|
|
1174
|
+
?? this.config.joinedStrands?.store
|
|
1175
|
+
?? (keyStore ? new KeyStoreJoinedStrandStore(keyStore, partyId) : new MemoryJoinedStrandStore(partyId));
|
|
1176
|
+
if (store.partyId !== partyId) {
|
|
1177
|
+
throw new Error(`CadreNodeConfig: joinedStrands.store is scoped to party ${store.partyId}, ` +
|
|
1178
|
+
`but this node serves party ${partyId} — refusing to offer another party's joined strands`);
|
|
1179
|
+
}
|
|
1180
|
+
this.joinedStrandStore = store;
|
|
1181
|
+
this.joinedStrandRows = new JoinedStrandRows(store);
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Construct (or adopt) the node-local strand peer book (see
|
|
1185
|
+
* {@link strandPeerBookStore}). Synchronous for the reason
|
|
1186
|
+
* {@link initializeBootstrapPeerStore} is: an injected store has already loaded its
|
|
1187
|
+
* persisted entries by the time it is handed in, and the in-memory fallback has
|
|
1188
|
+
* nothing to load. The instance is kept across stop()→start(). A store scoped to a
|
|
1189
|
+
* different party is a configuration error, fail closed: another party's strand
|
|
1190
|
+
* peers must never enter this node's dial loop.
|
|
1191
|
+
*/
|
|
1192
|
+
initializeStrandPeerBookStore() {
|
|
1193
|
+
const partyId = this.config.controlNetwork.partyId;
|
|
1194
|
+
if (this.strandPeerBookStore) {
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
const store = this.config.strandPeers?.store ?? new MemoryStrandPeerBookStore(partyId);
|
|
1198
|
+
if (store.partyId !== partyId) {
|
|
1199
|
+
throw new Error(`CadreNodeConfig: strandPeers.store is scoped to party ${store.partyId}, ` +
|
|
1200
|
+
`but this node serves party ${partyId} — refusing to mix strand peer books`);
|
|
1201
|
+
}
|
|
1202
|
+
this.strandPeerBookStore = store;
|
|
1203
|
+
}
|
|
1138
1204
|
/**
|
|
1139
1205
|
* The node-local enrolled-machine store (null before {@link start}) — what this
|
|
1140
1206
|
* node last knew about its party's size. Exposed for diagnostics and for a host
|
|
@@ -1152,6 +1218,15 @@ export class CadreNode {
|
|
|
1152
1218
|
getBootstrapPeerStore() {
|
|
1153
1219
|
return this.bootstrapPeerStore;
|
|
1154
1220
|
}
|
|
1221
|
+
/**
|
|
1222
|
+
* The node-local strand peer book (null before {@link start}) — per strand, the
|
|
1223
|
+
* strand peers this node dials first on launch, resume and refresh. Exposed for
|
|
1224
|
+
* diagnostics and for a host that wants to show "whom would this strand re-find
|
|
1225
|
+
* after a restart?".
|
|
1226
|
+
*/
|
|
1227
|
+
getStrandPeerBookStore() {
|
|
1228
|
+
return this.strandPeerBookStore;
|
|
1229
|
+
}
|
|
1155
1230
|
/**
|
|
1156
1231
|
* The node-local trusted-owner anchor (null before {@link start}). This is
|
|
1157
1232
|
* the set the authorized-membership predicate and seed-trust anchor consult —
|
|
@@ -1225,10 +1300,10 @@ export class CadreNode {
|
|
|
1225
1300
|
* needs no work of its own: `createLibp2pNode` registers the block-transfer
|
|
1226
1301
|
* handler on every node it builds, control node included, and this node's
|
|
1227
1302
|
* per-stream gate (`authorizeInboundControlStream`) covers the inbound
|
|
1228
|
-
* direction. A denied peer is retried on its next `
|
|
1229
|
-
* because the production join order is
|
|
1230
|
-
* committed membership change via
|
|
1231
|
-
* `scheduleConnectedPeers()` call.
|
|
1303
|
+
* direction. A denied peer is retried on its next `peer:identify` (a
|
|
1304
|
+
* reconnect re-runs identify), and — because the production join order is
|
|
1305
|
+
* connect-then-authorize — on every committed membership change via
|
|
1306
|
+
* {@link refreshAuthorizedControlPeers}'s `scheduleConnectedPeers()` call.
|
|
1232
1307
|
*
|
|
1233
1308
|
* No-ops (logged) when the embedder configured no control storage or the
|
|
1234
1309
|
* node exposes no key network — the backfill would have nothing to read or
|
|
@@ -1823,7 +1898,7 @@ export class CadreNode {
|
|
|
1823
1898
|
// ever holds many such connections, or refreshes get frequent, skip
|
|
1824
1899
|
// re-arming peers whose last pass was denied under the SAME membership
|
|
1825
1900
|
// snapshot instead of re-arming unconditionally.
|
|
1826
|
-
this.controlBackfill?.scheduleConnectedPeers();
|
|
1901
|
+
void this.controlBackfill?.scheduleConnectedPeers();
|
|
1827
1902
|
}
|
|
1828
1903
|
catch (error) {
|
|
1829
1904
|
log('refreshAuthorizedControlPeers(%s) failed — keeping previous snapshot: %o', reason, error);
|
|
@@ -1859,6 +1934,11 @@ export class CadreNode {
|
|
|
1859
1934
|
}
|
|
1860
1935
|
return this.bootstrapPeerIds;
|
|
1861
1936
|
}
|
|
1937
|
+
/**
|
|
1938
|
+
* What the strand watcher polls: this party's control rows plus a row per remembered
|
|
1939
|
+
* cross-party join (see {@link joinedStrandRows}), so a joined strand is re-offered
|
|
1940
|
+
* and relaunched on the same path as the party's own.
|
|
1941
|
+
*/
|
|
1862
1942
|
createStrandQueryable() {
|
|
1863
1943
|
return {
|
|
1864
1944
|
queryStrands: async () => {
|
|
@@ -1867,7 +1947,8 @@ export class CadreNode {
|
|
|
1867
1947
|
return [];
|
|
1868
1948
|
}
|
|
1869
1949
|
log('Querying strands from control database');
|
|
1870
|
-
|
|
1950
|
+
const control = await this.controlDatabase.queryStrands();
|
|
1951
|
+
return this.joinedStrandRows ? await this.joinedStrandRows.withControlRows(control) : control;
|
|
1871
1952
|
}
|
|
1872
1953
|
};
|
|
1873
1954
|
}
|
|
@@ -4149,6 +4230,10 @@ export class CadreNode {
|
|
|
4149
4230
|
* published (e.g. re-attaching its own orphan after a restart) founds it, and attaching
|
|
4150
4231
|
* anyone else's row joins, without the caller needing to know which it is.
|
|
4151
4232
|
*
|
|
4233
|
+
* A joined row (no `founder: true`) this party's control database does not hold — a
|
|
4234
|
+
* strand joined from another party — is remembered in the node's joined-strand store, so it is re-offered as
|
|
4235
|
+
* `strand:discovered` after a restart (see {@link forgetJoinedStrand}).
|
|
4236
|
+
*
|
|
4152
4237
|
* A rejected call leaves nothing running but DOES leave the sApp config
|
|
4153
4238
|
* registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
|
|
4154
4239
|
* automatic relaunch need it. A failed launch here hands the strand back to that
|
|
@@ -4179,6 +4264,12 @@ export class CadreNode {
|
|
|
4179
4264
|
`(Type 'c'), but this row is Type '${strandRow.Type}' — an open strand seats no ` +
|
|
4180
4265
|
'Member/Manager, so the key would be ignored.');
|
|
4181
4266
|
}
|
|
4267
|
+
// A row this node offered itself came from the control table or is already a
|
|
4268
|
+
// remembered join, and a row this node founds is not a join, so only a joined row
|
|
4269
|
+
// from elsewhere can be a new join to remember. A founded row recorded here would
|
|
4270
|
+
// come back after a restart with `FounderOwnerKey: null` and relaunch as a joiner.
|
|
4271
|
+
// Captured before the backlog delete below.
|
|
4272
|
+
const rememberable = founder !== true && !this.discoveredStrands.has(strandRow.Id);
|
|
4182
4273
|
// Store sApp config for this strand. The strand is claimed now, so it leaves the
|
|
4183
4274
|
// unclaimed backlog — a later `getDiscoveredStrands()` drain must not re-offer it.
|
|
4184
4275
|
this.sAppConfigs.set(strandRow.Id, sAppConfig);
|
|
@@ -4187,6 +4278,11 @@ export class CadreNode {
|
|
|
4187
4278
|
// the strand again, or a party-wide removal would never stop it here.
|
|
4188
4279
|
this.strandWatcher?.unsuppressStrand(strandRow.Id);
|
|
4189
4280
|
log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)', strandRow.Id, sAppConfig.id, founder ?? 'derived');
|
|
4281
|
+
// Before the launch, so a foreign strand whose launch fails, or whose app is killed
|
|
4282
|
+
// mid-launch, is still offered again: by the watcher's retry and by the next start.
|
|
4283
|
+
if (rememberable) {
|
|
4284
|
+
await this.rememberForeignStrand(strandRow);
|
|
4285
|
+
}
|
|
4190
4286
|
// An unset `founder` is DERIVED from the row inside launchStrand (this node founds
|
|
4191
4287
|
// iff the row's FounderOwnerKey is its own owner key); an explicit flag wins — the
|
|
4192
4288
|
// formation/responder flows pass one deliberately, since their consent-seated rows
|
|
@@ -4207,7 +4303,8 @@ export class CadreNode {
|
|
|
4207
4303
|
// only delays the watcher's own first attempt by one poll interval, which is what
|
|
4208
4304
|
// should happen right after an attempt that just failed.
|
|
4209
4305
|
//
|
|
4210
|
-
// NOTE: the retry relaunches from the CONTROL row
|
|
4306
|
+
// NOTE: the retry relaunches from the CONTROL row (or, for a strand joined from
|
|
4307
|
+
// another party, the remembered join) plus the registered config, not
|
|
4211
4308
|
// from the row and arguments passed here — so a caller that enriched either (a
|
|
4212
4309
|
// synthetic `MemberPrivateKey`, an explicit `founder` or `partyMemberPrivateKey`)
|
|
4213
4310
|
// is retried with less than it asked for. Inert today: founder-ness re-derives from
|
|
@@ -4543,6 +4640,9 @@ export class CadreNode {
|
|
|
4543
4640
|
// A refused pre-split launch left no tracked instance, so the stop below never
|
|
4544
4641
|
// reaches detachStrand for it — clear the refusal here so a re-founded id starts clean.
|
|
4545
4642
|
this.strandLaunchRefusals.delete(trimmed);
|
|
4643
|
+
// A party-wide removal: the strand's peers must not stay dial targets for a strand
|
|
4644
|
+
// that no longer exists (a re-published id is a fresh strand with fresh peers).
|
|
4645
|
+
this.forgetStrandPeers(trimmed, 'unpublishStrand');
|
|
4546
4646
|
// Clear THIS machine's own MemberPeer binding while the strand runtime — and the
|
|
4547
4647
|
// retained party key — is still live: the delete above already destroyed the
|
|
4548
4648
|
// party's StrandPartyKey row, so once this process forgets the key nothing can
|
|
@@ -4896,18 +4996,25 @@ export class CadreNode {
|
|
|
4896
4996
|
// retained launch config carries this derived key, so hibernate → wake
|
|
4897
4997
|
// (resumeStrand) reuses the same peerId.
|
|
4898
4998
|
//
|
|
4999
|
+
// Without an identity key (a node configured with neither `keyStore` nor
|
|
5000
|
+
// `privateKey` — tests, and an embedder that opted out of a stable identity) the
|
|
5001
|
+
// strand node still runs under a key CADRE-CORE HOLDS: a fresh random Ed25519 key,
|
|
5002
|
+
// exactly what libp2p would generate internally if handed none, except that the
|
|
5003
|
+
// book swap can sign this node's own entry with it (`strand-peer-book-swap.ts`) and
|
|
5004
|
+
// the retained launch config keeps the same strand peer id across a hibernation
|
|
5005
|
+
// resume. Stability across RESTARTS still needs an identity key.
|
|
5006
|
+
//
|
|
4899
5007
|
// NOTE: derivation requires an Ed25519 identity key, so a node configured
|
|
4900
5008
|
// with some other key type now fails strand launch outright (surfaced as
|
|
4901
5009
|
// `strand:error` / a rejected addStrand) where it previously started the
|
|
4902
5010
|
// strand on that key. Ed25519 is already required for every control-DB
|
|
4903
5011
|
// signing path, so nothing reachable today hits this; if a non-Ed25519
|
|
4904
|
-
// identity is ever supported,
|
|
4905
|
-
//
|
|
4906
|
-
// gives up peerId stability across restarts.
|
|
5012
|
+
// identity is ever supported, take the random-key branch below for it — it
|
|
5013
|
+
// still avoids the collision, and gives up only peerId stability across restarts.
|
|
4907
5014
|
const identityKey = this.identityKey;
|
|
4908
5015
|
const transportKey = identityKey
|
|
4909
5016
|
? await timed('strandTransportKey', () => strandTransportKey(identityKey, strand.Id))
|
|
4910
|
-
:
|
|
5017
|
+
: await generateKeyPair('Ed25519');
|
|
4911
5018
|
// Derived BEFORE seed resolution so the seed pass doubles as the delegate
|
|
4912
5019
|
// announcement and every grant is recorded before `startStrand` runs
|
|
4913
5020
|
// `libp2p.start()` (the responder records the grant before replying; the
|
|
@@ -4935,7 +5042,19 @@ export class CadreNode {
|
|
|
4935
5042
|
backfill: this.config.strandBackfill,
|
|
4936
5043
|
revocationEnforcement: this.config.strandRevocationEnforcement,
|
|
4937
5044
|
membershipReconciliation: this.config.strandMembershipReconciliation,
|
|
4938
|
-
onSelfRevoked: (revokedStrandId) =>
|
|
5045
|
+
onSelfRevoked: (revokedStrandId) => {
|
|
5046
|
+
this.forgetRevokedJoin(revokedStrandId);
|
|
5047
|
+
this.emit('strand:revoked', { strandId: revokedStrandId });
|
|
5048
|
+
},
|
|
5049
|
+
// The strand peer book's observation writer: every strand peer the node
|
|
5050
|
+
// identifies is remembered with its dialable addresses, so a restart re-finds
|
|
5051
|
+
// it. Retained with the launch config, so a hibernation wake re-arms it.
|
|
5052
|
+
onStrandPeerIdentified: (observedStrandId, observation) => this.observeStrandPeer(observedStrandId, observation),
|
|
5053
|
+
// The book's third writer, the signed swap between strand peers: the strand node
|
|
5054
|
+
// reads and writes the store directly (`strand-peer-book-swap.ts`). Retained with
|
|
5055
|
+
// the launch config too. `?? undefined`: the field is `null` before start(), and a
|
|
5056
|
+
// strand cannot launch before start(), so this is belt and braces.
|
|
5057
|
+
strandPeerBook: this.strandPeerBookStore ?? undefined,
|
|
4939
5058
|
onRejoinBlocked: (blockedStrandId) => this.emit('strand:rejoin-blocked', { strandId: blockedStrandId }),
|
|
4940
5059
|
// The joiner's first-sync write gate (strand-first-sync-gate.ts): a launch that
|
|
4941
5060
|
// comes up `'syncing'` announces the moment its database is published.
|
|
@@ -4998,11 +5117,31 @@ export class CadreNode {
|
|
|
4998
5117
|
*/
|
|
4999
5118
|
async resolveCohortSeed(strandId, delegatePeerId) {
|
|
5000
5119
|
const siblings = await this.resolveSiblingSeed(strandId, delegatePeerId);
|
|
5001
|
-
// Sibling answers FIRST: they were resolved just now, while a
|
|
5002
|
-
//
|
|
5120
|
+
// Sibling answers FIRST: they were resolved just now, while a book entry is as old
|
|
5121
|
+
// as the last connection to that peer. `unionAddrs` appends only what the siblings
|
|
5003
5122
|
// did not already name, so a fresher answer is never displaced and each source keeps
|
|
5004
|
-
// its own signaling-first ordering.
|
|
5005
|
-
return unionAddrs(siblings, this.
|
|
5123
|
+
// its own signaling-first ordering. `delegatePeerId` IS this strand node's own id.
|
|
5124
|
+
return unionAddrs(siblings, this.strandPeerBookAddrs(strandId, delegatePeerId));
|
|
5125
|
+
}
|
|
5126
|
+
/**
|
|
5127
|
+
* The strand peer book's contribution to a strand's seed: every live entry's
|
|
5128
|
+
* addresses, freshest peer first, minus this node's own entry when `selfPeerId` is
|
|
5129
|
+
* known (the book swap files one under the strand transport id; a node must never
|
|
5130
|
+
* dial itself). Empty before {@link start} builds the store.
|
|
5131
|
+
*/
|
|
5132
|
+
strandPeerBookAddrs(strandId, selfPeerId) {
|
|
5133
|
+
const store = this.strandPeerBookStore;
|
|
5134
|
+
if (!store) {
|
|
5135
|
+
return [];
|
|
5136
|
+
}
|
|
5137
|
+
const addrs = [];
|
|
5138
|
+
for (const entry of store.entries(strandId)) {
|
|
5139
|
+
if (entry.peerId === selfPeerId) {
|
|
5140
|
+
continue;
|
|
5141
|
+
}
|
|
5142
|
+
addrs.push(...entry.addrs);
|
|
5143
|
+
}
|
|
5144
|
+
return addrs;
|
|
5006
5145
|
}
|
|
5007
5146
|
/**
|
|
5008
5147
|
* The own-party half of {@link resolveCohortSeed}: strand-network addresses resolved
|
|
@@ -5265,8 +5404,8 @@ export class CadreNode {
|
|
|
5265
5404
|
// `connectedSiblingTargets`' membership read is unbounded, and with zero
|
|
5266
5405
|
// connections its answer is empty whatever the table holds, so decide it from the
|
|
5267
5406
|
// connection list instead of paying for the read once a tick. Note this only skips
|
|
5268
|
-
// the RPC: a strand
|
|
5269
|
-
//
|
|
5407
|
+
// the RPC: a strand with peer-book entries still refreshes below, because those
|
|
5408
|
+
// never came from a sibling in the first place.
|
|
5270
5409
|
//
|
|
5271
5410
|
// NOTE: one strand-addr RPC per (due strand × connected sibling) per refresh
|
|
5272
5411
|
// interval — each a tiny request/response on an already-open control
|
|
@@ -5286,17 +5425,17 @@ export class CadreNode {
|
|
|
5286
5425
|
}
|
|
5287
5426
|
/**
|
|
5288
5427
|
* One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, union their
|
|
5289
|
-
* answers with this strand's
|
|
5290
|
-
*
|
|
5291
|
-
*
|
|
5428
|
+
* answers with this strand's peer-book addresses, and merge the lot into the strand's
|
|
5429
|
+
* address book. Errors are logged and swallowed so one strand's failure never costs
|
|
5430
|
+
* the others their refresh.
|
|
5292
5431
|
*
|
|
5293
5432
|
* `targets` may be EMPTY — a solo node, or one whose only control peers are strangers.
|
|
5294
|
-
* The
|
|
5295
|
-
*
|
|
5296
|
-
*
|
|
5297
|
-
*
|
|
5298
|
-
*
|
|
5299
|
-
*
|
|
5433
|
+
* The peer-book addresses still have to be re-merged in that case: nothing re-resolves
|
|
5434
|
+
* another party's addresses, so this periodic re-merge is the ONLY thing standing
|
|
5435
|
+
* between them and the peerStore's one-hour expiry. A strand with neither a sibling to
|
|
5436
|
+
* ask nor a book entry does nothing and leaves its throttle unstamped, so the next
|
|
5437
|
+
* reconcile tick retries rather than sitting out the whole refresh interval having
|
|
5438
|
+
* done nothing.
|
|
5300
5439
|
*/
|
|
5301
5440
|
async refreshOneStrandPeerAddrs(strandId, targets, now) {
|
|
5302
5441
|
const controlNode = this.controlNode;
|
|
@@ -5304,11 +5443,13 @@ export class CadreNode {
|
|
|
5304
5443
|
if (!controlNode || !strandNode) {
|
|
5305
5444
|
return;
|
|
5306
5445
|
}
|
|
5307
|
-
const contactAddrs = this.crossPartyStrandAddrs.get(strandId) ?? [];
|
|
5308
|
-
if (targets.length === 0 && contactAddrs.length === 0) {
|
|
5309
|
-
return;
|
|
5310
|
-
}
|
|
5311
5446
|
try {
|
|
5447
|
+
// Inside the try: a strand node torn down mid-pass may throw on any read, and one
|
|
5448
|
+
// strand's failure must never cost the others their refresh.
|
|
5449
|
+
const contactAddrs = this.strandPeerBookAddrs(strandId, strandNode.peerId.toString());
|
|
5450
|
+
if (targets.length === 0 && contactAddrs.length === 0) {
|
|
5451
|
+
return;
|
|
5452
|
+
}
|
|
5312
5453
|
// The running strand node's own peerId is the delegate to announce — see
|
|
5313
5454
|
// the relationship note on refreshStrandPeerAddrs.
|
|
5314
5455
|
const siblingAddrs = targets.length === 0
|
|
@@ -5350,14 +5491,14 @@ export class CadreNode {
|
|
|
5350
5491
|
* peerStore's one-hour expiry. No new gating here — cross-party strand trust is
|
|
5351
5492
|
* `backlog/strand-network-nat-relay-reachability`.
|
|
5352
5493
|
*
|
|
5353
|
-
* NOTE: one input
|
|
5354
|
-
*
|
|
5355
|
-
* {@link refreshStrandPeerAddrs} re-merges the
|
|
5356
|
-
*
|
|
5357
|
-
*
|
|
5358
|
-
*
|
|
5359
|
-
*
|
|
5360
|
-
*
|
|
5494
|
+
* NOTE: one input ages out on a slower clock — the strand peer book's entries
|
|
5495
|
+
* ({@link strandPeerBookStore}). Nothing can re-resolve another party's addresses,
|
|
5496
|
+
* so {@link refreshStrandPeerAddrs} re-merges the book every pass, which means a
|
|
5497
|
+
* junk entry from a responder or a peer's identify survives until the book drops it
|
|
5498
|
+
* (14 days unrefreshed, or the peer's next connection replacing the entry) rather
|
|
5499
|
+
* than an hour. Bounded to 16 peers × 16 addrs per strand, forgotten on
|
|
5500
|
+
* `unpublishStrand` / `forgetJoinedStrand`, and still authority-free — so the
|
|
5501
|
+
* exposure is a handful of failed dials, not a trust hole.
|
|
5361
5502
|
*/
|
|
5362
5503
|
async mergeStrandPeerAddrs(strandNode, addrs, strandId) {
|
|
5363
5504
|
const counts = { merged: 0, restamped: 0, skipped: 0, failed: 0 };
|
|
@@ -5423,6 +5564,128 @@ export class CadreNode {
|
|
|
5423
5564
|
}
|
|
5424
5565
|
return orderSignalingFirst(addrs);
|
|
5425
5566
|
}
|
|
5567
|
+
/**
|
|
5568
|
+
* Leave a strand joined from another party: forget its remembered join, so no later
|
|
5569
|
+
* start re-offers it, then {@link stopStrand} it here. The "leave" a joiner has, since it
|
|
5570
|
+
* cannot {@link unpublishStrand} a row its party's control database never held. For one
|
|
5571
|
+
* of this party's own strands the forget is a no-op and this is just {@link stopStrand}.
|
|
5572
|
+
*
|
|
5573
|
+
* Touches nothing beyond this node — the other members are not told, and this party's
|
|
5574
|
+
* membership row in the strand stays.
|
|
5575
|
+
*/
|
|
5576
|
+
async forgetJoinedStrand(strandId) {
|
|
5577
|
+
if (!this._running) {
|
|
5578
|
+
throw new Error('CadreNode not running');
|
|
5579
|
+
}
|
|
5580
|
+
await this.joinedStrandRows.forget(strandId);
|
|
5581
|
+
this.forgetStrandPeers(strandId, 'forgetJoinedStrand');
|
|
5582
|
+
await this.stopStrand(strandId);
|
|
5583
|
+
}
|
|
5584
|
+
/**
|
|
5585
|
+
* Record `row` as a join from another party when this party's control database holds no
|
|
5586
|
+
* row for it — the one case nothing else would re-offer after a restart. Skips the write
|
|
5587
|
+
* when an identical record is already there (an app re-claiming a remembered join on
|
|
5588
|
+
* every start), and keeps the first `joinedAt`.
|
|
5589
|
+
*
|
|
5590
|
+
* Best-effort, for {@link addStrand}: a failure costs the strand its re-offer after the
|
|
5591
|
+
* next restart, not this attach, so it is reported and the attach goes on.
|
|
5592
|
+
* {@link formStrand}, whose join nothing else would name, fails loudly instead.
|
|
5593
|
+
*
|
|
5594
|
+
* NOTE: "no control row" also describes a row the party has just unpublished. A claim
|
|
5595
|
+
* of such a row the app kept from an earlier offer, made after the watcher already
|
|
5596
|
+
* withdrew it, is remembered as a join and survives the removal on this machine until
|
|
5597
|
+
* {@link forgetJoinedStrand}. Contrived today (a claim of a row the node itself is still
|
|
5598
|
+
* offering skips this method); if it shows up, check the strand's `Revocation`
|
|
5599
|
+
* tombstone here before recording.
|
|
5600
|
+
*/
|
|
5601
|
+
async rememberForeignStrand(row) {
|
|
5602
|
+
const store = this.joinedStrandStore;
|
|
5603
|
+
if (!store || !this.controlDatabase) {
|
|
5604
|
+
return;
|
|
5605
|
+
}
|
|
5606
|
+
try {
|
|
5607
|
+
if (await this.controlDatabase.queryStrand(row.Id)) {
|
|
5608
|
+
return;
|
|
5609
|
+
}
|
|
5610
|
+
const existing = (await store.list()).find((record) => record.Id === row.Id);
|
|
5611
|
+
if (existing?.Type === row.Type && existing.MemberPrivateKey === row.MemberPrivateKey) {
|
|
5612
|
+
return;
|
|
5613
|
+
}
|
|
5614
|
+
await store.record({
|
|
5615
|
+
Id: row.Id,
|
|
5616
|
+
Type: row.Type,
|
|
5617
|
+
MemberPrivateKey: row.MemberPrivateKey,
|
|
5618
|
+
joinedAt: existing?.joinedAt ?? Date.now()
|
|
5619
|
+
});
|
|
5620
|
+
}
|
|
5621
|
+
catch (error) {
|
|
5622
|
+
console.warn(`addStrand(${row.Id}): could not remember this strand as joined from another party, ` +
|
|
5623
|
+
'so it will not be re-offered after a restart:', error);
|
|
5624
|
+
}
|
|
5625
|
+
}
|
|
5626
|
+
/**
|
|
5627
|
+
* Self-revocation arm of the joined-strand record: a party removed from a strand must
|
|
5628
|
+
* not re-attach it on every launch. The strand keeps running this session — the
|
|
5629
|
+
* `strand:revoked` contract is that nothing is torn down for the app — and is gone
|
|
5630
|
+
* after the next start. The join record is a no-op for this party's own strands, which
|
|
5631
|
+
* have none; the strand peer book is forgotten either way, since a removed party must
|
|
5632
|
+
* not keep dialing the strand's peers.
|
|
5633
|
+
*
|
|
5634
|
+
* NOTE: a manager can re-admit a removed party directly (`addMemberByManager`), and the
|
|
5635
|
+
* membership loop then finishes the join on its own — but the record is gone by then, so
|
|
5636
|
+
* the next start does not re-attach the strand. Re-forming records it again. If direct
|
|
5637
|
+
* re-admission becomes a routine flow, re-record the join when the revoked-peer gate
|
|
5638
|
+
* clears for this node.
|
|
5639
|
+
*/
|
|
5640
|
+
forgetRevokedJoin(strandId) {
|
|
5641
|
+
void this.joinedStrandRows?.forgetAfterThisSession(strandId).catch((error) => {
|
|
5642
|
+
log('forgetting revoked joined strand %s failed; it will be re-offered on the next start: %o', strandId, error);
|
|
5643
|
+
});
|
|
5644
|
+
// The strand's peers go with the join: a removed party must not keep dialing them.
|
|
5645
|
+
this.forgetStrandPeers(strandId, 'self-revocation');
|
|
5646
|
+
}
|
|
5647
|
+
/**
|
|
5648
|
+
* Forget a strand's entries in the peer book (see {@link strandPeerBookStore}):
|
|
5649
|
+
* fire-and-log, like every book write — the removal is visible synchronously by the
|
|
5650
|
+
* store's contract, and a failed persist costs restart survival only.
|
|
5651
|
+
*/
|
|
5652
|
+
forgetStrandPeers(strandId, caller) {
|
|
5653
|
+
void this.strandPeerBookStore?.forget(strandId).catch((error) => {
|
|
5654
|
+
log('%s: forgetting strand %s peers failed (continuing): %o', caller, strandId, error);
|
|
5655
|
+
});
|
|
5656
|
+
}
|
|
5657
|
+
/**
|
|
5658
|
+
* The strand peer book's observation writer (`StartStrandConfig.onStrandPeerIdentified`):
|
|
5659
|
+
* a strand node identified a strand peer at `observation.addrs`, so remember it as an
|
|
5660
|
+
* unsigned entry seen just now. The observer never reports self, and the store binds
|
|
5661
|
+
* every address to the peer id before filing it.
|
|
5662
|
+
*/
|
|
5663
|
+
observeStrandPeer(strandId, observation) {
|
|
5664
|
+
this.rememberStrandPeer(strandId, {
|
|
5665
|
+
peerId: observation.peerId,
|
|
5666
|
+
addrs: observation.addrs,
|
|
5667
|
+
issuedAt: 0,
|
|
5668
|
+
lastSeenAt: Date.now()
|
|
5669
|
+
}, 'observeStrandPeer');
|
|
5670
|
+
}
|
|
5671
|
+
/**
|
|
5672
|
+
* Merge one entry into the peer book. Fire-and-log: the entry is visible
|
|
5673
|
+
* synchronously by the store's contract, and the promise tracks durability only. A
|
|
5674
|
+
* persist failure costs restart survival, never this session's seed — the same trade
|
|
5675
|
+
* {@link retainDialTarget} makes.
|
|
5676
|
+
*/
|
|
5677
|
+
rememberStrandPeer(strandId, entry, caller) {
|
|
5678
|
+
const store = this.strandPeerBookStore;
|
|
5679
|
+
if (!store) {
|
|
5680
|
+
// Unreachable in production: start() builds the store before any strand can
|
|
5681
|
+
// launch or form. Logged rather than thrown, for the same reason as retainDialTarget.
|
|
5682
|
+
log('%s: no strand peer book yet — not remembering %s for strand %s', caller, entry.peerId, strandId);
|
|
5683
|
+
return;
|
|
5684
|
+
}
|
|
5685
|
+
void store.merge(strandId, entry).catch((error) => {
|
|
5686
|
+
log('%s: persisting strand %s peer %s failed (continuing): %o', caller, strandId, entry.peerId, error);
|
|
5687
|
+
});
|
|
5688
|
+
}
|
|
5426
5689
|
/**
|
|
5427
5690
|
* Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
|
|
5428
5691
|
* stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
|
|
@@ -5431,7 +5694,9 @@ export class CadreNode {
|
|
|
5431
5694
|
* id in the watcher (`StrandWatcher.suppressStrand`) and drops it from
|
|
5432
5695
|
* {@link getDiscoveredStrands}, so neither a later poll nor a drain can undo a
|
|
5433
5696
|
* deliberate stop. Only an explicit {@link addStrand} does, which is the caller
|
|
5434
|
-
* reversing its own decision. Party-wide removal is {@link unpublishStrand}.
|
|
5697
|
+
* reversing its own decision. Party-wide removal is {@link unpublishStrand}. A strand
|
|
5698
|
+
* joined from another party comes back the same way, from its remembered join;
|
|
5699
|
+
* {@link forgetJoinedStrand} is how to leave one for good.
|
|
5435
5700
|
*/
|
|
5436
5701
|
async stopStrand(strandId) {
|
|
5437
5702
|
if (!this._running) {
|
|
@@ -6580,7 +6845,7 @@ export class CadreNode {
|
|
|
6580
6845
|
this.initializeStrandSolicitation();
|
|
6581
6846
|
}
|
|
6582
6847
|
const result = await this.strandSolicitationService.formStrand(invitation, disclosure, this.controlNode);
|
|
6583
|
-
this.
|
|
6848
|
+
this.recordFormationStrandPeers(result.strandId, result.strandAddrs);
|
|
6584
6849
|
// A closed host strand's approval carries the joiner's own membership invitation —
|
|
6585
6850
|
// persist this party's identity key and stage the invitation for bring-up to redeem.
|
|
6586
6851
|
// Runs AFTER the addr recording so a persistence failure (which throws — see the
|
|
@@ -6588,8 +6853,34 @@ export class CadreNode {
|
|
|
6588
6853
|
if (result.membershipInvite) {
|
|
6589
6854
|
await this.adoptFormationMembershipInvite(result.strandId, result.membershipInvite);
|
|
6590
6855
|
}
|
|
6856
|
+
await this.rememberFormedStrand(result);
|
|
6591
6857
|
return result;
|
|
6592
6858
|
}
|
|
6859
|
+
/**
|
|
6860
|
+
* Remember the strand a formation just joined (see {@link joinedStrandStore}), so it
|
|
6861
|
+
* is re-offered after a restart even when the app is killed before its `addStrand`.
|
|
6862
|
+
* Last in {@link formStrand}, after the membership adoption, so a failure here leaves
|
|
6863
|
+
* the party key and staged invitation in place for the re-formation it asks for.
|
|
6864
|
+
*
|
|
6865
|
+
* Throws on failure, like {@link adoptFormationMembershipInvite}: the formation's
|
|
6866
|
+
* token is spent, and a join no store names would vanish at the next restart.
|
|
6867
|
+
*/
|
|
6868
|
+
async rememberFormedStrand(result) {
|
|
6869
|
+
try {
|
|
6870
|
+
await this.joinedStrandStore.record({
|
|
6871
|
+
Id: result.strandId,
|
|
6872
|
+
Type: result.memberPrivateKey ? 'c' : 'o',
|
|
6873
|
+
MemberPrivateKey: result.memberPrivateKey ?? null,
|
|
6874
|
+
joinedAt: Date.now()
|
|
6875
|
+
});
|
|
6876
|
+
}
|
|
6877
|
+
catch (error) {
|
|
6878
|
+
throw new Error(`Formation for strand ${result.strandId} was approved (its one-time token is spent), but ` +
|
|
6879
|
+
'remembering the join in this node\'s joined-strand store failed, so the strand would not ' +
|
|
6880
|
+
'come back after a restart. Fix the store (usually the configured keyStore), then redeem a ' +
|
|
6881
|
+
'fresh invitation.', { cause: error });
|
|
6882
|
+
}
|
|
6883
|
+
}
|
|
6593
6884
|
/**
|
|
6594
6885
|
* Adopt an approved closed-strand formation's membership half on the JOINER:
|
|
6595
6886
|
*
|
|
@@ -6731,35 +7022,41 @@ export class CadreNode {
|
|
|
6731
7022
|
}
|
|
6732
7023
|
/**
|
|
6733
7024
|
* Remember the responder's strand-network addresses for a strand this node just
|
|
6734
|
-
* formed
|
|
6735
|
-
* launches it
|
|
6736
|
-
*
|
|
6737
|
-
*
|
|
6738
|
-
*
|
|
6739
|
-
*
|
|
6740
|
-
*
|
|
6741
|
-
|
|
6742
|
-
|
|
7025
|
+
* formed in the strand peer book (see {@link strandPeerBookStore}), so the strand's
|
|
7026
|
+
* discovery seed has something to work with when the app launches it — and again
|
|
7027
|
+
* after every restart.
|
|
7028
|
+
*
|
|
7029
|
+
* The carried list is peer-agnostic (`sanitizeStrandAddrs` bounds and parses it,
|
|
7030
|
+
* nothing more), so it is attributed per peer here (`groupAddrsByPeerId`, the same
|
|
7031
|
+
* rule the address-book merge applies) and each peer becomes one unsigned entry seen
|
|
7032
|
+
* just now: the responder disclosed the addresses live a moment ago. An entry naming
|
|
7033
|
+
* no destination peer is dropped there. Scoped strictly to `strandId`: these
|
|
7034
|
+
* addresses reach ONE strand node of ONE other party and must never seed another
|
|
7035
|
+
* strand's mesh or the control peerStore. An empty list records nothing, so a later
|
|
7036
|
+
* formation against the same strand that DOES disclose addresses is not shadowed.
|
|
7037
|
+
*/
|
|
7038
|
+
recordFormationStrandPeers(strandId, strandAddrs) {
|
|
6743
7039
|
if (strandAddrs.length === 0) {
|
|
6744
7040
|
log('formStrand: responder disclosed no strand addrs for %s — cross-party seed stays empty', strandId);
|
|
6745
7041
|
return;
|
|
6746
7042
|
}
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
7043
|
+
const now = Date.now();
|
|
7044
|
+
let peers = 0;
|
|
7045
|
+
for (const [peerId, addrs] of groupAddrsByPeerId([...strandAddrs])) {
|
|
7046
|
+
this.rememberStrandPeer(strandId, {
|
|
7047
|
+
peerId,
|
|
7048
|
+
addrs: addrs.map((ma) => ma.toString()),
|
|
7049
|
+
issuedAt: 0,
|
|
7050
|
+
lastSeenAt: now
|
|
7051
|
+
}, 'formStrand');
|
|
7052
|
+
peers++;
|
|
7053
|
+
}
|
|
6757
7054
|
// A strand already running when this lands (a re-formation) would otherwise wait out
|
|
6758
7055
|
// the rest of its ~10-minute refresh throttle before the new addresses reached its
|
|
6759
7056
|
// address book. Clearing the stamp makes the next 15 s reconcile tick merge them,
|
|
6760
7057
|
// which matters precisely because re-forming is the recovery path for a dead entry.
|
|
6761
7058
|
this.strandPeerAddrRefreshAt.delete(strandId);
|
|
6762
|
-
log('formStrand: recorded %d cross-party strand addr(s) for %s',
|
|
7059
|
+
log('formStrand: recorded %d cross-party strand addr(s) for %s under %d peer(s)', strandAddrs.length, strandId, peers);
|
|
6763
7060
|
}
|
|
6764
7061
|
/**
|
|
6765
7062
|
* Encode an open invitation for out-of-band delivery (QR, link, etc.).
|