@serfab/cadre-core 0.13.0 → 1.0.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 +20 -6
- package/dist/bootstrap-peer-store-file.js +2 -2
- package/dist/bootstrap-peer-store.d.ts +32 -12
- package/dist/bootstrap-peer-store.js +41 -15
- package/dist/bootstrap-peer-store.js.map +1 -1
- package/dist/cadre-node.d.ts +558 -80
- package/dist/cadre-node.js +1060 -170
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +13 -26
- package/dist/control-cohort.js +0 -26
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +299 -44
- package/dist/control-database.js +469 -69
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +40 -11
- package/dist/control-read-retry.js +48 -15
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-retry.d.ts +58 -3
- package/dist/control-retry.js +58 -3
- package/dist/control-retry.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +103 -19
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +80 -11
- package/dist/control-write-retry.js +168 -29
- package/dist/control-write-retry.js.map +1 -1
- package/dist/enrolled-machine-store.js +2 -2
- package/dist/index.d.ts +14 -7
- package/dist/index.js +33 -6
- package/dist/index.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +15 -7
- package/dist/node-local-snapshot.js +23 -8
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-dial.d.ts +143 -0
- package/dist/peer-dial.js +176 -0
- package/dist/peer-dial.js.map +1 -0
- package/dist/relay-addrs.d.ts +90 -52
- package/dist/relay-addrs.js +91 -59
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +71 -3
- package/dist/relay-reservation.js +116 -8
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +30 -1
- package/dist/seed-bootstrap.js +62 -17
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/storage-scope.d.ts +62 -0
- package/dist/storage-scope.js +75 -0
- package/dist/storage-scope.js.map +1 -0
- package/dist/strand-database.d.ts +34 -16
- package/dist/strand-database.js +38 -24
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +152 -0
- package/dist/strand-first-sync-gate.js +208 -0
- package/dist/strand-first-sync-gate.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +87 -5
- package/dist/strand-formation-manager.js +109 -5
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +21 -1
- package/dist/strand-formation-protocol.js +30 -0
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +243 -6
- package/dist/strand-instance-manager.js +470 -37
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +20 -12
- package/dist/strand-member-key.js +20 -12
- package/dist/strand-member-key.js.map +1 -1
- package/dist/strand-member-registry.js +3 -0
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +361 -0
- package/dist/strand-membership-reconciler.js +548 -0
- package/dist/strand-membership-reconciler.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +131 -11
- package/dist/strand-membership-writer.js +396 -134
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +46 -14
- package/dist/strand-network-config.js +79 -23
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-solicitation.d.ts +10 -1
- package/dist/strand-solicitation.js +5 -0
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +11 -7
- package/dist/strand-wake-protocol.js +20 -52
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.js +6 -0
- package/dist/strand-watcher.js.map +1 -1
- package/dist/timed-step.d.ts +12 -0
- package/dist/timed-step.js +28 -0
- package/dist/timed-step.js.map +1 -0
- package/dist/timeout-scheduler.d.ts +19 -0
- package/dist/timeout-scheduler.js +22 -0
- package/dist/timeout-scheduler.js.map +1 -0
- package/dist/types.d.ts +262 -30
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/bootstrap-peer-store-file.ts +2 -2
- package/src/bootstrap-peer-store.ts +69 -27
- package/src/cadre-node.ts +7301 -6353
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +122 -130
- package/src/control-database.ts +537 -69
- package/src/control-read-retry.ts +202 -167
- package/src/control-retry.ts +297 -190
- package/src/control-schema.ts +103 -19
- package/src/control-write-retry.ts +525 -383
- package/src/enrolled-machine-store.ts +2 -2
- package/src/index.ts +83 -6
- package/src/node-local-snapshot.ts +25 -8
- package/src/peer-dial.ts +215 -0
- package/src/relay-addrs.ts +458 -436
- package/src/relay-reservation.ts +951 -828
- package/src/seed-bootstrap.ts +76 -18
- package/src/storage-scope.ts +78 -0
- package/src/strand-database.ts +50 -27
- package/src/strand-first-sync-gate.ts +258 -0
- package/src/strand-formation-manager.ts +140 -5
- package/src/strand-formation-protocol.ts +43 -1
- package/src/strand-instance-manager.ts +577 -37
- package/src/strand-member-key.ts +20 -12
- package/src/strand-member-registry.ts +3 -0
- package/src/strand-membership-reconciler.ts +680 -0
- package/src/strand-membership-writer.ts +527 -189
- package/src/strand-network-config.ts +102 -24
- package/src/strand-solicitation.ts +16 -1
- package/src/strand-wake-protocol.ts +21 -66
- package/src/strand-watcher.ts +6 -0
- package/src/timed-step.ts +28 -0
- package/src/timeout-scheduler.ts +28 -0
- package/src/types.ts +1789 -1554
package/dist/cadre-node.js
CHANGED
|
@@ -8,6 +8,10 @@ import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WIND
|
|
|
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';
|
|
11
|
+
import { controlStorageScope } from './storage-scope.js';
|
|
12
|
+
import { generateStrandMemberKey, strandMemberKeyPair } from './strand-member-key.js';
|
|
13
|
+
import { assertNotPreSplitStrand, issueInvite, PreSplitStrandIdentityError } from './strand-membership-writer.js';
|
|
14
|
+
import { MEMBERSHIP_INVITE_TTL_MS } from './strand-formation-manager.js';
|
|
11
15
|
import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
|
|
12
16
|
import { loadOrCreateIdentityKey } from './identity-key.js';
|
|
13
17
|
import { MemoryTrustedOwnerStore } from './trusted-owner-store.js';
|
|
@@ -16,14 +20,14 @@ import { MemoryEnrolledMachineStore } from './enrolled-machine-store.js';
|
|
|
16
20
|
import { mergePeerAddrs, groupAddrsByPeerId } from './peer-addr-book.js';
|
|
17
21
|
import { verifyCadrePeerVoucher } from './peer-authorization.js';
|
|
18
22
|
import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
|
|
19
|
-
import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, withTrailingPeerId, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
|
|
23
|
+
import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, trailingPeerId, withTrailingPeerId, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
|
|
20
24
|
import { signDeviceTokenRecord, verifyDeviceTokenSignature, isPushPlatform } from './device-token.js';
|
|
21
25
|
import { StrandWatcher } from './strand-watcher.js';
|
|
22
|
-
import { StrandInstanceManager } from './strand-instance-manager.js';
|
|
26
|
+
import { StrandInstanceManager, liveStrandStatus } from './strand-instance-manager.js';
|
|
23
27
|
import { PeerJoinBackfill } from './peer-join-backfill.js';
|
|
24
28
|
import { deriveCohortMembers } from './strand-cohort.js';
|
|
25
|
-
import { selectControlCohortDials,
|
|
26
|
-
import {
|
|
29
|
+
import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
|
|
30
|
+
import { dialPeerAddrs, DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS, DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS } from './peer-dial.js';
|
|
27
31
|
import { EnrollmentService } from './enrollment.js';
|
|
28
32
|
import { HibernationManager } from './hibernation-manager.js';
|
|
29
33
|
import { ControlDatabase, isStrandIdConflict } from './control-database.js';
|
|
@@ -40,6 +44,7 @@ import { superviseRelayReservation, resolveRelayReservationState } from './relay
|
|
|
40
44
|
import { PushFanoutService } from './push-fanout.js';
|
|
41
45
|
import { summarizeConnectionPaths, classifyTransport } from './diagnostics/connection-path.js';
|
|
42
46
|
import { TurnRelayTracker } from './diagnostics/webrtc-turn-tracker.js';
|
|
47
|
+
import { timedStep } from './timed-step.js';
|
|
43
48
|
const log = debug('sereus:cadre:node');
|
|
44
49
|
const timing = debug('sereus:cadre:timing');
|
|
45
50
|
/**
|
|
@@ -213,10 +218,11 @@ export class CadreNode {
|
|
|
213
218
|
this._running = false;
|
|
214
219
|
/**
|
|
215
220
|
* The control database's own raw storage (cache-wrapped), resolved once per
|
|
216
|
-
* `start()` and released in {@link cleanup}. Owning it is what keeps
|
|
217
|
-
*
|
|
218
|
-
* `
|
|
219
|
-
*
|
|
221
|
+
* `start()` and released in {@link cleanup}. Owning it is what keeps the
|
|
222
|
+
* provider call for the control scope — `controlStorageScope(partyId)`, see
|
|
223
|
+
* `storage-scope.ts` — a once-per-runtime call: a `stop()` then `start()` cycle
|
|
224
|
+
* on this object re-resolves against a live cache, and never orphans the
|
|
225
|
+
* previous wrapper's registration in the shared cache pool.
|
|
220
226
|
*/
|
|
221
227
|
this.controlStorage = null;
|
|
222
228
|
/**
|
|
@@ -272,6 +278,14 @@ export class CadreNode {
|
|
|
272
278
|
this.controlBringUpInFlight = false;
|
|
273
279
|
/** Map of strandId -> sAppConfig for sAppId filtering and management */
|
|
274
280
|
this.sAppConfigs = new Map();
|
|
281
|
+
/**
|
|
282
|
+
* Strands the control network advertises that no local sAppConfig claims — the
|
|
283
|
+
* backlog behind `strand:discovered`. Maintained in {@link handleStrandAdded}
|
|
284
|
+
* (added), {@link addStrand} (claimed), {@link detachStrand} (stopped or its
|
|
285
|
+
* control row vanished) and {@link cleanup}. Read through
|
|
286
|
+
* {@link getDiscoveredStrands}.
|
|
287
|
+
*/
|
|
288
|
+
this.discoveredStrands = new Map();
|
|
275
289
|
/**
|
|
276
290
|
* Most-recently pushed invite addresses (see {@link setInviteAddresses}).
|
|
277
291
|
* When non-null these take priority over `libp2pNode.getMultiaddrs()` when
|
|
@@ -388,37 +402,89 @@ export class CadreNode {
|
|
|
388
402
|
*/
|
|
389
403
|
this.crossPartyStrandAddrs = new Map();
|
|
390
404
|
/**
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
405
|
+
* PENDING strand membership invitations learned at formation, keyed by strandId — the
|
|
406
|
+
* single-use `Strand.Invite` credential a closed-strand formation result carried back
|
|
407
|
+
* ({@link FormStrandResult.membershipInvite}), waiting for this node's strand bring-up
|
|
408
|
+
* to redeem (`consumeInvite` seats the `Strand.Member` row under this party's own key
|
|
409
|
+
* — the `strand-node-binds-member-peer` half of the party-identity chain).
|
|
410
|
+
*
|
|
411
|
+
* IN-MEMORY, deliberately, like {@link crossPartyStrandAddrs}: a restarted joiner
|
|
412
|
+
* re-forms from scratch anyway (durability is
|
|
413
|
+
* `backlog/feat-cross-party-strand-addr-durability`'s problem), and re-forming issues
|
|
414
|
+
* a fresh invitation. A re-formation against the same strand REPLACES the entry — the
|
|
415
|
+
* fresh invitation supersedes one that may have expired. The party key the invitation
|
|
416
|
+
* admits, by contrast, IS persisted (`StrandPartyKey`, seated by
|
|
417
|
+
* {@link adoptFormationMembershipInvite} before the entry lands here), so a lost
|
|
418
|
+
* invitation never orphans an identity.
|
|
419
|
+
*
|
|
420
|
+
* INVALIDATION is owned by the strand's bring-up membership reconciler
|
|
421
|
+
* (`strand-membership-reconciler.ts`, wired via `StartStrandConfig.pendingMembershipInvite`
|
|
422
|
+
* in {@link launchStrand}): it deletes the entry once the invitation is redeemed, burned
|
|
423
|
+
* against an already-seated member, or found dead (expired / cancelled / consumed
|
|
424
|
+
* elsewhere / the strand sealed). Until the strand is actually launched here the entry
|
|
425
|
+
* just waits.
|
|
426
|
+
*
|
|
427
|
+
* NOTE: entries live for the node's lifetime (one small pair per formed closed
|
|
428
|
+
* strand), the same unbounded-keys tripwire {@link crossPartyStrandAddrs} documents —
|
|
429
|
+
* if a node ever forms strands at scale, evict alongside that map.
|
|
430
|
+
*/
|
|
431
|
+
this.pendingMembershipInvites = new Map();
|
|
432
|
+
/**
|
|
433
|
+
* Founder launches refused because the strand was founded before the per-party
|
|
434
|
+
* identity split (`PreSplitStrandIdentityError`), keyed by strandId. Read by
|
|
435
|
+
* {@link issueStrandMembershipInvite}, which rethrows the recorded error so a bound
|
|
436
|
+
* redemption against the strand is rejected as "must be recreated" — not told to retry
|
|
437
|
+
* because the runtime is not live (a refused fresh launch leaves none) or failed by the
|
|
438
|
+
* `Strand.Invite` gate (a refused in-place founding leaves a joiner instance up whose
|
|
439
|
+
* party key is no manager).
|
|
440
|
+
*
|
|
441
|
+
* Written and cleared by {@link launchStrand}; also cleared by {@link detachStrand},
|
|
442
|
+
* {@link unpublishStrand} and {@link cleanup}, so a recreated id starts clean.
|
|
443
|
+
* In-memory: after a restart the next founder launch of the strand records it again;
|
|
444
|
+
* until then issuance still reads the fingerprint off the live rows whenever a runtime
|
|
445
|
+
* is up — only a strand with no runtime at all answers retryably meanwhile.
|
|
446
|
+
*/
|
|
447
|
+
this.strandLaunchRefusals = new Map();
|
|
448
|
+
/**
|
|
449
|
+
* Dial targets learned out of band, keyed by peer id, valued by the multiaddr
|
|
450
|
+
* strings this node was handed (parsed lazily, at dial time). Two writers:
|
|
451
|
+
* {@link recordSeedBootstrapPeers} retains the owner-flagged peers of every seed
|
|
452
|
+
* this node applies, and {@link addDrone} retains the addresses of every node
|
|
453
|
+
* this node adds. Two readers: {@link dialColdStartBootstrap} dials every entry
|
|
454
|
+
* while the control database still has no siblings, and
|
|
455
|
+
* {@link resolveControlDialAddrs} falls back to a sibling's entry when neither
|
|
456
|
+
* its signed record nor the address book yields an address.
|
|
397
457
|
*
|
|
398
458
|
* A STORE rather than a plain map (see `bootstrap-peer-store.ts`), because the
|
|
399
459
|
* targets must outlive the process: a node seeded into a party it could not
|
|
400
460
|
* reach has nothing else on disk naming that party's addresses (`applySeed`
|
|
401
461
|
* writes no control row, and `CadrePeer` fills in only after a connection
|
|
402
462
|
* succeeds), so an in-memory-only set left it stranded permanently across a
|
|
403
|
-
* restart.
|
|
463
|
+
* restart. An owner that added a node it cannot be dialed by is in the same
|
|
464
|
+
* position: the added node's row stays unsigned, so unresolvable, until that
|
|
465
|
+
* node self-publishes over a connection only the owner can open. Constructed (or
|
|
466
|
+
* adopted from `config.bootstrapPeers.store`) by
|
|
404
467
|
* {@link initializeBootstrapPeerStore} during {@link start}; deliberately NOT
|
|
405
468
|
* cleared by {@link cleanup}, so it survives a stop()→start() cycle of the same
|
|
406
469
|
* node instance — same lifecycle as {@link trustedOwnerStore}. Durability
|
|
407
|
-
* depends on the injected backend
|
|
408
|
-
* RN/browser stay ephemeral for now.
|
|
470
|
+
* depends on the injected backend; the default is in-memory.
|
|
409
471
|
*
|
|
410
472
|
* Deliberately NOT the libp2p peer store, which {@link peerStoreAddrs} already
|
|
411
473
|
* reads for the steady-state path. The peer store is shared with everything
|
|
412
474
|
* libp2p discovers, so "dial every entry" would grow into dialing arbitrary
|
|
413
475
|
* discovered peers as the node lives longer; this store holds exactly the peers
|
|
414
|
-
* an owner-signed, trust-anchored seed nominated as owners
|
|
415
|
-
*
|
|
416
|
-
*
|
|
476
|
+
* an owner-signed, trust-anchored seed nominated as owners, and the nodes this
|
|
477
|
+
* node chose to add. `applySeed` also merges a seed's addresses into the peer
|
|
478
|
+
* store, where they age out; an added node's addresses are never merged there,
|
|
479
|
+
* because only verified addresses go into the address book (see
|
|
480
|
+
* {@link warmSiblingAddrBook}).
|
|
417
481
|
*
|
|
418
|
-
* A later
|
|
482
|
+
* A later record OVERWRITES an entry rather than merging, so a re-seed after an
|
|
419
483
|
* owner's address changes replaces the stale address instead of accumulating
|
|
420
|
-
* dead ones
|
|
421
|
-
* the
|
|
484
|
+
* dead ones, and {@link warmSiblingAddrBook} replaces an entry with the addresses
|
|
485
|
+
* the sibling's signed record resolves to when they differ. Only this node's own
|
|
486
|
+
* {@link removePeer} evicts an entry: otherwise they are the node's only way back
|
|
487
|
+
* to those peers if it is ever stranded again.
|
|
422
488
|
*/
|
|
423
489
|
this.bootstrapPeerStore = null;
|
|
424
490
|
/**
|
|
@@ -458,6 +524,19 @@ export class CadreNode {
|
|
|
458
524
|
* both read "no row yet" and race a duplicate INSERT (a `CadrePeer` PK conflict).
|
|
459
525
|
*/
|
|
460
526
|
this.registerSelfInFlight = null;
|
|
527
|
+
/**
|
|
528
|
+
* When this node last PUBLISHED its own `CadrePeer` address record (inserted or
|
|
529
|
+
* refreshed), or null if it never has in this session. Stamped by
|
|
530
|
+
* {@link noteSelfRecordPublished} on every successful publish, wherever it was driven
|
|
531
|
+
* from — the boot pass, the heartbeat, an address change, a drain or an explicit call.
|
|
532
|
+
*
|
|
533
|
+
* Read by {@link escalateIfSelfRecordStale} for the one consequence worth an operator's
|
|
534
|
+
* attention: once this gap exceeds {@link DEFAULT_PEER_RECORD_MAX_AGE_MS}, every other
|
|
535
|
+
* machine in the party is already discarding this node's address as stale.
|
|
536
|
+
*/
|
|
537
|
+
this.lastSelfRecordPublishAt = null;
|
|
538
|
+
/** Say-once latch for {@link escalateIfSelfRecordStale}; re-armed by the next publish. */
|
|
539
|
+
this.selfRecordStaleWarned = false;
|
|
461
540
|
// ── Write-while-alone re-replication (control-write-ensure-replicated) ──────
|
|
462
541
|
/**
|
|
463
542
|
* Re-replication queue for owner `authorizePeer` writes that committed while
|
|
@@ -492,6 +571,14 @@ export class CadreNode {
|
|
|
492
571
|
* exactly a "removals from before this lifetime" case).
|
|
493
572
|
*/
|
|
494
573
|
this.reissuedHeldRevocations = false;
|
|
574
|
+
/**
|
|
575
|
+
* Whether this process has seen the singleton `Revocation` ledger marker filed — its own
|
|
576
|
+
* `'opened'` or an `'already-open'` answer, in {@link openRevocationLedgerIfDue}. A
|
|
577
|
+
* record of work done, not a cached authorization answer: the marker can never be
|
|
578
|
+
* deleted (`NoDelete`), so this can only go stale if the database itself is replaced,
|
|
579
|
+
* which is why stop() clears it with the other per-process replication state.
|
|
580
|
+
*/
|
|
581
|
+
this.revocationLedgerOpened = false;
|
|
495
582
|
/** This node's own `CadrePeer` self-write committed local-only (re-touched on growth). */
|
|
496
583
|
this.pendingSelfPeerWrite = false;
|
|
497
584
|
/** This node's own `DeviceToken` self-write committed local-only (re-touched on growth). */
|
|
@@ -627,6 +714,25 @@ export class CadreNode {
|
|
|
627
714
|
getStrands() {
|
|
628
715
|
return this.strandManager.getInstances();
|
|
629
716
|
}
|
|
717
|
+
/**
|
|
718
|
+
* Strands the control network advertises that no local sAppConfig claims —
|
|
719
|
+
* the backlog behind `strand:discovered`.
|
|
720
|
+
*
|
|
721
|
+
* The event fires once per strand, and it can fire before the app has
|
|
722
|
+
* subscribed (the watcher's first poll runs inside `start()`). So an app that
|
|
723
|
+
* auto-joins discovered strands must subscribe FIRST and then drain this map,
|
|
724
|
+
* not rely on the event alone. Entries leave the map when the strand is
|
|
725
|
+
* claimed ({@link addStrand}) or its control row disappears.
|
|
726
|
+
*
|
|
727
|
+
* Bounded by the number of strands this party has that this node does not run
|
|
728
|
+
* — the control database holds only this party's rows — so there is no cap and
|
|
729
|
+
* no eviction policy to reason about.
|
|
730
|
+
*
|
|
731
|
+
* Returns a snapshot; mutating it does not affect the node.
|
|
732
|
+
*/
|
|
733
|
+
getDiscoveredStrands() {
|
|
734
|
+
return new Map(this.discoveredStrands);
|
|
735
|
+
}
|
|
630
736
|
/**
|
|
631
737
|
* Get a specific strand instance
|
|
632
738
|
*/
|
|
@@ -742,6 +848,9 @@ export class CadreNode {
|
|
|
742
848
|
// Committed-delete seam → write-while-alone revocation queue. Same wiring
|
|
743
849
|
// window argument as the membership listener above.
|
|
744
850
|
this.controlDatabase.setGuardedDeleteListener((revocation) => this.noteGuardedDelete(revocation));
|
|
851
|
+
// Abandoned-write seam. Wired here, before the first control write can run, so a
|
|
852
|
+
// write lost during the rest of bring-up is reported like any other.
|
|
853
|
+
this.controlDatabase.setControlWriteAbandonedListener((abandonment) => this.noteControlWriteAbandoned(abandonment));
|
|
745
854
|
// Create strand queryable using the control database
|
|
746
855
|
const queryable = this.createStrandQueryable();
|
|
747
856
|
// Create and start the strand watcher with sAppId lookup
|
|
@@ -808,7 +917,7 @@ export class CadreNode {
|
|
|
808
917
|
// scheduleSelfRegistration, so this node's first published `CadrePeer` row
|
|
809
918
|
// already carries the `/p2p-circuit` address the reservation earns it.
|
|
810
919
|
// Throws on a relay that will not have us — start() is fail-fast for a node
|
|
811
|
-
// whose operator named a relay.
|
|
920
|
+
// whose operator named a relay, unless network.requireRelay is false.
|
|
812
921
|
await this.driveControlRelayReservation();
|
|
813
922
|
// Schedule self-registration in background
|
|
814
923
|
this.scheduleSelfRegistration();
|
|
@@ -1025,10 +1134,10 @@ export class CadreNode {
|
|
|
1025
1134
|
return this.enrolledMachineStore;
|
|
1026
1135
|
}
|
|
1027
1136
|
/**
|
|
1028
|
-
* The node-local
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1137
|
+
* The node-local bootstrap-peer store (null before {@link start}) — the dial
|
|
1138
|
+
* targets learned out of band that {@link reconcileControlCohort} falls back to.
|
|
1139
|
+
* Exposed for diagnostics and for a host that wants to show "what would this
|
|
1140
|
+
* node dial if it is stranded?".
|
|
1032
1141
|
*/
|
|
1033
1142
|
getBootstrapPeerStore() {
|
|
1034
1143
|
return this.bootstrapPeerStore;
|
|
@@ -1180,8 +1289,12 @@ export class CadreNode {
|
|
|
1180
1289
|
if (!provider) {
|
|
1181
1290
|
return undefined;
|
|
1182
1291
|
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1292
|
+
// Party-scoped, not the bare literal 'control': the control database holds THIS
|
|
1293
|
+
// party's records, so two parties on one device must not land in one store.
|
|
1294
|
+
// Same key as the cache label, which also makes the shared pool's `stats()` readable.
|
|
1295
|
+
const scope = controlStorageScope(this.config.controlNetwork.partyId);
|
|
1296
|
+
const resolved = typeof provider === 'function' ? provider(scope) : provider;
|
|
1297
|
+
this.controlStorage = wrapStorageWithCache(resolved, scope);
|
|
1185
1298
|
return this.controlStorage;
|
|
1186
1299
|
}
|
|
1187
1300
|
/**
|
|
@@ -1195,9 +1308,8 @@ export class CadreNode {
|
|
|
1195
1308
|
* operator whose relay slot is decorative may genuinely want only the announced
|
|
1196
1309
|
* address, so this reports and proceeds.
|
|
1197
1310
|
*
|
|
1198
|
-
* Keyed off
|
|
1199
|
-
*
|
|
1200
|
-
* longer route — is caught too.
|
|
1311
|
+
* Keyed off `relayAddrs` AND `listenAddrs`, so a hand-written `/p2p-circuit` entry
|
|
1312
|
+
* in `listenAddrs` — a reservation by the longer route — is caught too.
|
|
1201
1313
|
*
|
|
1202
1314
|
* NOTE: the only direct `console.*` in this library — a boot-time operator warning, not a
|
|
1203
1315
|
* diagnostic trace (those use `debug`, which an operator never sees without `DEBUG=`). If a
|
|
@@ -1209,9 +1321,13 @@ export class CadreNode {
|
|
|
1209
1321
|
if (!replacesAdvertisedAddrs(network)) {
|
|
1210
1322
|
return;
|
|
1211
1323
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1324
|
+
// Read off the raw config, not the resolved listen set: the question is only
|
|
1325
|
+
// "does this config name a relay", and the resolution throws on a hand-written
|
|
1326
|
+
// `<relay>/p2p-circuit` listen entry — which still warrants this warning, and
|
|
1327
|
+
// gets its own refusal a few lines later in `buildControlNodeOptions`.
|
|
1328
|
+
const namesRelay = (network?.relayAddrs?.length ?? 0) > 0
|
|
1329
|
+
|| (network?.listenAddrs ?? []).some((addr) => addr.includes('/p2p-circuit'));
|
|
1330
|
+
if (!namesRelay) {
|
|
1215
1331
|
return;
|
|
1216
1332
|
}
|
|
1217
1333
|
console.warn('network.announceAddrs is set alongside a circuit-relay listener (from network.relayAddrs, ' +
|
|
@@ -1231,11 +1347,11 @@ export class CadreNode {
|
|
|
1231
1347
|
buildControlNodeOptions() {
|
|
1232
1348
|
const { controlNetwork, network, profile } = this.config;
|
|
1233
1349
|
const identityKey = this.identityKey;
|
|
1234
|
-
// `
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
//
|
|
1238
|
-
const listenAddrs = resolveListenAddrs(network
|
|
1350
|
+
// `network.relayAddrs` contributes the bare `/p2p-circuit` SEARCH entry, which
|
|
1351
|
+
// opens no connection — the reservation is driven explicitly at the END of
|
|
1352
|
+
// `start()`, so the control database is built while this node holds zero
|
|
1353
|
+
// control connections. See `relay-addrs.ts` and `start()`.
|
|
1354
|
+
const listenAddrs = resolveListenAddrs(network);
|
|
1239
1355
|
// The transports those listen entries imply. A `/ws` entry switches db-p2p's
|
|
1240
1356
|
// WebSocket transport on (without it the entry binds nothing and libp2p reports
|
|
1241
1357
|
// nothing); anything outside {tcp, ws/wss, p2p-circuit} throws here rather than
|
|
@@ -1809,6 +1925,7 @@ export class CadreNode {
|
|
|
1809
1925
|
if (await this.seedBootstrapService.insertSelfPeerRecord(record)) {
|
|
1810
1926
|
if (this.committedAlone())
|
|
1811
1927
|
this.pendingSelfPeerWrite = true;
|
|
1928
|
+
this.noteSelfRecordPublished();
|
|
1812
1929
|
log('registerSelf: inserted own CadrePeer record (owner-signed, updatedAt=%d, %d addrs, sig=%s…)', record.updatedAt, addrs.length, record.sig.slice(0, 16));
|
|
1813
1930
|
return 'inserted';
|
|
1814
1931
|
}
|
|
@@ -1846,9 +1963,81 @@ export class CadreNode {
|
|
|
1846
1963
|
await this.controlDatabase.updateSelfPeerRecord(record);
|
|
1847
1964
|
if (this.committedAlone())
|
|
1848
1965
|
this.pendingSelfPeerWrite = true;
|
|
1966
|
+
this.noteSelfRecordPublished();
|
|
1849
1967
|
log('registerSelf: refreshed own CadrePeer record (updatedAt=%d, %d addrs, sig=%s…)', record.updatedAt, addrs.length, record.sig.slice(0, 16));
|
|
1850
1968
|
return 'refreshed';
|
|
1851
1969
|
}
|
|
1970
|
+
/**
|
|
1971
|
+
* Record that this node's own address record just landed, and re-arm the stale-record
|
|
1972
|
+
* warning. Called from BOTH publishing paths of {@link publishSelfRecord} and nowhere
|
|
1973
|
+
* else — a `skipped` publish wrote nothing, so it must not refresh the stamp.
|
|
1974
|
+
*/
|
|
1975
|
+
noteSelfRecordPublished() {
|
|
1976
|
+
this.lastSelfRecordPublishAt = Date.now();
|
|
1977
|
+
this.selfRecordStaleWarned = false;
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Report an abandoned control write to the embedding app.
|
|
1981
|
+
*
|
|
1982
|
+
* Every control write funnels through `ControlDatabase.lockedWithRetry`, which now tells
|
|
1983
|
+
* this node when it gives one up. Foreground writes ALSO reject to their caller; the
|
|
1984
|
+
* background ones ({@link startRecordRefresh}'s republish and the two replication
|
|
1985
|
+
* drains) are fired unawaited with a `debug`-only catch, so this event is the only thing
|
|
1986
|
+
* that reaches an app at all.
|
|
1987
|
+
*
|
|
1988
|
+
* Reporting only — the one operator-visible escalation lives on the republish path
|
|
1989
|
+
* ({@link escalateIfSelfRecordStale}), where the consequence is measurable.
|
|
1990
|
+
*/
|
|
1991
|
+
noteControlWriteAbandoned(abandonment) {
|
|
1992
|
+
log('Control write abandoned [%s] after %d/%d attempt(s) in %dms (%s): %o', abandonment.label ?? 'unlabelled', abandonment.attemptsMade, abandonment.attemptsAllowed, abandonment.elapsedMs, abandonment.reason, abandonment.error);
|
|
1993
|
+
this.emit('control:write-abandoned', abandonment);
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Escalate ONE failed self-address republish to the operator — once — when the node has
|
|
1997
|
+
* not published its own record for longer than a record stays fresh.
|
|
1998
|
+
*
|
|
1999
|
+
* The bar is the CONSEQUENCE, not a failure count: a resolver discards a `CadrePeer`
|
|
2000
|
+
* record older than {@link DEFAULT_PEER_RECORD_MAX_AGE_MS} (15 minutes) and the heartbeat
|
|
2001
|
+
* re-stamps at half that, so by the time this gap opens every other machine in the party
|
|
2002
|
+
* has already stopped accepting this node's address. A count would fire far too early —
|
|
2003
|
+
* one failed heartbeat is already half the budget, and a single miss costs nothing.
|
|
2004
|
+
*
|
|
2005
|
+
* Say-once, re-armed by the next successful publish ({@link noteSelfRecordPublished}), so
|
|
2006
|
+
* a node that stays broken warns once rather than every 7.5 minutes.
|
|
2007
|
+
*
|
|
2008
|
+
* A node that has never published in this session is skipped: it has no record out there
|
|
2009
|
+
* to go stale, and the reason it has none (not a member yet, no signing key, revoked) is
|
|
2010
|
+
* already logged by `registerSelf` itself.
|
|
2011
|
+
*
|
|
2012
|
+
* Only a FAILED republish is checked. A heartbeat whose publish reports `skipped` neither
|
|
2013
|
+
* stamps nor warns, so a node that stops being able to publish without erroring — revoked,
|
|
2014
|
+
* or its row removed — goes quiet here. That is the intended reading: a revoked node has
|
|
2015
|
+
* nothing to publish and its unreachability is the point, not a fault to report.
|
|
2016
|
+
*
|
|
2017
|
+
* NOTE: the second `console.*` in this library, and the reason
|
|
2018
|
+
* {@link warnIfAnnounceAddrsDiscardRelay}'s note says a third should not simply be added:
|
|
2019
|
+
* both are operator warnings about a configuration/health condition an embedder cannot
|
|
2020
|
+
* see otherwise, and both have an event beside them (`control:write-abandoned` carries
|
|
2021
|
+
* the underlying write failure). A third such condition should surface through
|
|
2022
|
+
* {@link CadreNodeEvents} alone unless it likewise degrades the whole party in silence.
|
|
2023
|
+
*/
|
|
2024
|
+
escalateIfSelfRecordStale(reason, error) {
|
|
2025
|
+
const publishedAt = this.lastSelfRecordPublishAt;
|
|
2026
|
+
if (publishedAt === null || this.selfRecordStaleWarned) {
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
const staleForMs = Date.now() - publishedAt;
|
|
2030
|
+
if (staleForMs < DEFAULT_PEER_RECORD_MAX_AGE_MS) {
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
this.selfRecordStaleWarned = true;
|
|
2034
|
+
console.warn(`[sereus] this machine has not published its own address record for ${Math.round(staleForMs / 60000)} `
|
|
2035
|
+
+ `minutes. The latest attempt (${reason}) failed with: `
|
|
2036
|
+
+ `${error instanceof Error ? error.message : String(error)}. `
|
|
2037
|
+
+ 'Other machines in the party discard an address record older than '
|
|
2038
|
+
+ `${Math.round(DEFAULT_PEER_RECORD_MAX_AGE_MS / 60000)} minutes, so any of them not already `
|
|
2039
|
+
+ 'connected to this one can no longer reach it. Publishing keeps retrying; this is reported once.');
|
|
2040
|
+
}
|
|
1852
2041
|
/**
|
|
1853
2042
|
* Sign this node's address record for publication, stamped strictly later than
|
|
1854
2043
|
* `existing` (the row it is about to replace, or null for a first insert) so a
|
|
@@ -1955,7 +2144,12 @@ export class CadreNode {
|
|
|
1955
2144
|
return;
|
|
1956
2145
|
}
|
|
1957
2146
|
const republish = (reason) => {
|
|
1958
|
-
void this.registerSelf().catch((error) =>
|
|
2147
|
+
void this.registerSelf().catch((error) => {
|
|
2148
|
+
log('Record refresh (%s) failed: %o', reason, error);
|
|
2149
|
+
// The only failure in this file that degrades the whole party on its own, so it is
|
|
2150
|
+
// the only one that escalates past `debug`. See escalateIfSelfRecordStale.
|
|
2151
|
+
this.escalateIfSelfRecordStale(reason, error);
|
|
2152
|
+
});
|
|
1959
2153
|
};
|
|
1960
2154
|
const reconcile = (reason) => {
|
|
1961
2155
|
void this.reconcileControlCohort().catch((error) => log('Control-cohort reconcile (%s) failed: %o', reason, error));
|
|
@@ -1973,7 +2167,10 @@ export class CadreNode {
|
|
|
1973
2167
|
// `CadrePeer` row, so its boot-time publish does nothing, and the two triggers
|
|
1974
2168
|
// wired here are the only ones after that: `self:peer:update` needs an ADDRESS
|
|
1975
2169
|
// CHANGE, and a relay-only node's addresses stop changing the moment its
|
|
1976
|
-
// reservation lands — inside `start()`, before this listener exists
|
|
2170
|
+
// reservation lands — inside `start()`, before this listener exists (on the
|
|
2171
|
+
// `network.requireRelay: false` posture the reservation may instead land LATER,
|
|
2172
|
+
// from the retry supervisor, and then this listener is what publishes the
|
|
2173
|
+
// `/p2p-circuit` address the boot-time publish could not carry). Harmless
|
|
1977
2174
|
// where a late-authorized node's addresses keep churning (NAT/relay rotation
|
|
1978
2175
|
// fire the event anyway), and invisible while parties authorize members before
|
|
1979
2176
|
// they boot. If late enrollment becomes the normal path — an invited phone is
|
|
@@ -2035,10 +2232,16 @@ export class CadreNode {
|
|
|
2035
2232
|
this.hasControlConnection = false;
|
|
2036
2233
|
this.reconstructedLocalOnlyWrites = false;
|
|
2037
2234
|
this.reissuedHeldRevocations = false;
|
|
2235
|
+
this.revocationLedgerOpened = false;
|
|
2038
2236
|
this.pendingPeerWrites.clear();
|
|
2039
2237
|
this.pendingRevocations.clear();
|
|
2040
2238
|
this.pendingSelfPeerWrite = false;
|
|
2041
2239
|
this.pendingSelfDeviceWrite = false;
|
|
2240
|
+
// Self-record publication history is per-session too: a restarted node re-publishes
|
|
2241
|
+
// from scratch, and carrying the previous lifetime's stamp forward would either
|
|
2242
|
+
// suppress the stale-record warning or fire it on a node that just came up.
|
|
2243
|
+
this.lastSelfRecordPublishAt = null;
|
|
2244
|
+
this.selfRecordStaleWarned = false;
|
|
2042
2245
|
}
|
|
2043
2246
|
/**
|
|
2044
2247
|
* Resolve a peer's current, signed, trust-checkable multiaddrs from only its
|
|
@@ -2126,7 +2329,7 @@ export class CadreNode {
|
|
|
2126
2329
|
* Applied by ALL THREE sources of control-dial candidates, so no call site
|
|
2127
2330
|
* invents its own rule and none can produce a mixed list: the signed record
|
|
2128
2331
|
* ({@link resolvePeerAddrs}), the libp2p address book
|
|
2129
|
-
* ({@link peerStoreAddrs}), and a
|
|
2332
|
+
* ({@link peerStoreAddrs}), and a retained out-of-band dial target
|
|
2130
2333
|
* ({@link bootstrapDialAddrs}).
|
|
2131
2334
|
*
|
|
2132
2335
|
* An address naming a DIFFERENT trailing peer id is dropped: it does not reach
|
|
@@ -2153,14 +2356,14 @@ export class CadreNode {
|
|
|
2153
2356
|
* way, never thrown, so every caller's list-shaping stays total.
|
|
2154
2357
|
*
|
|
2155
2358
|
* Used both to normalize addresses this node DIALS (a `CadrePeer` row, a
|
|
2156
|
-
*
|
|
2359
|
+
* retained bootstrap addr) and to normalize the ones it ANNOUNCES
|
|
2157
2360
|
* ({@link getStrandMultiaddrs}); the rule is the same either way — an address
|
|
2158
2361
|
* that does not reach `peerId` is not an address for `peerId`.
|
|
2159
2362
|
*
|
|
2160
2363
|
* `withTrailingPeerId` encapsulates `/p2p/<peerId>`, which throws on a peer id
|
|
2161
2364
|
* that does not parse. Unreachable for a `CadrePeer` row (the binding gate
|
|
2162
2365
|
* above parsed it already) and for our own node's id, reachable for a
|
|
2163
|
-
*
|
|
2366
|
+
* retained one ({@link bootstrapDialAddrs}).
|
|
2164
2367
|
*/
|
|
2165
2368
|
bindAddrToPeer(addr, peerId) {
|
|
2166
2369
|
try {
|
|
@@ -2210,9 +2413,15 @@ export class CadreNode {
|
|
|
2210
2413
|
* `self:peer:update` (all wired in {@link startRecordRefresh}).
|
|
2211
2414
|
*
|
|
2212
2415
|
* Concurrent triggers collapse into a single in-flight pass
|
|
2213
|
-
* (see {@link reconcileControlCohortInFlight}) so two passes never double-dial
|
|
2416
|
+
* (see {@link reconcileControlCohortInFlight}) so two passes never double-dial;
|
|
2417
|
+
* a call that joins an in-flight pass resolves to THAT pass's result.
|
|
2214
2418
|
* Best-effort throughout: a failure to resolve/dial any one sibling is logged
|
|
2215
2419
|
* and the pass continues; the whole pass is a no-op when the node is alone.
|
|
2420
|
+
*
|
|
2421
|
+
* Resolves to the peers the pass dialled ({@link ControlCohortReconcileResult}),
|
|
2422
|
+
* so a caller can tell a link this pass opened from one something else opened —
|
|
2423
|
+
* the pass skips a peer that is already connected. The timer and event triggers
|
|
2424
|
+
* discard it.
|
|
2216
2425
|
*/
|
|
2217
2426
|
async reconcileControlCohort() {
|
|
2218
2427
|
if (this.reconcileControlCohortInFlight) {
|
|
@@ -2221,7 +2430,7 @@ export class CadreNode {
|
|
|
2221
2430
|
const op = this.runReconcileControlCohort();
|
|
2222
2431
|
this.reconcileControlCohortInFlight = op;
|
|
2223
2432
|
try {
|
|
2224
|
-
await op;
|
|
2433
|
+
return await op;
|
|
2225
2434
|
}
|
|
2226
2435
|
finally {
|
|
2227
2436
|
this.reconcileControlCohortInFlight = null;
|
|
@@ -2232,7 +2441,7 @@ export class CadreNode {
|
|
|
2232
2441
|
// Shutdown / not-yet-started guard (mirrors publishSelfRecord). A pass that
|
|
2233
2442
|
// fires after stop() began must early-return rather than touch a torn-down node.
|
|
2234
2443
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
2235
|
-
return;
|
|
2444
|
+
return { dialed: [] };
|
|
2236
2445
|
}
|
|
2237
2446
|
// Ride this pass's cadence to refresh the per-stream gate's materialized
|
|
2238
2447
|
// authorized set — membership changes that ARRIVED BY REPLICATION (rather
|
|
@@ -2242,11 +2451,15 @@ export class CadreNode {
|
|
|
2242
2451
|
// CadrePeer query (two reads per pass), plus a third from
|
|
2243
2452
|
// `refreshStrandPeerAddrs` on the passes where a strand is due AND this node
|
|
2244
2453
|
// holds a control connection (one read for the whole pass, not one per
|
|
2245
|
-
// strand)
|
|
2246
|
-
//
|
|
2454
|
+
// strand). Each also reads Revocation first. Before the Revocation ledger
|
|
2455
|
+
// marker exists (filed below, once connected) that block is missing, so every
|
|
2456
|
+
// one of those reads consults the cohort about it; once the marker exists every
|
|
2457
|
+
// block they touch is held and none of them does (both states pinned in
|
|
2458
|
+
// control-founding-consult-budget.spec.ts). If those reads ever get costly,
|
|
2459
|
+
// share one row-set across all three.
|
|
2247
2460
|
await this.refreshMembershipGate('reconcile');
|
|
2248
2461
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
2249
|
-
return;
|
|
2462
|
+
return { dialed: [] };
|
|
2250
2463
|
}
|
|
2251
2464
|
// Refresh relay delegate grants BEFORE the sibling enumeration below: a
|
|
2252
2465
|
// solo cadre with a party relay has no siblings to dial but must still keep
|
|
@@ -2254,7 +2467,7 @@ export class CadreNode {
|
|
|
2254
2467
|
// the reservation and faces the connection gate again).
|
|
2255
2468
|
await this.refreshDelegateGrants();
|
|
2256
2469
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
2257
|
-
return;
|
|
2470
|
+
return { dialed: [] };
|
|
2258
2471
|
}
|
|
2259
2472
|
// Then re-warm each running strand's own address book from its siblings, on
|
|
2260
2473
|
// its own (much longer) throttle. Same reasoning as warmSiblingAddrBook
|
|
@@ -2262,7 +2475,7 @@ export class CadreNode {
|
|
|
2262
2475
|
// layer under cadre-core dials strand peers by bare peer id.
|
|
2263
2476
|
await this.refreshStrandPeerAddrs();
|
|
2264
2477
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
2265
|
-
return;
|
|
2478
|
+
return { dialed: [] };
|
|
2266
2479
|
}
|
|
2267
2480
|
const selfPeerId = this.controlNode.peerId.toString();
|
|
2268
2481
|
// Reap guarded rows this node still holds for incarnations an already-committed
|
|
@@ -2304,7 +2517,27 @@ export class CadreNode {
|
|
|
2304
2517
|
log('reconcileControlCohort: reap pass failed (continuing): %o', error);
|
|
2305
2518
|
}
|
|
2306
2519
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
2307
|
-
return;
|
|
2520
|
+
return { dialed: [] };
|
|
2521
|
+
}
|
|
2522
|
+
// File the Revocation ledger marker once, so that table stops being a never-written
|
|
2523
|
+
// block, which the storage layer re-checks with the cohort on every read (and every
|
|
2524
|
+
// membership lookup and guarded insert reads it). Connected-only for the reap's
|
|
2525
|
+
// reason above: a marker committed alone is local-only, and one filed by a
|
|
2526
|
+
// disconnected owner while another machine creates the same collection is exactly
|
|
2527
|
+
// that fork. Owner-only, and at most once per process — see openRevocationLedgerIfDue.
|
|
2528
|
+
//
|
|
2529
|
+
// NOTE: accepted tradeoff — a solo founder does not file the marker until it holds a
|
|
2530
|
+
// control connection (normally its first sibling; a relay or bootstrap connection also
|
|
2531
|
+
// counts — see getControlConnectionCount — and is the reap's same residual), so until
|
|
2532
|
+
// then it keeps paying one local findCluster per read of
|
|
2533
|
+
// the missing Revocation block (0.009 ms each, measured upstream; no network work on
|
|
2534
|
+
// a cohort of one). Fork safety weighed over that and kept; revisit if findCluster
|
|
2535
|
+
// ever shows up as material in a device profile, or if a solo-founding marker can be
|
|
2536
|
+
// made fork-safe (e.g. filed inside the genesis transaction, before any other machine
|
|
2537
|
+
// can hold the party's collections).
|
|
2538
|
+
await this.openRevocationLedgerIfDue();
|
|
2539
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
2540
|
+
return { dialed: [] };
|
|
2308
2541
|
}
|
|
2309
2542
|
}
|
|
2310
2543
|
// 1. Enumerate known siblings. This membership read is itself a pull-on-read
|
|
@@ -2318,12 +2551,11 @@ export class CadreNode {
|
|
|
2318
2551
|
// because filling this table needs a connection and getting a connection
|
|
2319
2552
|
// needs this table. Fall back to the seed's bootstrap addresses; a solo
|
|
2320
2553
|
// cadre has none and this is a no-op. Must not throw or busy-loop.
|
|
2321
|
-
await this.dialColdStartBootstrap();
|
|
2322
|
-
return;
|
|
2554
|
+
return { dialed: await this.dialColdStartBootstrap() };
|
|
2323
2555
|
}
|
|
2324
2556
|
// Re-guard after the await: a stop() may have raced the membership read.
|
|
2325
2557
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
2326
|
-
return;
|
|
2558
|
+
return { dialed: [] };
|
|
2327
2559
|
}
|
|
2328
2560
|
// 2. Classify backbone (owner) members and select a bounded dial set.
|
|
2329
2561
|
// NOTE: deliberately the REPLICATED table, not the node-local anchor — this
|
|
@@ -2333,7 +2565,7 @@ export class CadreNode {
|
|
|
2333
2565
|
// the anchor if owner status here ever gates something trusted.
|
|
2334
2566
|
const ownerKeys = await this.controlDatabase.getOwnerKeys();
|
|
2335
2567
|
if (!this._running || !this.controlNode) {
|
|
2336
|
-
return;
|
|
2568
|
+
return { dialed: [] };
|
|
2337
2569
|
}
|
|
2338
2570
|
const targetDegree = this.config.network?.controlCohort?.targetDegree
|
|
2339
2571
|
?? DEFAULT_CONTROL_COHORT_TARGET_DEGREE;
|
|
@@ -2346,25 +2578,51 @@ export class CadreNode {
|
|
|
2346
2578
|
// keeping what resolved for the dial loop below.
|
|
2347
2579
|
const resolved = await this.warmSiblingAddrBook(siblings);
|
|
2348
2580
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
2349
|
-
return;
|
|
2581
|
+
return { dialed: [] };
|
|
2350
2582
|
}
|
|
2351
2583
|
// 4. Skip already-connected peers (no re-dial / churn for live connections).
|
|
2352
2584
|
const connected = new Set(this.controlNode.getConnections().map((c) => c.remotePeer.toString()));
|
|
2353
2585
|
// 5. Dial each selected, not-yet-connected sibling, best-effort, from the
|
|
2354
2586
|
// addresses step 3 already resolved for it.
|
|
2355
|
-
|
|
2587
|
+
const dialed = [];
|
|
2356
2588
|
for (const sibling of dials) {
|
|
2357
2589
|
if (!this._running || !this.controlNode) {
|
|
2358
|
-
return;
|
|
2590
|
+
return { dialed };
|
|
2359
2591
|
}
|
|
2360
2592
|
if (connected.has(sibling.peerId)) {
|
|
2361
2593
|
continue;
|
|
2362
2594
|
}
|
|
2363
2595
|
if (await this.dialControlSibling(sibling, resolved.get(sibling.peerId) ?? [])) {
|
|
2364
|
-
dialed
|
|
2596
|
+
dialed.push(sibling.peerId);
|
|
2365
2597
|
}
|
|
2366
2598
|
}
|
|
2367
|
-
log('reconcileControlCohort: pass complete (siblings=%d, selected=%d, dialed=%d)', siblings.length, dials.length, dialed);
|
|
2599
|
+
log('reconcileControlCohort: pass complete (siblings=%d, selected=%d, dialed=%d)', siblings.length, dials.length, dialed.length);
|
|
2600
|
+
return { dialed };
|
|
2601
|
+
}
|
|
2602
|
+
/**
|
|
2603
|
+
* The owner-only half of the reconcile pass's ledger-marker step: file the singleton
|
|
2604
|
+
* `Revocation` marker ({@link SeedBootstrapService.openRevocationLedger}) unless this
|
|
2605
|
+
* process has already seen it filed. The connectivity gate is the caller's
|
|
2606
|
+
* ({@link runReconcileControlCohort}); why the marker exists is on
|
|
2607
|
+
* {@link ControlDatabase.openRevocationLedger}.
|
|
2608
|
+
*
|
|
2609
|
+
* Sets {@link revocationLedgerOpened} on `'opened'` or `'already-open'`. A node that
|
|
2610
|
+
* cannot sign as an owner does nothing: it picks the owner's marker up the first time it
|
|
2611
|
+
* reads the table, like any other replicated row. Best-effort like every step of the
|
|
2612
|
+
* pass — a failure is logged and leaves the flag clear, so the next connected pass retries.
|
|
2613
|
+
*/
|
|
2614
|
+
async openRevocationLedgerIfDue() {
|
|
2615
|
+
if (this.revocationLedgerOpened || !this.seedBootstrapService?.canAuthorize()) {
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
try {
|
|
2619
|
+
const outcome = await this.seedBootstrapService.openRevocationLedger();
|
|
2620
|
+
this.revocationLedgerOpened = true;
|
|
2621
|
+
log('reconcileControlCohort: revocation ledger marker %s', outcome);
|
|
2622
|
+
}
|
|
2623
|
+
catch (error) {
|
|
2624
|
+
log('reconcileControlCohort: filing the revocation ledger marker failed (retrying next pass): %o', error);
|
|
2625
|
+
}
|
|
2368
2626
|
}
|
|
2369
2627
|
/**
|
|
2370
2628
|
* Resolve every sibling's signed address record once and merge what resolves
|
|
@@ -2385,6 +2643,9 @@ export class CadreNode {
|
|
|
2385
2643
|
* resolves to nothing (revoked, stale, untrusted) is not written at all, so its
|
|
2386
2644
|
* existing entry ages out on its own.
|
|
2387
2645
|
*
|
|
2646
|
+
* The same resolution also keeps a sibling's retained out-of-band dial target
|
|
2647
|
+
* current ({@link refreshDialHint}).
|
|
2648
|
+
*
|
|
2388
2649
|
* NOTE: this resolves EVERY sibling serially before the dial loop below runs,
|
|
2389
2650
|
* so it costs one record query per sibling per reconcile pass (~15s) and each
|
|
2390
2651
|
* one delays the pass's first dial. A cadre is a handful of devices, so today
|
|
@@ -2394,6 +2655,9 @@ export class CadreNode {
|
|
|
2394
2655
|
async warmSiblingAddrBook(siblings) {
|
|
2395
2656
|
const resolved = new Map();
|
|
2396
2657
|
const counts = { merged: 0, restamped: 0, skipped: 0, failed: 0 };
|
|
2658
|
+
// One copy of the store's map for the whole loop; a refresh replaces an entry
|
|
2659
|
+
// rather than mutating it, so the copy stays a consistent "before" view.
|
|
2660
|
+
const hints = this.bootstrapPeerStore?.all();
|
|
2397
2661
|
for (const sibling of siblings) {
|
|
2398
2662
|
if (!this._running || !this.controlNode) {
|
|
2399
2663
|
break;
|
|
@@ -2406,6 +2670,7 @@ export class CadreNode {
|
|
|
2406
2670
|
if (!this._running || !controlNode) {
|
|
2407
2671
|
break;
|
|
2408
2672
|
}
|
|
2673
|
+
this.refreshDialHint(sibling.peerId, addrs, hints?.get(sibling.peerId)?.addrs);
|
|
2409
2674
|
// `mergePeerAddrs` owns the whole best-effort contract — including parsing
|
|
2410
2675
|
// a malformed `CadrePeer.PeerId` — so one bad row cannot abort the pass.
|
|
2411
2676
|
counts[await mergePeerAddrs(controlNode, sibling.peerId, addrs)]++;
|
|
@@ -2413,6 +2678,40 @@ export class CadreNode {
|
|
|
2413
2678
|
log('reconcileControlCohort: address book warmed (siblings=%d, merged=%d, restamped=%d, skipped=%d, failed=%d)', resolved.size, counts.merged, counts.restamped, counts.skipped, counts.failed);
|
|
2414
2679
|
return resolved;
|
|
2415
2680
|
}
|
|
2681
|
+
/**
|
|
2682
|
+
* Replace a sibling's retained out-of-band dial target (see
|
|
2683
|
+
* {@link bootstrapPeerStore}) with the addresses its signed record just resolved
|
|
2684
|
+
* to, when the two differ — so an address change this node saw while the record
|
|
2685
|
+
* was fresh (a new port, a new LAN address) is what it dials after a relaunch
|
|
2686
|
+
* that outlives the record's freshness window.
|
|
2687
|
+
*
|
|
2688
|
+
* Only a sibling that already HAS an entry (`retained`) is refreshed: the store
|
|
2689
|
+
* holds peers learned out of band and must not grow into a copy of every
|
|
2690
|
+
* sibling's addresses. An empty resolution leaves the entry alone — a stale or
|
|
2691
|
+
* not-yet-signed record is exactly when the entry is needed. The comparison is
|
|
2692
|
+
* order-insensitive and runs on the retained addresses after binding them to the
|
|
2693
|
+
* peer id, so an entry recorded without `/p2p/` suffixes is not rewritten merely
|
|
2694
|
+
* for lacking them.
|
|
2695
|
+
*
|
|
2696
|
+
* NOTE: replaces rather than merges, so the entry becomes exactly what the
|
|
2697
|
+
* sibling announces. If a node's signed record ever lists fewer addresses this
|
|
2698
|
+
* node can reach than it was added with (say an announce override naming only a
|
|
2699
|
+
* public address, for a node added by its LAN address), a relaunch dials the
|
|
2700
|
+
* worse set; merge the two lists here if that shows up.
|
|
2701
|
+
*/
|
|
2702
|
+
refreshDialHint(peerId, resolved, retained) {
|
|
2703
|
+
if (!retained || resolved.length === 0) {
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
// Both lists are de-duplicated by `normalizeDialAddrs`, so equal size plus
|
|
2707
|
+
// containment is set equality.
|
|
2708
|
+
const current = resolved.map((addr) => addr.toString());
|
|
2709
|
+
const previous = new Set(this.bootstrapDialAddrs(peerId, retained).map((addr) => addr.toString()));
|
|
2710
|
+
if (current.length === previous.size && current.every((addr) => previous.has(addr))) {
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
this.retainDialTarget(peerId, current, 'refreshDialHint');
|
|
2714
|
+
}
|
|
2416
2715
|
/**
|
|
2417
2716
|
* {@link resolvePeerAddrs} for one sibling, best-effort: a control-DB read
|
|
2418
2717
|
* failure yields `[]` (and the cold-start fallback then gets its turn) rather
|
|
@@ -2428,25 +2727,31 @@ export class CadreNode {
|
|
|
2428
2727
|
}
|
|
2429
2728
|
}
|
|
2430
2729
|
/**
|
|
2431
|
-
*
|
|
2730
|
+
* Time limits for dialing ONE control-network peer from its candidate
|
|
2731
|
+
* addresses ({@link dialPeerAddrs}): the reconcile pass's steady-state sibling
|
|
2432
2732
|
* ({@link dialControlSibling}) and cold-start bootstrap peer
|
|
2433
|
-
* ({@link dialBootstrapPeer})
|
|
2434
|
-
*
|
|
2733
|
+
* ({@link dialBootstrapPeer}) dials, and — handed to every
|
|
2734
|
+
* {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials and
|
|
2735
|
+
* `dialInvite`.
|
|
2435
2736
|
*
|
|
2436
|
-
* See {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} for why
|
|
2437
|
-
*
|
|
2737
|
+
* See {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} for why a peer's dial is
|
|
2738
|
+
* bounded as a whole, and {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS}
|
|
2739
|
+
* for why each address is bounded as well.
|
|
2438
2740
|
*/
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2741
|
+
controlDialBudget() {
|
|
2742
|
+
const cohort = this.config.network?.controlCohort;
|
|
2743
|
+
return {
|
|
2744
|
+
perAddressMs: cohort?.perAddressDialTimeoutMs ?? DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS,
|
|
2745
|
+
totalMs: cohort?.dialTimeoutMs ?? DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS,
|
|
2746
|
+
};
|
|
2442
2747
|
}
|
|
2443
2748
|
/**
|
|
2444
2749
|
* Dial one sibling from its already-resolved addresses, best-effort. Returns
|
|
2445
|
-
* whether
|
|
2750
|
+
* whether the dial resolved (false when no address resolves or the dial fails).
|
|
2446
2751
|
*
|
|
2447
2752
|
* A per-peer failure (NAT, offline, relay down, connection-gater denial, or
|
|
2448
|
-
* the {@link
|
|
2449
|
-
*
|
|
2753
|
+
* the {@link controlDialBudget} expiring) is logged and swallowed so one
|
|
2754
|
+
* unreachable sibling never aborts the pass — exactly like
|
|
2450
2755
|
* {@link SeedBootstrapService.applySeed}'s owner-dial loop. A failed dial is
|
|
2451
2756
|
* simply retried on the next pass.
|
|
2452
2757
|
*/
|
|
@@ -2462,7 +2767,7 @@ export class CadreNode {
|
|
|
2462
2767
|
}
|
|
2463
2768
|
try {
|
|
2464
2769
|
log('reconcileControlCohort: dialing sibling %s (%d addr(s))', sibling.peerId, addrs.length);
|
|
2465
|
-
await
|
|
2770
|
+
await dialPeerAddrs(controlNode, addrs, this.controlDialBudget(), `reconcileControlCohort dial of sibling ${sibling.peerId}`);
|
|
2466
2771
|
return true;
|
|
2467
2772
|
}
|
|
2468
2773
|
catch (error) {
|
|
@@ -2471,20 +2776,65 @@ export class CadreNode {
|
|
|
2471
2776
|
}
|
|
2472
2777
|
}
|
|
2473
2778
|
/**
|
|
2474
|
-
* A sibling's control-network dial addresses for the reconcile pass
|
|
2779
|
+
* A sibling's control-network dial addresses for the reconcile pass, from the
|
|
2780
|
+
* first of three sources that yields any:
|
|
2781
|
+
*
|
|
2782
|
+
* 1. the signed, fresh, trust-gated control addresses `resolved` for it by
|
|
2783
|
+
* {@link warmSiblingAddrBook} — passed in rather than re-resolved, so the
|
|
2784
|
+
* pass makes one record query per sibling;
|
|
2785
|
+
* 2. the libp2p address book ({@link peerStoreAddrs}) — the entries `applySeed`
|
|
2786
|
+
* and identify put there, until they age out;
|
|
2787
|
+
* 3. the sibling's retained out-of-band dial target ({@link retainedDialAddrs}).
|
|
2788
|
+
* For a node that cannot listen, this is the only source for a sibling it
|
|
2789
|
+
* added but has not yet connected to: the added node's row stays unsigned
|
|
2790
|
+
* until it self-publishes, which needs the connection this dial opens. It is
|
|
2791
|
+
* also what a relaunch that outlived the record's freshness window uses.
|
|
2792
|
+
*
|
|
2793
|
+
* Returns `[]` (never throws) when none yields an address — that sibling is
|
|
2794
|
+
* skipped this pass.
|
|
2475
2795
|
*
|
|
2476
|
-
*
|
|
2477
|
-
* `
|
|
2478
|
-
*
|
|
2479
|
-
*
|
|
2480
|
-
*
|
|
2481
|
-
*
|
|
2796
|
+
* The list may name transports this node cannot dial (a lent node reports TCP
|
|
2797
|
+
* and `/ws` addresses to a phone that dials WebSockets only). That needs no
|
|
2798
|
+
* filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.1.3)
|
|
2799
|
+
* rejects an address no transport can dial before touching the network, so
|
|
2800
|
+
* such an address costs a log line.
|
|
2801
|
+
*
|
|
2802
|
+
* The list is NOT handed to one `dial()`. libp2p tries a multi-address dial's
|
|
2803
|
+
* addresses one after another under a single deadline, with no limit per
|
|
2804
|
+
* address, and sorts loopback addresses last — so one or two addresses that
|
|
2805
|
+
* never answer used up the whole deadline before the address that worked was
|
|
2806
|
+
* tried. {@link dialPeerAddrs} dials each address on its own time limit instead.
|
|
2482
2807
|
*/
|
|
2483
2808
|
async resolveControlDialAddrs(peerId, resolved) {
|
|
2484
2809
|
if (resolved.length > 0) {
|
|
2485
2810
|
return resolved;
|
|
2486
2811
|
}
|
|
2487
|
-
|
|
2812
|
+
const booked = await this.peerStoreAddrs(peerId);
|
|
2813
|
+
if (booked.length > 0) {
|
|
2814
|
+
return booked;
|
|
2815
|
+
}
|
|
2816
|
+
return this.retainedDialAddrs(peerId);
|
|
2817
|
+
}
|
|
2818
|
+
/**
|
|
2819
|
+
* A sibling's retained out-of-band dial target (see {@link bootstrapPeerStore}),
|
|
2820
|
+
* bound to its peer id through {@link bootstrapDialAddrs}; `[]` when it has none.
|
|
2821
|
+
*
|
|
2822
|
+
* These addresses are never merged into the libp2p address book: they are
|
|
2823
|
+
* unverified, and the address book takes only verified ones (see
|
|
2824
|
+
* `mergePeerAddrs`). Layers that dial by bare peer id use the connection this
|
|
2825
|
+
* dial opens, and after it drops the next reconcile pass dials again.
|
|
2826
|
+
*
|
|
2827
|
+
* NOTE: an entry is consulted here only for a current sibling, so a peer revoked
|
|
2828
|
+
* by another owner is not dialed from it, even though the entry stays (only this
|
|
2829
|
+
* node's own {@link removePeer} forgets one). The cold-start branch
|
|
2830
|
+
* ({@link dialColdStartBootstrap}) still dials every entry while there are no
|
|
2831
|
+
* siblings at all; if entries for revoked peers ever cause dial churn there,
|
|
2832
|
+
* prune entries whose peer has a retired `CadrePeer` row during the membership
|
|
2833
|
+
* refresh.
|
|
2834
|
+
*/
|
|
2835
|
+
retainedDialAddrs(peerId) {
|
|
2836
|
+
const entry = this.bootstrapPeerStore?.all().get(peerId);
|
|
2837
|
+
return entry ? this.bootstrapDialAddrs(peerId, entry.addrs) : [];
|
|
2488
2838
|
}
|
|
2489
2839
|
/**
|
|
2490
2840
|
* Cold-start fallback: the libp2p peerStore multiaddrs for `peerId` (seeded by
|
|
@@ -2532,37 +2882,55 @@ export class CadreNode {
|
|
|
2532
2882
|
* signature-checked against a trust-anchored signer before this runs, and the
|
|
2533
2883
|
* flag only *selects a dial target*; a dial grants no authority.
|
|
2534
2884
|
*
|
|
2535
|
-
*
|
|
2536
|
-
* that applies a seed minted after it joined finds itself in the owner list;
|
|
2537
|
-
* retaining that would make the cold-start pass dial this node forever (the
|
|
2538
|
-
* steady-state pass filters self out of its sibling list for the same reason).
|
|
2885
|
+
* Addressless peers and self are skipped by {@link retainDialTarget}.
|
|
2539
2886
|
*/
|
|
2540
2887
|
recordSeedBootstrapPeers(seed) {
|
|
2888
|
+
for (const peer of seed.peers) {
|
|
2889
|
+
if (peer.isOwner) {
|
|
2890
|
+
this.retainDialTarget(peer.peerId, peer.multiaddrs, 'recordSeedBootstrapPeers');
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
/**
|
|
2895
|
+
* Retain (or replace) one peer's out-of-band dial target in
|
|
2896
|
+
* {@link bootstrapPeerStore}. Shared by every writer: seed intake, {@link addDrone}
|
|
2897
|
+
* and {@link refreshDialHint}.
|
|
2898
|
+
*
|
|
2899
|
+
* Fire-and-log: the entry is visible synchronously by the store's contract, and
|
|
2900
|
+
* the promise tracks durability only. A persist failure costs restart survival,
|
|
2901
|
+
* never this session's dial set — the same trade
|
|
2902
|
+
* `SeedBootstrapService.anchorAcceptedSigner` makes — so it is logged rather than
|
|
2903
|
+
* failing a seed or an add that has already been accepted.
|
|
2904
|
+
*
|
|
2905
|
+
* Two peers are never retained. One with no address: there is nothing to dial.
|
|
2906
|
+
* And self: `createSeed` projects EVERY `CadrePeer` row, so an owner that applies
|
|
2907
|
+
* a seed minted after it joined finds itself in the owner list, and `addDrone`
|
|
2908
|
+
* could be handed this node's own id. Retaining self would make the cold-start
|
|
2909
|
+
* pass dial this node forever (the steady-state pass filters self out of its
|
|
2910
|
+
* sibling list for the same reason).
|
|
2911
|
+
*/
|
|
2912
|
+
retainDialTarget(peerId, addrs, caller) {
|
|
2541
2913
|
const store = this.bootstrapPeerStore;
|
|
2542
2914
|
if (!store) {
|
|
2543
2915
|
// Unreachable in production: start() builds the store before any network
|
|
2544
|
-
// bring-up, and
|
|
2545
|
-
// SeedBootstrapService rejects
|
|
2546
|
-
|
|
2916
|
+
// bring-up, and every caller needs a started node (an uninitialized
|
|
2917
|
+
// SeedBootstrapService rejects a seed or an add, and the reconcile pass
|
|
2918
|
+
// needs a running one).
|
|
2919
|
+
log('%s: no bootstrap-peer store yet; not retaining %s', caller, peerId);
|
|
2547
2920
|
return;
|
|
2548
2921
|
}
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
if (!peer.isOwner || peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
|
|
2552
|
-
continue;
|
|
2553
|
-
}
|
|
2554
|
-
// Sync-visible by contract; the promise tracks durability only. A persist
|
|
2555
|
-
// failure costs restart survival, never this session's retry set — the same
|
|
2556
|
-
// trade `SeedBootstrapService.anchorAcceptedSigner` makes — so log and carry
|
|
2557
|
-
// on rather than failing the seed that was already accepted.
|
|
2558
|
-
void store.record(peer.peerId, peer.multiaddrs).catch((error) => {
|
|
2559
|
-
log('recordSeedBootstrapPeers: persisting bootstrap peer %s failed (retained in memory): %o', peer.peerId, error);
|
|
2560
|
-
});
|
|
2922
|
+
if (addrs.length === 0 || peerId === this.controlNode?.peerId.toString()) {
|
|
2923
|
+
return;
|
|
2561
2924
|
}
|
|
2925
|
+
void store.record(peerId, addrs).catch((error) => {
|
|
2926
|
+
log('%s: persisting dial target %s failed (retained in memory): %o', caller, peerId, error);
|
|
2927
|
+
});
|
|
2562
2928
|
}
|
|
2563
2929
|
/**
|
|
2564
|
-
* Cold-start branch of the reconcile pass: re-dial
|
|
2565
|
-
*
|
|
2930
|
+
* Cold-start branch of the reconcile pass: re-dial every retained out-of-band
|
|
2931
|
+
* dial target (see {@link bootstrapPeerStore}) — in practice the owner peers of
|
|
2932
|
+
* the seeds this node applied — while the control database still has no
|
|
2933
|
+
* siblings to dial.
|
|
2566
2934
|
*
|
|
2567
2935
|
* `SeedBootstrapService.applySeed` dials those owners exactly ONCE, best-effort.
|
|
2568
2936
|
* When that single dial fails — owner momentarily down, relay reservation not
|
|
@@ -2589,14 +2957,14 @@ export class CadreNode {
|
|
|
2589
2957
|
// cannot mutate what we are iterating.
|
|
2590
2958
|
const targets = this.bootstrapPeerStore?.all();
|
|
2591
2959
|
if (!controlNode || !targets || targets.size === 0) {
|
|
2592
|
-
return;
|
|
2960
|
+
return [];
|
|
2593
2961
|
}
|
|
2594
2962
|
// Same skip rule as step 3 of the steady-state pass: no churn on live links.
|
|
2595
2963
|
const connected = new Set(controlNode.getConnections().map((c) => c.remotePeer.toString()));
|
|
2596
|
-
|
|
2964
|
+
const dialed = [];
|
|
2597
2965
|
for (const [peerId, entry] of targets) {
|
|
2598
2966
|
if (!this._running || !this.controlNode) {
|
|
2599
|
-
return;
|
|
2967
|
+
return dialed;
|
|
2600
2968
|
}
|
|
2601
2969
|
// NOTE: a connection this node still holds in `status: 'open'` after the remote
|
|
2602
2970
|
// has already aborted it counts as connected here, so the cold-start retry stays
|
|
@@ -2609,19 +2977,21 @@ export class CadreNode {
|
|
|
2609
2977
|
continue;
|
|
2610
2978
|
}
|
|
2611
2979
|
if (await this.dialBootstrapPeer(peerId, entry.addrs)) {
|
|
2612
|
-
dialed
|
|
2980
|
+
dialed.push(peerId);
|
|
2613
2981
|
}
|
|
2614
2982
|
}
|
|
2615
|
-
log('reconcileControlCohort: cold-start pass complete (bootstrap=%d, connected=%d, dialed=%d)', targets.size, connected.size, dialed);
|
|
2983
|
+
log('reconcileControlCohort: cold-start pass complete (bootstrap=%d, connected=%d, dialed=%d)', targets.size, connected.size, dialed.length);
|
|
2984
|
+
return dialed;
|
|
2616
2985
|
}
|
|
2617
2986
|
/**
|
|
2618
|
-
* Bind a
|
|
2619
|
-
* authenticates the peer it is aiming at rather than
|
|
2987
|
+
* Bind a retained dial target's addresses (see {@link bootstrapPeerStore}) to
|
|
2988
|
+
* its peer id, so the dial authenticates the peer it is aiming at rather than
|
|
2989
|
+
* trusting whoever answers.
|
|
2620
2990
|
*
|
|
2621
2991
|
* The same {@link normalizeDialAddrs} rule the resolved and address-book paths
|
|
2622
2992
|
* use, on the third and last source of control-dial candidates: an address that
|
|
2623
2993
|
* already terminates in `/p2p/<id>` must carry THIS peer's id or it is dropped
|
|
2624
|
-
* (
|
|
2994
|
+
* (an entry that disagrees with itself is not a dial target); one that does not —
|
|
2625
2995
|
* a bare listen addr, or a relay hop with the destination missing — gets the id
|
|
2626
2996
|
* encapsulated; an unencapsulatable one is dropped rather than dialed bare.
|
|
2627
2997
|
*/
|
|
@@ -2641,7 +3011,7 @@ export class CadreNode {
|
|
|
2641
3011
|
}
|
|
2642
3012
|
try {
|
|
2643
3013
|
log('reconcileControlCohort(cold-start): dialing bootstrap peer %s (%d addr(s))', peerId, parsed.length);
|
|
2644
|
-
await
|
|
3014
|
+
await dialPeerAddrs(controlNode, parsed, this.controlDialBudget(), `reconcileControlCohort cold-start dial of bootstrap peer ${peerId}`);
|
|
2645
3015
|
return true;
|
|
2646
3016
|
}
|
|
2647
3017
|
catch (error) {
|
|
@@ -3308,6 +3678,13 @@ export class CadreNode {
|
|
|
3308
3678
|
// (register a config + addStrand); the strand-agnostic seam keeps this
|
|
3309
3679
|
// class free of any app's join policy. A self-configured strand (config
|
|
3310
3680
|
// already present) keeps auto-starting below, unchanged.
|
|
3681
|
+
//
|
|
3682
|
+
// Recorded BEFORE the emit so a handler that synchronously drains
|
|
3683
|
+
// `getDiscoveredStrands()` sees this strand too. The watcher never offers
|
|
3684
|
+
// the same strand twice (its `knownStrands` retains the id for the life of
|
|
3685
|
+
// the process), so this map — not the event — is what a late subscriber
|
|
3686
|
+
// reads. See the `strand:discovered` doc in types.ts.
|
|
3687
|
+
this.discoveredStrands.set(strand.Id, strand);
|
|
3311
3688
|
this.emit('strand:discovered', { strandId: strand.Id, strand });
|
|
3312
3689
|
return;
|
|
3313
3690
|
}
|
|
@@ -3413,8 +3790,10 @@ export class CadreNode {
|
|
|
3413
3790
|
}
|
|
3414
3791
|
// Stop all strand instances
|
|
3415
3792
|
await this.strandManager.stopAll();
|
|
3416
|
-
// Clear sApp configs
|
|
3793
|
+
// Clear sApp configs, recorded launch refusals and the unclaimed-strand backlog
|
|
3417
3794
|
this.sAppConfigs.clear();
|
|
3795
|
+
this.strandLaunchRefusals.clear();
|
|
3796
|
+
this.discoveredStrands.clear();
|
|
3418
3797
|
// Drop delegate-admission state: the grants are scoped to the session that
|
|
3419
3798
|
// recorded them, so a stop()/start() cycle on this object must not keep
|
|
3420
3799
|
// admitting delegates announced under the previous one, nor carry stale
|
|
@@ -3435,6 +3814,7 @@ export class CadreNode {
|
|
|
3435
3814
|
if (this.controlDatabase) {
|
|
3436
3815
|
this.controlDatabase.setMembershipChangeListener(null);
|
|
3437
3816
|
this.controlDatabase.setGuardedDeleteListener(null);
|
|
3817
|
+
this.controlDatabase.setControlWriteAbandonedListener(null);
|
|
3438
3818
|
await this.controlDatabase.close();
|
|
3439
3819
|
this.controlDatabase = null;
|
|
3440
3820
|
}
|
|
@@ -3461,6 +3841,21 @@ export class CadreNode {
|
|
|
3461
3841
|
// healthy and make the embedder's retry a no-op ("already running").
|
|
3462
3842
|
this._running = false;
|
|
3463
3843
|
}
|
|
3844
|
+
/**
|
|
3845
|
+
* A gated joiner's database was just published (`StartStrandConfig.onWritable`): the
|
|
3846
|
+
* instance is `'active'` now, so re-arm its idle timer as any activity would. Nothing
|
|
3847
|
+
* records activity on a gated joiner, so its idle timer may already have fired — the
|
|
3848
|
+
* instance read `'idle'` with a hibernate timer pending — and without this the flip
|
|
3849
|
+
* back to `'active'` would leave that timer to quiesce a strand the app just started
|
|
3850
|
+
* using. Emits `strand:writable` after the timers are settled.
|
|
3851
|
+
*/
|
|
3852
|
+
handleStrandWritable(strandId) {
|
|
3853
|
+
const instance = this.strandManager.getInstance(strandId);
|
|
3854
|
+
if (instance) {
|
|
3855
|
+
this.hibernationManager.recordActivity(instance);
|
|
3856
|
+
}
|
|
3857
|
+
this.emit('strand:writable', { strandId });
|
|
3858
|
+
}
|
|
3464
3859
|
// Hibernation callbacks
|
|
3465
3860
|
async handleStrandIdle(strandId) {
|
|
3466
3861
|
const instance = this.strandManager.getInstance(strandId);
|
|
@@ -3510,9 +3905,10 @@ export class CadreNode {
|
|
|
3510
3905
|
log('handleStrandWake: strand %s not found', strandId);
|
|
3511
3906
|
return;
|
|
3512
3907
|
}
|
|
3513
|
-
// Still live (idle wake, or defensive double-wake): no rebuild needed.
|
|
3908
|
+
// Still live (idle wake, or defensive double-wake): no rebuild needed. A joiner
|
|
3909
|
+
// still behind its first-sync gate wakes back to `'syncing'`, not `'active'`.
|
|
3514
3910
|
if (instance.libp2pNode || instance.database) {
|
|
3515
|
-
instance.status =
|
|
3911
|
+
instance.status = liveStrandStatus(instance);
|
|
3516
3912
|
instance.lastActivity = new Date();
|
|
3517
3913
|
log('Strand %s woke (already live)', strandId);
|
|
3518
3914
|
this.emit('strand:waking', { strandId });
|
|
@@ -3630,7 +4026,7 @@ export class CadreNode {
|
|
|
3630
4026
|
await this.holdWakeWindow(instance, windowMs);
|
|
3631
4027
|
const sawActivity = instance.lastActivity !== activityMark;
|
|
3632
4028
|
if (sawActivity) {
|
|
3633
|
-
instance.status =
|
|
4029
|
+
instance.status = liveStrandStatus(instance);
|
|
3634
4030
|
this.emit('strand:waking', { strandId });
|
|
3635
4031
|
log('Wake window: strand %s saw activity during the window; staying active', strandId);
|
|
3636
4032
|
return true;
|
|
@@ -3687,25 +4083,72 @@ export class CadreNode {
|
|
|
3687
4083
|
*
|
|
3688
4084
|
* A rejected call leaves nothing running but DOES leave the sApp config
|
|
3689
4085
|
* registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
|
|
3690
|
-
* automatic relaunch need it.
|
|
3691
|
-
*
|
|
3692
|
-
*
|
|
3693
|
-
*
|
|
3694
|
-
*
|
|
4086
|
+
* automatic relaunch need it. That background relaunch covers only a strand the
|
|
4087
|
+
* WATCHER launched: it forgets a row whose `onStrandAdded` threw and re-attempts it
|
|
4088
|
+
* on a later poll (each failure re-emitting `strand:error`) until it succeeds. A
|
|
4089
|
+
* strand claimed here after it was DISCOVERED gets no such retry — the watcher
|
|
4090
|
+
* already recorded it in its `knownStrands` when it offered it as
|
|
4091
|
+
* `strand:discovered`, and no later poll re-offers a strand it knows. This call has
|
|
4092
|
+
* also already dropped it from {@link getDiscoveredStrands}, so a failed claim
|
|
4093
|
+
* leaves the strand dormant until the caller retries or the node restarts (see
|
|
4094
|
+
* `backlog/bug-discovered-strand-lost-when-claim-fails`).
|
|
4095
|
+
* {@link detachStrand} (reached via {@link stopStrand}) is what abandons a strand
|
|
4096
|
+
* for good.
|
|
3695
4097
|
*/
|
|
3696
4098
|
async addStrand(config) {
|
|
3697
4099
|
if (!this._running) {
|
|
3698
4100
|
throw new Error('CadreNode not running');
|
|
3699
4101
|
}
|
|
3700
|
-
const { strandRow, sAppConfig, founder } = config;
|
|
3701
|
-
|
|
4102
|
+
const { strandRow, sAppConfig, founder, partyMemberPrivateKey } = config;
|
|
4103
|
+
if (partyMemberPrivateKey !== undefined && strandRow.Type !== 'c') {
|
|
4104
|
+
// Only a closed strand seats a Member/Manager, so an open launch would drop this
|
|
4105
|
+
// key silently. Refuse instead: the caller has confused the party identity key
|
|
4106
|
+
// with something an open strand uses, and a silent drop hides that.
|
|
4107
|
+
throw new Error(`addStrand(${strandRow.Id}): partyMemberPrivateKey belongs to a CLOSED strand ` +
|
|
4108
|
+
`(Type 'c'), but this row is Type '${strandRow.Type}' — an open strand seats no ` +
|
|
4109
|
+
'Member/Manager, so the key would be ignored.');
|
|
4110
|
+
}
|
|
4111
|
+
// Store sApp config for this strand. The strand is claimed now, so it leaves the
|
|
4112
|
+
// unclaimed backlog — a later `getDiscoveredStrands()` drain must not re-offer it.
|
|
3702
4113
|
this.sAppConfigs.set(strandRow.Id, sAppConfig);
|
|
4114
|
+
this.discoveredStrands.delete(strandRow.Id);
|
|
3703
4115
|
log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)', strandRow.Id, sAppConfig.id, founder ?? 'derived');
|
|
3704
4116
|
// An unset `founder` is DERIVED from the row inside launchStrand (this node founds
|
|
3705
4117
|
// iff the row's FounderOwnerKey is its own owner key); an explicit flag wins — the
|
|
3706
4118
|
// formation/responder flows pass one deliberately, since their consent-seated rows
|
|
3707
|
-
// carry a null column. See StrandConfig.founder.
|
|
3708
|
-
|
|
4119
|
+
// carry a null column. See StrandConfig.founder. Same rule for the explicit
|
|
4120
|
+
// partyMemberPrivateKey: it wins over the StrandPartyKey control-row read.
|
|
4121
|
+
const instance = await this.launchStrand(strandRow, sAppConfig, founder, partyMemberPrivateKey);
|
|
4122
|
+
// The first-sync write gate: a JOINING machine's database is withheld until it has
|
|
4123
|
+
// received the strand's Header from another member, because a write before that
|
|
4124
|
+
// forks every table it touches (see strand-first-sync-gate.ts). Waiting here is what
|
|
4125
|
+
// lets an app write straight after `addStrand` resolves — the reference chat apps
|
|
4126
|
+
// do — so the default is to wait, bounded by `strandFirstSync.timeoutMs`. A timeout
|
|
4127
|
+
// rejects with the retryable `StrandAwaitingFirstSyncError` and leaves the launch
|
|
4128
|
+
// up: this same call, made again once a member is reachable, completes the attach
|
|
4129
|
+
// (the tracked-instance path above returns the still-syncing instance, and this
|
|
4130
|
+
// wait picks it up). Founders and machines that already hold the Header resolve at
|
|
4131
|
+
// once. Only a LIVE gated instance is waited on — a hibernating one is returned as
|
|
4132
|
+
// it always was (no database either; a wake publishes it).
|
|
4133
|
+
if (config.awaitFirstSync !== false && this.strandManager.isAwaitingFirstSync(strandRow.Id)) {
|
|
4134
|
+
await this.strandManager.whenWritable(strandRow.Id, { timeoutMs: this.config.strandFirstSync?.timeoutMs });
|
|
4135
|
+
}
|
|
4136
|
+
return instance;
|
|
4137
|
+
}
|
|
4138
|
+
/**
|
|
4139
|
+
* Resolve once a launched strand is writable — its database published to the app and
|
|
4140
|
+
* its status `'active'` — or reject with the retryable `StrandAwaitingFirstSyncError`
|
|
4141
|
+
* after `timeoutMs` (default `strandFirstSync.timeoutMs`, else
|
|
4142
|
+
* `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`). The promise form of the `strand:writable`
|
|
4143
|
+
* event, for a caller that attached without waiting ({@link StrandConfig.awaitFirstSync}
|
|
4144
|
+
* `false`) or that holds a strand the watcher launched. Resolves immediately for a
|
|
4145
|
+
* strand that is already writable; rejects immediately for one this node does not run.
|
|
4146
|
+
* A hibernating strand is not woken — wake it first if that is what is wanted.
|
|
4147
|
+
*/
|
|
4148
|
+
async whenStrandWritable(strandId, options) {
|
|
4149
|
+
return await this.strandManager.whenWritable(strandId, {
|
|
4150
|
+
timeoutMs: options?.timeoutMs ?? this.config.strandFirstSync?.timeoutMs
|
|
4151
|
+
});
|
|
3709
4152
|
}
|
|
3710
4153
|
/**
|
|
3711
4154
|
* Publish a strand row to the shared control database under this node's own
|
|
@@ -3751,10 +4194,16 @@ export class CadreNode {
|
|
|
3751
4194
|
* Failing loudly here is intentional: a silently-unpublished strand would run
|
|
3752
4195
|
* as a local-only island that no peer could ever discover or join.
|
|
3753
4196
|
*
|
|
4197
|
+
* A CLOSED strand's publish also seats this party's own membership identity key —
|
|
4198
|
+
* the `StrandPartyKey` row {@link ensureStrandPartyKey} documents — which is what the
|
|
4199
|
+
* founder bootstrap derives `Member.Key`/`Manager.MemberKey` from. The
|
|
4200
|
+
* `memberPrivateKey` ARGUMENT stays the strand-wide read secret formation hands to
|
|
4201
|
+
* joiners; it derives nobody's identity.
|
|
4202
|
+
*
|
|
3754
4203
|
* @param strandId - Unique strand identifier (typically the same id passed to
|
|
3755
4204
|
* {@link addStrand}).
|
|
3756
4205
|
* @param type - `'o'` for open (default) or `'c'` for closed.
|
|
3757
|
-
* @param memberPrivateKey - Optional membership key for a closed strand.
|
|
4206
|
+
* @param memberPrivateKey - Optional shared membership (read) key for a closed strand.
|
|
3758
4207
|
* @returns The live `Strand` row — the one just inserted, or the matching one already
|
|
3759
4208
|
* there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
|
|
3760
4209
|
* on a repeat it is the stored key, not the argument.
|
|
@@ -3792,6 +4241,13 @@ export class CadreNode {
|
|
|
3792
4241
|
// so a re-seat is not mistaken for a repeat.
|
|
3793
4242
|
const matched = requireMatchingStrandRow(existing, desired, 'is already published');
|
|
3794
4243
|
log('publishStrand(%s): already published (type %s) with identical content — no-op', trimmed, type);
|
|
4244
|
+
// A closed strand THIS machine published must hold a party key (its own membership
|
|
4245
|
+
// identity — see ensureStrandPartyKey). Insert-if-absent, so the ordinary repeat
|
|
4246
|
+
// is a read-only no-op and a publish interrupted between the row insert and the
|
|
4247
|
+
// mint heals here. A row another machine published is that machine's to mint for.
|
|
4248
|
+
if (type === 'c' && this.isSelfFoundedRow(matched)) {
|
|
4249
|
+
await this.ensureStrandPartyKey(trimmed);
|
|
4250
|
+
}
|
|
3795
4251
|
return matched;
|
|
3796
4252
|
}
|
|
3797
4253
|
try {
|
|
@@ -3814,6 +4270,14 @@ export class CadreNode {
|
|
|
3814
4270
|
return requireMatchingStrandRow(landed, desired, 'landed concurrently from another founder');
|
|
3815
4271
|
}
|
|
3816
4272
|
log('Published strand %s (type %s) to control DB under owner %s', trimmed, type, signingKey.publicKeyB64);
|
|
4273
|
+
// A closed strand's publish also mints this party's own membership identity key
|
|
4274
|
+
// (StrandPartyKey) — the key the founder bootstrap derives Member/Manager from,
|
|
4275
|
+
// deliberately distinct from the shared memberPrivateKey argument above. A failure
|
|
4276
|
+
// here surfaces (the strand row has already committed, so a retried publish takes
|
|
4277
|
+
// the idempotent branch and heals the mint; so does the next founder launch).
|
|
4278
|
+
if (type === 'c') {
|
|
4279
|
+
await this.ensureStrandPartyKey(trimmed);
|
|
4280
|
+
}
|
|
3817
4281
|
return desired;
|
|
3818
4282
|
}
|
|
3819
4283
|
/**
|
|
@@ -3856,15 +4320,18 @@ export class CadreNode {
|
|
|
3856
4320
|
* a different `Type`, or either half rejects.
|
|
3857
4321
|
*/
|
|
3858
4322
|
async foundStrand(config) {
|
|
3859
|
-
const { strandId, type = 'o', memberPrivateKey, sAppConfig } = config;
|
|
4323
|
+
const { strandId, type = 'o', memberPrivateKey, sAppConfig, awaitFirstSync } = config;
|
|
3860
4324
|
if (!this._running || !this.controlDatabase) {
|
|
3861
4325
|
throw new Error(`CadreNode must be started before attempting to found strand ${strandId}`);
|
|
3862
4326
|
}
|
|
4327
|
+
const controlDatabase = this.controlDatabase;
|
|
3863
4328
|
const trimmed = requireNonBlank(strandId, 'strand id');
|
|
3864
|
-
const
|
|
4329
|
+
const timed = (step, op) => timedStep('foundStrand', trimmed, step, op);
|
|
4330
|
+
const tTotal = performance.now();
|
|
4331
|
+
const published = await timed('queryStrand', () => controlDatabase.queryStrand(trimmed));
|
|
3865
4332
|
const strandRow = published
|
|
3866
4333
|
? this.adoptPublishedStrand(published, type, memberPrivateKey)
|
|
3867
|
-
: await this.publishStrand(trimmed, type, memberPrivateKey);
|
|
4334
|
+
: await timed('publishStrand', () => this.publishStrand(trimmed, type, memberPrivateKey));
|
|
3868
4335
|
// Derived, not hardcoded `true`: adopting a row another machine published means that
|
|
3869
4336
|
// machine runs the bootstrap — this one must attach or it would write a second Header.
|
|
3870
4337
|
const founded = this.isSelfFoundedRow(strandRow);
|
|
@@ -3873,7 +4340,8 @@ export class CadreNode {
|
|
|
3873
4340
|
'(FounderOwnerKey is not this node\'s owner key) — attaching as a joiner; ' +
|
|
3874
4341
|
'the founder bootstrap runs on the publishing machine', trimmed);
|
|
3875
4342
|
}
|
|
3876
|
-
const instance = await this.addStrand({ strandRow, sAppConfig, founder: founded });
|
|
4343
|
+
const instance = await timed('addStrand', () => this.addStrand({ strandRow, sAppConfig, founder: founded, awaitFirstSync }));
|
|
4344
|
+
timing(`[foundStrand:${trimmed}] total: ${Math.round(performance.now() - tTotal)}ms`);
|
|
3877
4345
|
return { instance, strandRow, founded };
|
|
3878
4346
|
}
|
|
3879
4347
|
/**
|
|
@@ -3911,10 +4379,12 @@ export class CadreNode {
|
|
|
3911
4379
|
* is an owner-signed control-plane write like every other, not "destroy the network".
|
|
3912
4380
|
*
|
|
3913
4381
|
* Irreversible for a closed strand (`Type='c'`): the row carries `MemberPrivateKey`,
|
|
3914
|
-
* this party's
|
|
3915
|
-
*
|
|
3916
|
-
*
|
|
3917
|
-
*
|
|
4382
|
+
* this party's read secret for that network, and the strand's `StrandPartyKey` row —
|
|
4383
|
+
* this party's own membership identity, removed (and tombstoned) in the SAME
|
|
4384
|
+
* transaction — is stored nowhere else either. With both gone the party can never
|
|
4385
|
+
* again sign as its seated member/manager there, and a re-published row mints a fresh
|
|
4386
|
+
* identity that does not match the membership already written into the strand's RBAC
|
|
4387
|
+
* layer. The strand id itself is NOT
|
|
3918
4388
|
* blacklisted: a fresh owner-signed {@link publishStrand} re-seats it under a new
|
|
3919
4389
|
* stamp — only the unsigned consent re-seat is permanently foreclosed (the removal's
|
|
3920
4390
|
* `Revocation` tombstone names the id, which the consent branch of
|
|
@@ -3965,6 +4435,26 @@ export class CadreNode {
|
|
|
3965
4435
|
'row deletion itself cannot be replayed, so other nodes may keep running the strand ' +
|
|
3966
4436
|
'until the collection converges.', trimmed);
|
|
3967
4437
|
}
|
|
4438
|
+
// A refused pre-split launch left no tracked instance, so the stop below never
|
|
4439
|
+
// reaches detachStrand for it — clear the refusal here so a re-founded id starts clean.
|
|
4440
|
+
this.strandLaunchRefusals.delete(trimmed);
|
|
4441
|
+
// Clear THIS machine's own MemberPeer binding while the strand runtime — and the
|
|
4442
|
+
// retained party key — is still live: the delete above already destroyed the
|
|
4443
|
+
// party's StrandPartyKey row, so once this process forgets the key nothing can
|
|
4444
|
+
// ever sign the removal again. Runs AFTER the control delete so a rejected
|
|
4445
|
+
// unpublish (unauthorized signer) never strips a binding the party still needs;
|
|
4446
|
+
// best-effort by contract (never throws) — a hibernating or unreachable strand
|
|
4447
|
+
// keeps the stale binding, which grants nothing today. Sibling machines observing
|
|
4448
|
+
// this removal via their watchers do NOT clear their own bindings (see the NOTE on
|
|
4449
|
+
// StrandInstanceManager.clearOwnMemberPeerBinding).
|
|
4450
|
+
//
|
|
4451
|
+
// NOTE: this makes unpublishStrand await a STRAND-NETWORK write it never used to —
|
|
4452
|
+
// however long that write takes to reject or commit is now added to unpublish's
|
|
4453
|
+
// wall-clock. Fine while strand writes fail fast on an unreachable quorum; if
|
|
4454
|
+
// unpublish ever starts hanging on an isolated strand, give this call its own
|
|
4455
|
+
// deadline rather than dropping it (a binding cleared late is still worth more than
|
|
4456
|
+
// one never cleared).
|
|
4457
|
+
await this.strandManager.clearOwnMemberPeerBinding(trimmed);
|
|
3968
4458
|
// Converge locally now rather than waiting up to a poll interval. The watcher fires
|
|
3969
4459
|
// onStrandRemoved for a strand it tracked; the explicit stop below covers a node whose
|
|
3970
4460
|
// strandFilter never admitted this strand (the watcher never knew it, so it will never
|
|
@@ -3976,6 +4466,101 @@ export class CadreNode {
|
|
|
3976
4466
|
}
|
|
3977
4467
|
log('Unpublished strand %s from control DB under owner %s', trimmed, signingKey.publicKeyB64);
|
|
3978
4468
|
}
|
|
4469
|
+
/**
|
|
4470
|
+
* Seat this party's own strand membership identity key — the `StrandPartyKey` row —
|
|
4471
|
+
* for `strandId`, minting a fresh one when none is given, and adopting the stored one
|
|
4472
|
+
* when a row already exists. Insert-if-absent and stable thereafter: the founding
|
|
4473
|
+
* `Member.Key` must not change across restarts, or the bootstrap's insert-if-absent
|
|
4474
|
+
* guards stop matching.
|
|
4475
|
+
*
|
|
4476
|
+
* This is the identity half of the closed-strand key split: the row's `PrivateKey` —
|
|
4477
|
+
* NOT the strand row's shared `MemberPrivateKey`, which formation hands to every
|
|
4478
|
+
* joining party — is what the founder bootstrap derives `Member.Key`/`Manager.MemberKey`
|
|
4479
|
+
* from. It replicates to every machine this party owns (same plaintext-at-rest stance
|
|
4480
|
+
* as `MemberPrivateKey`; docs/strands.md → "Closed-Strand Member Key Handling") and is
|
|
4481
|
+
* never put on the formation wire.
|
|
4482
|
+
*
|
|
4483
|
+
* Called internally by {@link publishStrand} (closed strands mint at publish) and by a
|
|
4484
|
+
* founder launch that finds no row (a publish interrupted before its mint); public so a
|
|
4485
|
+
* test harness — or a joiner flow that persists a formation-issued identity — can seat
|
|
4486
|
+
* a specific key deliberately.
|
|
4487
|
+
*
|
|
4488
|
+
* @param strandId - The strand the key is this party's identity for.
|
|
4489
|
+
* @param partyMemberPrivateKey - Optional specific key (base64 protobuf, as
|
|
4490
|
+
* `generateStrandMemberKey` mints). When a row already holds a DIFFERENT key this
|
|
4491
|
+
* throws — a party has one identity per strand; rotation is a deliberate
|
|
4492
|
+
* remove-then-insert, not a silent swap.
|
|
4493
|
+
* @returns The live party key: the one just seated, or the stored one.
|
|
4494
|
+
*/
|
|
4495
|
+
async ensureStrandPartyKey(strandId, partyMemberPrivateKey) {
|
|
4496
|
+
const trimmed = requireNonBlank(strandId, 'strand id');
|
|
4497
|
+
const signingKey = this.requireOwnerSigningKey(`seat a party key for strand ${trimmed}`);
|
|
4498
|
+
const existing = await this.controlDatabase.queryStrandPartyKey(trimmed);
|
|
4499
|
+
if (existing !== null) {
|
|
4500
|
+
if (partyMemberPrivateKey !== undefined && partyMemberPrivateKey !== existing) {
|
|
4501
|
+
// Neither key is included: the party key is this party's membership identity secret.
|
|
4502
|
+
throw new Error(`Strand ${trimmed} already has a party key and the supplied one differs. The stored ` +
|
|
4503
|
+
'key is the identity the strand membership was seated under; to rotate it, remove ' +
|
|
4504
|
+
'the row deliberately (deleteStrandPartyKey) rather than overwriting it.');
|
|
4505
|
+
}
|
|
4506
|
+
return existing;
|
|
4507
|
+
}
|
|
4508
|
+
const key = partyMemberPrivateKey ?? await generateStrandMemberKey();
|
|
4509
|
+
try {
|
|
4510
|
+
await this.controlDatabase.insertStrandPartyKey(trimmed, key, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64));
|
|
4511
|
+
}
|
|
4512
|
+
catch (error) {
|
|
4513
|
+
// The read and the insert are not atomic; a concurrent seat of the same strand's
|
|
4514
|
+
// key can land in between. Adopt the landed row when it satisfies this request —
|
|
4515
|
+
// the same resolution publishStrand applies to its own read-then-insert window —
|
|
4516
|
+
// and rethrow every other rejection (unauthorized signer, retired stamp).
|
|
4517
|
+
const landed = await this.controlDatabase.queryStrandPartyKey(trimmed);
|
|
4518
|
+
if (landed === null || (partyMemberPrivateKey !== undefined && partyMemberPrivateKey !== landed)) {
|
|
4519
|
+
throw error;
|
|
4520
|
+
}
|
|
4521
|
+
log('ensureStrandPartyKey(%s): lost a concurrent seat race; adopting the landed key', trimmed);
|
|
4522
|
+
return landed;
|
|
4523
|
+
}
|
|
4524
|
+
log('Seated strand party key for %s under owner %s', trimmed, signingKey.publicKeyB64);
|
|
4525
|
+
return key;
|
|
4526
|
+
}
|
|
4527
|
+
/**
|
|
4528
|
+
* Resolve the party membership key a closed strand's launch threads into the founder
|
|
4529
|
+
* bootstrap: the explicit attach-time key when given, else the party's persisted
|
|
4530
|
+
* `StrandPartyKey` row, else — on the one machine whose owner key the row names as
|
|
4531
|
+
* founder — a freshly minted-and-persisted key, which seats the identity for a publish
|
|
4532
|
+
* that was interrupted before its mint. Everyone else resolves undefined: non-founding
|
|
4533
|
+
* machines never mint (no mint race between a party's machines), and only a founder
|
|
4534
|
+
* bootstrap needs the key at all.
|
|
4535
|
+
*
|
|
4536
|
+
* Minting does NOT repair a strand founded before the key split: its founding
|
|
4537
|
+
* membership was seated under the shared `MemberPrivateKey`, and nothing re-seats it —
|
|
4538
|
+
* the founder bootstrap refuses such a strand (`PreSplitStrandIdentityError`) instead.
|
|
4539
|
+
*
|
|
4540
|
+
* NOTE: "founding machine" here is the ROW's provenance, not the launch's resolved
|
|
4541
|
+
* `founder` flag — so an explicit `founder: false` over a row this machine published
|
|
4542
|
+
* still mints, an owner-signed write a caller that said "I am not founding" did not ask
|
|
4543
|
+
* for. Harmless (the party needs that identity for the strand either way, and the mint
|
|
4544
|
+
* is insert-if-absent) and unreachable today: the one explicit `founder: false` caller
|
|
4545
|
+
* passes a row with a null `FounderOwnerKey`. If a caller ever pairs `founder: false`
|
|
4546
|
+
* with its own published row, gate the mint on the resolved flag too.
|
|
4547
|
+
*/
|
|
4548
|
+
async resolveStrandPartyKey(strand, explicitKey) {
|
|
4549
|
+
if (explicitKey !== undefined) {
|
|
4550
|
+
return explicitKey;
|
|
4551
|
+
}
|
|
4552
|
+
if (!this.controlDatabase) {
|
|
4553
|
+
return undefined;
|
|
4554
|
+
}
|
|
4555
|
+
const stored = await this.controlDatabase.queryStrandPartyKey(strand.Id);
|
|
4556
|
+
if (stored !== null) {
|
|
4557
|
+
return stored;
|
|
4558
|
+
}
|
|
4559
|
+
if (!this.isSelfFoundedRow(strand)) {
|
|
4560
|
+
return undefined;
|
|
4561
|
+
}
|
|
4562
|
+
return await this.ensureStrandPartyKey(strand.Id);
|
|
4563
|
+
}
|
|
3979
4564
|
/**
|
|
3980
4565
|
* Publish an owner-signed `FormationInvite` (open-invitation token) to the
|
|
3981
4566
|
* shared control database, so a later {@link formStrand} redemption can be
|
|
@@ -4120,24 +4705,61 @@ export class CadreNode {
|
|
|
4120
4705
|
* now the tracked instance is founded in place
|
|
4121
4706
|
* ({@link StrandInstanceManager.foundExistingStrand}), waking it first if quiesced
|
|
4122
4707
|
* so the bootstrap actually runs before this resolves.
|
|
4708
|
+
*
|
|
4709
|
+
* A founder launch refused as pre-split (`PreSplitStrandIdentityError`) is recorded in
|
|
4710
|
+
* {@link strandLaunchRefusals} for the formation arm, then rethrown; a founder launch
|
|
4711
|
+
* that succeeds clears the record (it ran the bootstrap's pre-split check and passed).
|
|
4123
4712
|
*/
|
|
4124
|
-
async launchStrand(strand, sAppConfig, founder) {
|
|
4713
|
+
async launchStrand(strand, sAppConfig, founder, explicitPartyKey) {
|
|
4125
4714
|
const resolvedFounder = founder ?? this.isSelfFoundedRow(strand);
|
|
4715
|
+
try {
|
|
4716
|
+
const instance = await this.startOrFoundStrand(strand, sAppConfig, resolvedFounder, explicitPartyKey);
|
|
4717
|
+
if (resolvedFounder) {
|
|
4718
|
+
this.strandLaunchRefusals.delete(strand.Id);
|
|
4719
|
+
}
|
|
4720
|
+
return instance;
|
|
4721
|
+
}
|
|
4722
|
+
catch (error) {
|
|
4723
|
+
if (error instanceof PreSplitStrandIdentityError) {
|
|
4724
|
+
this.strandLaunchRefusals.set(strand.Id, error);
|
|
4725
|
+
}
|
|
4726
|
+
throw error;
|
|
4727
|
+
}
|
|
4728
|
+
}
|
|
4729
|
+
/**
|
|
4730
|
+
* The launch itself, for {@link launchStrand} (which documents the behaviour): found a
|
|
4731
|
+
* tracked instance in place, or start a fresh one.
|
|
4732
|
+
*/
|
|
4733
|
+
async startOrFoundStrand(strand, sAppConfig, resolvedFounder, explicitPartyKey) {
|
|
4734
|
+
const timed = (step, op) => timedStep('startOrFoundStrand', strand.Id, step, op);
|
|
4126
4735
|
const existing = this.strandManager.getInstance(strand.Id);
|
|
4127
4736
|
if (existing) {
|
|
4128
4737
|
if (resolvedFounder) {
|
|
4129
|
-
|
|
4738
|
+
// The resolver runs only when the retained config lacks a party key for a
|
|
4739
|
+
// closed strand (see foundExistingStrand), so the common watcher re-entry
|
|
4740
|
+
// ('already-founder') still costs no control read.
|
|
4741
|
+
const outcome = await timed('foundExistingStrand', () => this.strandManager.foundExistingStrand(strand.Id, () => this.resolveStrandPartyKey(strand, explicitPartyKey)));
|
|
4130
4742
|
if (outcome === 'needs-resume') {
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4743
|
+
try {
|
|
4744
|
+
// Quiesced instance: the retained config now founds, but founding promises
|
|
4745
|
+
// the bootstrap has RUN by the time the caller resolves — wake through the
|
|
4746
|
+
// hibernation manager (coalesced with any in-flight wake, timer-aware) so
|
|
4747
|
+
// the rebuild executes it now rather than at some eventual wake.
|
|
4748
|
+
await timed('wakeStrand', () => this.wakeStrand(strand.Id));
|
|
4749
|
+
// The wake's rebuild founds — UNLESS a wake was already in flight when the
|
|
4750
|
+
// config flipped, in which case it had already read the pre-flip config and
|
|
4751
|
+
// rebuilt as a joiner, and `wakeStrand` merely coalesced onto it. Re-run the
|
|
4752
|
+
// (insert-if-absent) bootstrap so founding never resolves headerless.
|
|
4753
|
+
await timed('ensureFounderBootstrap', () => this.strandManager.ensureFounderBootstrap(strand.Id));
|
|
4754
|
+
}
|
|
4755
|
+
catch (error) {
|
|
4756
|
+
// The founding did not happen (e.g. the rebuild refused a pre-split strand and
|
|
4757
|
+
// rolled back, leaving the instance tracked with no runtime): withdraw the flip
|
|
4758
|
+
// so the next attempt re-runs the founding instead of resolving
|
|
4759
|
+
// 'already-founder' over an instance that never founded.
|
|
4760
|
+
this.strandManager.withdrawFounderRequest(strand.Id);
|
|
4761
|
+
throw error;
|
|
4762
|
+
}
|
|
4141
4763
|
}
|
|
4142
4764
|
log('launchStrand: strand %s already tracked — founder request honored (%s)', strand.Id, outcome);
|
|
4143
4765
|
}
|
|
@@ -4146,6 +4768,15 @@ export class CadreNode {
|
|
|
4146
4768
|
}
|
|
4147
4769
|
return existing;
|
|
4148
4770
|
}
|
|
4771
|
+
// A closed strand's launch carries the party's OWN membership identity key: the
|
|
4772
|
+
// explicit attach-time key, else the control-layer StrandPartyKey row — minted here
|
|
4773
|
+
// when this machine is the row-derived founder and no row exists yet (a publish
|
|
4774
|
+
// interrupted before its mint). A joiner with no persisted key threads undefined;
|
|
4775
|
+
// only a FOUNDER bootstrap needs the key, and that path throws loudly without one
|
|
4776
|
+
// (StrandDatabase).
|
|
4777
|
+
const partyMemberPrivateKey = strand.Type === 'c'
|
|
4778
|
+
? await timed('resolveStrandPartyKey', () => this.resolveStrandPartyKey(strand, explicitPartyKey))
|
|
4779
|
+
: undefined;
|
|
4149
4780
|
// Each strand node gets its own transport identity, derived from the cadre
|
|
4150
4781
|
// identity key + strandId (see strand-transport-key.ts). Sharing the
|
|
4151
4782
|
// control node's key here gave every node one peerId, which collides at a
|
|
@@ -4161,16 +4792,17 @@ export class CadreNode {
|
|
|
4161
4792
|
// identity is ever supported, fall back to `undefined` here — libp2p then
|
|
4162
4793
|
// generates a random per-strand key, which still avoids the collision but
|
|
4163
4794
|
// gives up peerId stability across restarts.
|
|
4164
|
-
const
|
|
4165
|
-
|
|
4795
|
+
const identityKey = this.identityKey;
|
|
4796
|
+
const transportKey = identityKey
|
|
4797
|
+
? await timed('strandTransportKey', () => strandTransportKey(identityKey, strand.Id))
|
|
4166
4798
|
: undefined;
|
|
4167
4799
|
// Derived BEFORE seed resolution so the seed pass doubles as the delegate
|
|
4168
4800
|
// announcement and every grant is recorded before `startStrand` runs
|
|
4169
4801
|
// `libp2p.start()` (the responder records the grant before replying; the
|
|
4170
4802
|
// client awaits the replies).
|
|
4171
4803
|
const delegatePeerId = transportKey ? peerIdFromPrivateKey(transportKey).toString() : undefined;
|
|
4172
|
-
const bootstrapNodes = await this.resolveCohortSeed(strand.Id, delegatePeerId);
|
|
4173
|
-
const instance = await this.strandManager.startStrand({
|
|
4804
|
+
const bootstrapNodes = await timed('resolveCohortSeed', () => this.resolveCohortSeed(strand.Id, delegatePeerId));
|
|
4805
|
+
const instance = await timed('strandManager.startStrand', () => this.strandManager.startStrand({
|
|
4174
4806
|
strandRow: strand,
|
|
4175
4807
|
sAppConfig,
|
|
4176
4808
|
storage: this.config.storage,
|
|
@@ -4190,16 +4822,36 @@ export class CadreNode {
|
|
|
4190
4822
|
// are on `StartStrandConfig.servingMachines`.
|
|
4191
4823
|
backfill: this.config.strandBackfill,
|
|
4192
4824
|
revocationEnforcement: this.config.strandRevocationEnforcement,
|
|
4825
|
+
membershipReconciliation: this.config.strandMembershipReconciliation,
|
|
4193
4826
|
onSelfRevoked: (revokedStrandId) => this.emit('strand:revoked', { strandId: revokedStrandId }),
|
|
4827
|
+
// The joiner's first-sync write gate (strand-first-sync-gate.ts): a launch that
|
|
4828
|
+
// comes up `'syncing'` announces the moment its database is published.
|
|
4829
|
+
firstSync: this.config.strandFirstSync,
|
|
4830
|
+
onWritable: (writableStrandId) => this.handleStrandWritable(writableStrandId),
|
|
4831
|
+
// Re-announce the delegate to ONE relay before the strand node's reservation
|
|
4832
|
+
// supervisor re-drives it (see announceDelegateToRelay). Retained with the
|
|
4833
|
+
// launch config, so a hibernation wake's rebuilt supervisors get it too.
|
|
4834
|
+
announceDelegateToRelay: (announcedStrandId, relayAddr, announcedDelegatePeerId) => this.announceDelegateToRelay(announcedStrandId, relayAddr, announcedDelegatePeerId),
|
|
4835
|
+
// The staged formation invitation seam for the bring-up membership
|
|
4836
|
+
// reconciler: read lazily per pass (a re-formation replaces the entry) and
|
|
4837
|
+
// cleared once spent, burned, or dead — see pendingMembershipInvites.
|
|
4838
|
+
pendingMembershipInvite: {
|
|
4839
|
+
get: () => this.pendingMembershipInvites.get(strand.Id),
|
|
4840
|
+
clear: () => { this.pendingMembershipInvites.delete(strand.Id); }
|
|
4841
|
+
},
|
|
4194
4842
|
// The RESOLVED flag, never the raw argument — see the doc comment above.
|
|
4195
|
-
founder: resolvedFounder
|
|
4196
|
-
|
|
4843
|
+
founder: resolvedFounder,
|
|
4844
|
+
// Retained with the launch config, so a hibernation wake rebuilds under the
|
|
4845
|
+
// same identity without re-reading the control DB.
|
|
4846
|
+
partyMemberPrivateKey
|
|
4847
|
+
}));
|
|
4197
4848
|
// The seed reached the node as `bootstrapNodes`, which only enters the
|
|
4198
4849
|
// address book via @libp2p/bootstrap discovery. Merge it directly too, so a
|
|
4199
4850
|
// sibling is dialable by bare peer id from the first moment (see
|
|
4200
4851
|
// mergeStrandPeerAddrs; refreshStrandPeerAddrs keeps it warm from here on).
|
|
4201
|
-
|
|
4202
|
-
|
|
4852
|
+
const strandNode = instance.libp2pNode;
|
|
4853
|
+
if (strandNode) {
|
|
4854
|
+
await timed('mergeStrandPeerAddrs', () => this.mergeStrandPeerAddrs(strandNode, bootstrapNodes, strand.Id));
|
|
4203
4855
|
}
|
|
4204
4856
|
this.hibernationManager.trackStrand(instance);
|
|
4205
4857
|
this.emit('strand:started', { strandId: strand.Id });
|
|
@@ -4345,10 +4997,10 @@ export class CadreNode {
|
|
|
4345
4997
|
* Recorded OPTIMISTICALLY at announce time: `collectStrandAddrs` folds
|
|
4346
4998
|
* per-peer failure to `[]` and reports no per-peer success, and threading
|
|
4347
4999
|
* success out would change its API for little gain. A failed INITIAL announce
|
|
4348
|
-
*
|
|
4349
|
-
*
|
|
4350
|
-
*
|
|
4351
|
-
* (15 min), still inside the 30 min TTL.
|
|
5000
|
+
* costs the strand supervisor its first attempt only (the relay denies the
|
|
5001
|
+
* reservation; every re-drive re-announces first through
|
|
5002
|
+
* {@link announceDelegateToRelay}); a failed REFRESH retries within
|
|
5003
|
+
* `DELEGATE_GRANT_TTL_MS / 2` (15 min), still inside the 30 min TTL.
|
|
4352
5004
|
*/
|
|
4353
5005
|
recordDelegateAnnounces(relayPeerIds, strandId, now = Date.now()) {
|
|
4354
5006
|
for (const relayPeerId of relayPeerIds) {
|
|
@@ -4390,6 +5042,49 @@ export class CadreNode {
|
|
|
4390
5042
|
}
|
|
4391
5043
|
await Promise.all([...running].map(([strandId, delegatePeerId]) => this.announceDelegateToDueRelays(strandId, delegatePeerId, relays, now)));
|
|
4392
5044
|
}
|
|
5045
|
+
/**
|
|
5046
|
+
* Announce ONE strand's delegate peerId to ONE relay, UNTHROTTLED — the
|
|
5047
|
+
* `beforeRedrive` hook of that strand node's per-relay reservation supervisor
|
|
5048
|
+
* (`strand-instance-manager.ts` → `buildStrandRuntime`), run before every
|
|
5049
|
+
* re-drive after the first attempt.
|
|
5050
|
+
*
|
|
5051
|
+
* Why a re-drive must re-announce first: a party control node running the relay
|
|
5052
|
+
* server admits the strand node's derived peerId on an in-memory delegate grant
|
|
5053
|
+
* (`delegate-admission.ts`), and a relay restart drops every grant it held
|
|
5054
|
+
* without telling the announcer. {@link refreshDelegateGrants} would re-announce
|
|
5055
|
+
* on its own at most every `DELEGATE_GRANT_TTL_MS / 2` (15 min) per (relay,
|
|
5056
|
+
* strand) — far slower than the supervisor's backoff — so without this the first
|
|
5057
|
+
* re-drives after a relay restart would be denied at the relay's connection gate.
|
|
5058
|
+
* The throttle map is updated afterwards, so the periodic pass does not announce
|
|
5059
|
+
* again right away.
|
|
5060
|
+
*
|
|
5061
|
+
* Against a dedicated ops relay (no strand-addr RPC) the request fails per-peer
|
|
5062
|
+
* and `collectStrandAddrs` folds it to `[]`: one wasted protocol negotiation per
|
|
5063
|
+
* re-drive attempt, bounded by the supervisor's backoff. Never throws on that
|
|
5064
|
+
* path; a relay addr that names no peer id is logged and skipped (the hook's
|
|
5065
|
+
* caller runs the drive regardless).
|
|
5066
|
+
*
|
|
5067
|
+
* NOTE: against a relay that is DOWN this hook costs up to two strand-addr
|
|
5068
|
+
* timeouts (10 s each: dial by peer id, then by addr) before the 10 s drive even
|
|
5069
|
+
* starts, so one failed re-drive can hold the supervisor `driving` for ~30 s.
|
|
5070
|
+
* Bounded and harmless while the relay is unreachable anyway; if recovery
|
|
5071
|
+
* latency after a relay comes back ever matters, skip the announce when the
|
|
5072
|
+
* control node holds no connection to the relay (the drive's own dial fails
|
|
5073
|
+
* faster) rather than shortening the strand-addr timeout.
|
|
5074
|
+
*/
|
|
5075
|
+
async announceDelegateToRelay(strandId, relayAddr, delegatePeerId) {
|
|
5076
|
+
const controlNode = this.controlNode;
|
|
5077
|
+
if (!controlNode) {
|
|
5078
|
+
return;
|
|
5079
|
+
}
|
|
5080
|
+
const relayPeerId = trailingPeerId(multiaddr(relayAddr));
|
|
5081
|
+
if (relayPeerId === null) {
|
|
5082
|
+
log('announceDelegateToRelay: relay addr %s names no peer id; strand %s not announced', relayAddr, strandId);
|
|
5083
|
+
return;
|
|
5084
|
+
}
|
|
5085
|
+
await collectStrandAddrs(controlNode, [{ peerId: relayPeerId, addrs: [multiaddr(relayAddr)] }], strandId, { delegatePeerId });
|
|
5086
|
+
this.recordDelegateAnnounces([relayPeerId], strandId);
|
|
5087
|
+
}
|
|
4393
5088
|
/** One strand's share of {@link refreshDelegateGrants}: announce to the relays whose grant is due. */
|
|
4394
5089
|
async announceDelegateToDueRelays(strandId, delegatePeerId, relays, now) {
|
|
4395
5090
|
const controlNode = this.controlNode;
|
|
@@ -4616,8 +5311,12 @@ export class CadreNode {
|
|
|
4616
5311
|
/**
|
|
4617
5312
|
* Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
|
|
4618
5313
|
* stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
|
|
4619
|
-
* intact, so on the next node
|
|
4620
|
-
*
|
|
5314
|
+
* intact, so on the next node RESTART the strand is rediscovered and surfaces as
|
|
5315
|
+
* `strand:discovered` again — not on the next watcher poll, which never re-offers a
|
|
5316
|
+
* strand it has already seen (`StrandWatcher.knownStrands` retains the id for the life
|
|
5317
|
+
* of the process). The stop also drops the strand from {@link getDiscoveredStrands},
|
|
5318
|
+
* so a drain cannot undo a deliberate stop. Party-wide removal is
|
|
5319
|
+
* {@link unpublishStrand}.
|
|
4621
5320
|
*/
|
|
4622
5321
|
async stopStrand(strandId) {
|
|
4623
5322
|
if (!this._running) {
|
|
@@ -4627,20 +5326,28 @@ export class CadreNode {
|
|
|
4627
5326
|
}
|
|
4628
5327
|
/**
|
|
4629
5328
|
* Local teardown for one strand, shared by the caller-driven {@link stopStrand} and the
|
|
4630
|
-
* watcher-driven `handleStrandRemoved`: untrack hibernation, drop the sApp config
|
|
4631
|
-
* the instance, emit `strand:stopped`. Touches no
|
|
4632
|
-
* removal the node is on is the caller's concern,
|
|
5329
|
+
* watcher-driven `handleStrandRemoved`: untrack hibernation, drop the sApp config and any
|
|
5330
|
+
* recorded launch refusal, stop the instance, emit `strand:stopped`. Touches no
|
|
5331
|
+
* control-plane row — which side of the removal the node is on is the caller's concern,
|
|
5332
|
+
* not this method's.
|
|
4633
5333
|
*
|
|
4634
5334
|
* The stop + emit are skipped when the strand manager holds no instance for `strandId` —
|
|
4635
5335
|
* e.g. a party owner that published a strand's row but never ran it locally, or an
|
|
4636
5336
|
* explicit {@link stopStrand} for an id this node never started. `hibernationManager`
|
|
4637
|
-
* untrack and the `sAppConfigs`
|
|
4638
|
-
* nothing to remove), so a launch
|
|
4639
|
-
* gets its stray
|
|
5337
|
+
* untrack and the `sAppConfigs` / {@link strandLaunchRefusals} / {@link discoveredStrands}
|
|
5338
|
+
* deletes stay unconditional (all no-ops when there is nothing to remove), so a launch
|
|
5339
|
+
* that failed before an instance was ever tracked still gets its stray entries cleared.
|
|
5340
|
+
*
|
|
5341
|
+
* Dropping the {@link discoveredStrands} entry here covers both callers, and both
|
|
5342
|
+
* readings are the intended one: `handleStrandRemoved` arrives because the control row
|
|
5343
|
+
* is gone, and an explicit {@link stopStrand} is a deliberate abandonment — neither
|
|
5344
|
+
* strand may be re-offered to a later `getDiscoveredStrands()` drain.
|
|
4640
5345
|
*/
|
|
4641
5346
|
async detachStrand(strandId) {
|
|
4642
5347
|
this.hibernationManager.untrackStrand(strandId);
|
|
4643
5348
|
this.sAppConfigs.delete(strandId);
|
|
5349
|
+
this.strandLaunchRefusals.delete(strandId);
|
|
5350
|
+
this.discoveredStrands.delete(strandId);
|
|
4644
5351
|
if (!this.strandManager.hasStrand(strandId)) {
|
|
4645
5352
|
log('detachStrand: strand %s not tracked locally — no-op (no stop, no strand:stopped)', strandId);
|
|
4646
5353
|
return;
|
|
@@ -4856,6 +5563,11 @@ export class CadreNode {
|
|
|
4856
5563
|
* relay indistinguishable from a hung start, and much shorter would fail nodes
|
|
4857
5564
|
* on links that were merely slow. The retries carry on in the background after
|
|
4858
5565
|
* this resolves, exactly as they do for a {@link reserveRelays} caller.
|
|
5566
|
+
*
|
|
5567
|
+
* `network.requireRelay === false` softens only the outcome below: a first
|
|
5568
|
+
* attempt that lands no `/p2p-circuit` address is logged instead of thrown, and
|
|
5569
|
+
* `start()` carries on with the retry supervisor already running in the
|
|
5570
|
+
* background (the same supervisor {@link reserveRelays} always starts).
|
|
4859
5571
|
*/
|
|
4860
5572
|
async driveControlRelayReservation() {
|
|
4861
5573
|
const relayAddrs = this.config.network?.relayAddrs ?? [];
|
|
@@ -4866,6 +5578,11 @@ export class CadreNode {
|
|
|
4866
5578
|
const state = await this.reserveRelays([...relayAddrs]);
|
|
4867
5579
|
timing('[start] reserveRelays: %dms', Math.round(performance.now() - t0));
|
|
4868
5580
|
if (state.status !== 'reserved') {
|
|
5581
|
+
if (this.config.network?.requireRelay === false) {
|
|
5582
|
+
log('Relay reservation did not land on the first attempt (status: %s); ' +
|
|
5583
|
+
'continuing without it because network.requireRelay is false: %o', state.status, state);
|
|
5584
|
+
return;
|
|
5585
|
+
}
|
|
4869
5586
|
throw new RelayReservationFailedError(relayAddrs, state);
|
|
4870
5587
|
}
|
|
4871
5588
|
log('Reserved a relay slot: %o', state.circuitAddrs);
|
|
@@ -5001,6 +5718,7 @@ export class CadreNode {
|
|
|
5001
5718
|
partyId: this.config.controlNetwork.partyId,
|
|
5002
5719
|
ownerPrivateKey,
|
|
5003
5720
|
inviteAddressResolver: () => this.resolveInviteAddresses(),
|
|
5721
|
+
dialBudget: this.controlDialBudget(),
|
|
5004
5722
|
trustPolicy: this.config.seedTrustPolicy,
|
|
5005
5723
|
// Seed trust anchors on the node-local store (seeded just above with this
|
|
5006
5724
|
// node's own genesis key), never on the replicated OwnerKey table.
|
|
@@ -5240,6 +5958,7 @@ export class CadreNode {
|
|
|
5240
5958
|
partyId: this.config.controlNetwork.partyId,
|
|
5241
5959
|
// No owner key - this node only receives seeds
|
|
5242
5960
|
inviteAddressResolver: () => this.resolveInviteAddresses(),
|
|
5961
|
+
dialBudget: this.controlDialBudget(),
|
|
5243
5962
|
trustPolicy: this.config.seedTrustPolicy,
|
|
5244
5963
|
// A listener-only node accepts a wire-delivered seed solely against this
|
|
5245
5964
|
// anchor (there is no per-call override on the inbound handler): with no
|
|
@@ -5407,6 +6126,10 @@ export class CadreNode {
|
|
|
5407
6126
|
await this.seedBootstrapService.authorizePeer({ peerId, multiaddrs });
|
|
5408
6127
|
// Queue for re-replication if this committed local-only (no connected cohort).
|
|
5409
6128
|
this.noteControlWrite(peerId, 'authorize');
|
|
6129
|
+
// NOTE: unlike addDrone, `multiaddrs` is not retained as a dial target: the
|
|
6130
|
+
// flows that vouch through here have the new peer reach this node (it applies a
|
|
6131
|
+
// seed naming the owners, or it dialed in with an invite). If one ever vouches a
|
|
6132
|
+
// peer that cannot reach this node, retain its addresses as addDrone does.
|
|
5410
6133
|
}
|
|
5411
6134
|
/**
|
|
5412
6135
|
* Remove a previously-authorized peer from the cadre.
|
|
@@ -5423,6 +6146,13 @@ export class CadreNode {
|
|
|
5423
6146
|
await this.seedBootstrapService.removePeer(peerId);
|
|
5424
6147
|
// Track + loudly flag a delete that committed local-only (security-relevant).
|
|
5425
6148
|
this.noteControlWrite(peerId, 'remove');
|
|
6149
|
+
// A removed peer is no longer a dial target, however this node learned its
|
|
6150
|
+
// address. Gone from the store synchronously; a persist failure only lets the
|
|
6151
|
+
// entry reappear after a restart, so it is logged rather than failing a
|
|
6152
|
+
// removal that has already committed.
|
|
6153
|
+
void this.bootstrapPeerStore?.forget(peerId).catch((error) => {
|
|
6154
|
+
log('removePeer: persisting the removal of dial target %s failed (forgotten in memory): %o', peerId, error);
|
|
6155
|
+
});
|
|
5426
6156
|
}
|
|
5427
6157
|
/**
|
|
5428
6158
|
* Create a seed from the current control network state.
|
|
@@ -5459,6 +6189,7 @@ export class CadreNode {
|
|
|
5459
6189
|
const tempService = new SeedBootstrapService({
|
|
5460
6190
|
partyId: seed.partyId,
|
|
5461
6191
|
trustPolicy: this.config.seedTrustPolicy,
|
|
6192
|
+
dialBudget: this.controlDialBudget(),
|
|
5462
6193
|
// The anchor is node-scoped, not service-scoped: a throwaway service
|
|
5463
6194
|
// must consult (and persist an accepted signer into) the SAME store the
|
|
5464
6195
|
// persistent one would, or a cold-start enrollment via this path would
|
|
@@ -5539,6 +6270,20 @@ export class CadreNode {
|
|
|
5539
6270
|
/**
|
|
5540
6271
|
* Add a drone to the cadre (for phone/server adding provider-hosted node).
|
|
5541
6272
|
* Creates authorization and seed for drone initialization.
|
|
6273
|
+
*
|
|
6274
|
+
* Also retains the drone's handed-over addresses as a durable dial target (see
|
|
6275
|
+
* {@link bootstrapPeerStore}). The drone cannot dial an owner that does not
|
|
6276
|
+
* listen (a phone), and its `CadrePeer` row stays unsigned — so unresolvable —
|
|
6277
|
+
* until it self-publishes over a connection; this node therefore has to open
|
|
6278
|
+
* that connection from the addresses it was handed, on this launch or a later
|
|
6279
|
+
* one.
|
|
6280
|
+
*
|
|
6281
|
+
* Nothing is dialed here: the drone has not received the seed yet. After
|
|
6282
|
+
* delivering it, call {@link reconcileControlCohort} to dial straight away;
|
|
6283
|
+
* otherwise the next timed reconcile pass does. A pass already in flight is
|
|
6284
|
+
* joined rather than restarted, and one that listed siblings before this add
|
|
6285
|
+
* does not dial the drone, so a caller waiting for the connection should allow
|
|
6286
|
+
* for one more timed pass.
|
|
5542
6287
|
*/
|
|
5543
6288
|
async addDrone(options) {
|
|
5544
6289
|
if (!this.seedBootstrapService) {
|
|
@@ -5546,7 +6291,12 @@ export class CadreNode {
|
|
|
5546
6291
|
}
|
|
5547
6292
|
// The drone's `CadrePeer` insert notifies the membership hub on the way to the
|
|
5548
6293
|
// seed, so the per-stream gate already admits it here.
|
|
5549
|
-
|
|
6294
|
+
const result = await this.seedBootstrapService.addDrone(options);
|
|
6295
|
+
// Same queueing as authorizePeer: a phone adding its first always-on node has
|
|
6296
|
+
// no control connection, so the insert usually committed local-only.
|
|
6297
|
+
this.noteControlWrite(options.dronePeerId, 'authorize');
|
|
6298
|
+
this.retainDialTarget(options.dronePeerId, options.droneMultiaddrs, 'addDrone');
|
|
6299
|
+
return result;
|
|
5550
6300
|
}
|
|
5551
6301
|
/**
|
|
5552
6302
|
* Create an invite for a phone to join the cadre.
|
|
@@ -5619,6 +6369,7 @@ export class CadreNode {
|
|
|
5619
6369
|
const tempService = new SeedBootstrapService({
|
|
5620
6370
|
partyId: invite.partyId,
|
|
5621
6371
|
trustPolicy: this.config.seedTrustPolicy,
|
|
6372
|
+
dialBudget: this.controlDialBudget(),
|
|
5622
6373
|
});
|
|
5623
6374
|
if (this.controlNode && this.controlDatabase) {
|
|
5624
6375
|
tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
|
|
@@ -5648,7 +6399,11 @@ export class CadreNode {
|
|
|
5648
6399
|
// Overrides any caller-supplied hook, exactly like partyId/cadrePeerAddrs: only
|
|
5649
6400
|
// this node can say which strand-network addresses it is actually listening on,
|
|
5650
6401
|
// and a wrong answer here seeds a joiner's mesh with addresses that reach nobody.
|
|
5651
|
-
resolveStrandAddrs: (strandId) => this.getStrandMultiaddrs(strandId)
|
|
6402
|
+
resolveStrandAddrs: (strandId) => this.getStrandMultiaddrs(strandId),
|
|
6403
|
+
// Overridden on the same grounds: only this node holds the running strand
|
|
6404
|
+
// instance's database and this party's `StrandPartyKey` identity, and a wrong
|
|
6405
|
+
// issuer here would admit joiners under someone else's authority.
|
|
6406
|
+
issueMembershipInvite: (strandId) => this.issueStrandMembershipInvite(strandId)
|
|
5652
6407
|
});
|
|
5653
6408
|
// Register as responder on the control node
|
|
5654
6409
|
this.strandSolicitationService.registerResponder(this.controlNode);
|
|
@@ -5695,8 +6450,143 @@ export class CadreNode {
|
|
|
5695
6450
|
}
|
|
5696
6451
|
const result = await this.strandSolicitationService.formStrand(invitation, disclosure, this.controlNode);
|
|
5697
6452
|
this.recordCrossPartyStrandAddrs(result.strandId, result.strandAddrs);
|
|
6453
|
+
// A closed host strand's approval carries the joiner's own membership invitation —
|
|
6454
|
+
// persist this party's identity key and stage the invitation for bring-up to redeem.
|
|
6455
|
+
// Runs AFTER the addr recording so a persistence failure (which throws — see the
|
|
6456
|
+
// method) still leaves the cross-party seed in place for a manual recovery.
|
|
6457
|
+
if (result.membershipInvite) {
|
|
6458
|
+
await this.adoptFormationMembershipInvite(result.strandId, result.membershipInvite);
|
|
6459
|
+
}
|
|
5698
6460
|
return result;
|
|
5699
6461
|
}
|
|
6462
|
+
/**
|
|
6463
|
+
* Adopt an approved closed-strand formation's membership half on the JOINER:
|
|
6464
|
+
*
|
|
6465
|
+
* 1. Mint-or-reuse this party's own strand identity — {@link ensureStrandPartyKey}
|
|
6466
|
+
* with no explicit key returns the stored `StrandPartyKey` row when one exists (a
|
|
6467
|
+
* re-formation by an existing member party: lost addresses, an app reinstall with
|
|
6468
|
+
* an intact control DB) and mints + persists a fresh one otherwise. The invitation
|
|
6469
|
+
* will admit THIS key's public half as the `Strand.Member`.
|
|
6470
|
+
* 2. Stage the invitation in {@link pendingMembershipInvites} for the strand
|
|
6471
|
+
* bring-up flow (`strand-node-binds-member-peer`) to redeem via `consumeInvite`.
|
|
6472
|
+
*
|
|
6473
|
+
* Throws — failing the whole {@link formStrand} — when the identity cannot be
|
|
6474
|
+
* persisted: a joiner "joined" without a persistable identity could never become a
|
|
6475
|
+
* member, and failing loudly beats a silent half-member. The formation itself has
|
|
6476
|
+
* already succeeded by then and its one-time token is SPENT, so the error says so:
|
|
6477
|
+
* recovery is fixing the underlying cause (no owner signing key / control DB write
|
|
6478
|
+
* rejected) and redeeming a FRESH invitation.
|
|
6479
|
+
*/
|
|
6480
|
+
async adoptFormationMembershipInvite(strandId, invite) {
|
|
6481
|
+
try {
|
|
6482
|
+
await this.ensureStrandPartyKey(strandId);
|
|
6483
|
+
}
|
|
6484
|
+
catch (error) {
|
|
6485
|
+
throw new Error(`Formation for strand ${strandId} was approved (its one-time token is spent), but ` +
|
|
6486
|
+
'persisting this party\'s membership identity (StrandPartyKey) failed — the joiner ' +
|
|
6487
|
+
'cannot become a member without it. Fix the underlying cause, then redeem a fresh ' +
|
|
6488
|
+
'invitation (the delivered one dies with this error).', { cause: error });
|
|
6489
|
+
}
|
|
6490
|
+
// NOTE: staging alone is not enough on a node whose reconciler for this strand has
|
|
6491
|
+
// already finished. `StrandMembershipReconciler` latches a terminal stopped state on
|
|
6492
|
+
// its `done` path and is rebuilt only by a strand relaunch, so a RE-formation against
|
|
6493
|
+
// an already-launched strand stages an invitation no loop will ever look at. Harmless
|
|
6494
|
+
// for a party that is still a member (the invitation would only be burned), and the
|
|
6495
|
+
// live defect for one that has been REMOVED — which is exactly when an app re-forms.
|
|
6496
|
+
// Tracked as `backlog/bug-removed-party-cannot-redeem-its-way-back`; pinned by
|
|
6497
|
+
// `strand-party-removal-via-formation-e2e.integration.ts` (test 2).
|
|
6498
|
+
this.pendingMembershipInvites.set(strandId, invite);
|
|
6499
|
+
log('formStrand: staged membership invitation for strand %s (party key persisted)', strandId);
|
|
6500
|
+
}
|
|
6501
|
+
/**
|
|
6502
|
+
* The pending single-use membership invitation a closed-strand formation carried back
|
|
6503
|
+
* for `strandId`, or `undefined` when none is staged — the seam the strand bring-up
|
|
6504
|
+
* membership reconciler (`strand-membership-reconciler.ts`) reads to redeem the
|
|
6505
|
+
* joiner's `Strand.Member` seat. `undefined` therefore also means "already redeemed,
|
|
6506
|
+
* burned, or found dead": the reconciler clears the entry as soon as it settles the
|
|
6507
|
+
* invitation, so a caller polling this sees it disappear on its own. In-memory only;
|
|
6508
|
+
* see {@link pendingMembershipInvites} for lifetime and re-formation semantics.
|
|
6509
|
+
*/
|
|
6510
|
+
getPendingMembershipInvite(strandId) {
|
|
6511
|
+
return this.pendingMembershipInvites.get(strandId);
|
|
6512
|
+
}
|
|
6513
|
+
/**
|
|
6514
|
+
* Responder-side issuer behind the formation manager's `issueMembershipInvite` seam
|
|
6515
|
+
* (see `StrandFormationManagerOptions.issueMembershipInvite` for the contract this
|
|
6516
|
+
* implements): mint a single-use `Strand.Invite` against the LIVE host strand so a
|
|
6517
|
+
* validated joiner can seat its own `Strand.Member` row.
|
|
6518
|
+
*
|
|
6519
|
+
* - Open host strand → `null` (no members, nothing to invite into).
|
|
6520
|
+
* - Closed host strand whose founder launch was refused as pre-split
|
|
6521
|
+
* ({@link strandLaunchRefusals}) → rethrow the recorded `PreSplitStrandIdentityError`;
|
|
6522
|
+
* the manager maps it to the NON-retryable `HOST_STRAND_MUST_BE_RECREATED_REASON`.
|
|
6523
|
+
* - Closed host strand with no `StrandPartyKey` row → throw: this party's identity is
|
|
6524
|
+
* the invite's issuing manager, and without it nothing can sign the issuance. (The
|
|
6525
|
+
* founder's publish/launch paths mint it, so this is a not-yet-converged sibling.)
|
|
6526
|
+
* The manager maps the throw to a clean retryable rejection BEFORE the formation
|
|
6527
|
+
* token is spent.
|
|
6528
|
+
* - Closed host strand with no running local instance/database → throw, same mapping:
|
|
6529
|
+
* a joiner admitted without an invitation would look joined and never become a
|
|
6530
|
+
* member, and a responder not running the strand cannot serve its sync anyway.
|
|
6531
|
+
* - Closed host strand whose LIVE rows carry the pre-split fingerprint
|
|
6532
|
+
* (`assertNotPreSplitStrand`) → throw `PreSplitStrandIdentityError`, same mapping as
|
|
6533
|
+
* the recorded refusal. Covers the responders that never ran a refused founder launch:
|
|
6534
|
+
* a sibling machine of the founding party, or a node restarted since the refusal.
|
|
6535
|
+
*
|
|
6536
|
+
* The recorded refusal is checked first: it is an in-memory read and the only
|
|
6537
|
+
* permanent diagnosis. Identity is checked BEFORE the runtime: it is the cheaper read
|
|
6538
|
+
* and the more actionable diagnosis when both are missing (a missing runtime is
|
|
6539
|
+
* transient, a missing identity is not), and it keeps the branch reachable without
|
|
6540
|
+
* standing a strand runtime up.
|
|
6541
|
+
*
|
|
6542
|
+
* The invitation expires `MEMBERSHIP_INVITE_TTL_MS` from now — see that constant for
|
|
6543
|
+
* the slow-joiner / lost-result tradeoff.
|
|
6544
|
+
*
|
|
6545
|
+
* NOTE: the issuing identity must be a `Strand.Manager` (the schema's `InviteValid`
|
|
6546
|
+
* gate), and only the FOUNDING party's key is seated as one. Today only the founder
|
|
6547
|
+
* party can host a bound formation at all — a joining party never gets the host
|
|
6548
|
+
* strand's control `Strand` row, so `resolveStrand` reports `missing` on it — so this
|
|
6549
|
+
* never bites. If a joined party is ever able to host formations into a strand it
|
|
6550
|
+
* joined (re-invite / multi-hop join), issuance here fails the manager gate and every
|
|
6551
|
+
* such redemption rejects with the retryable-sounding
|
|
6552
|
+
* `MEMBERSHIP_INVITE_UNAVAILABLE_REASON` forever; that flow needs manager delegation,
|
|
6553
|
+
* not a retry.
|
|
6554
|
+
*/
|
|
6555
|
+
async issueStrandMembershipInvite(strandId) {
|
|
6556
|
+
if (!this.controlDatabase) {
|
|
6557
|
+
throw new Error(`Cannot issue a membership invitation for strand ${strandId}: control database unavailable`);
|
|
6558
|
+
}
|
|
6559
|
+
const row = await this.controlDatabase.queryStrand(strandId);
|
|
6560
|
+
if (!row) {
|
|
6561
|
+
// The bound resolution saw this row moments ago; a vanished row is a concurrent
|
|
6562
|
+
// unpublish — reject rather than invite into a strand this party just removed.
|
|
6563
|
+
throw new Error(`Cannot issue a membership invitation for strand ${strandId}: its Strand row is gone`);
|
|
6564
|
+
}
|
|
6565
|
+
if (row.Type !== 'c') {
|
|
6566
|
+
return null;
|
|
6567
|
+
}
|
|
6568
|
+
const refusal = this.strandLaunchRefusals.get(strandId);
|
|
6569
|
+
if (refusal) {
|
|
6570
|
+
throw refusal;
|
|
6571
|
+
}
|
|
6572
|
+
const partyKey = await this.controlDatabase.queryStrandPartyKey(strandId);
|
|
6573
|
+
if (partyKey === null) {
|
|
6574
|
+
throw new Error(`Cannot issue a membership invitation for closed strand ${strandId}: this party holds ` +
|
|
6575
|
+
'no StrandPartyKey row for it (identity not yet converged from the machine that published it)');
|
|
6576
|
+
}
|
|
6577
|
+
const db = this.strandManager.getInstance(strandId)?.database?.getDatabase();
|
|
6578
|
+
if (!db) {
|
|
6579
|
+
throw new Error(`Cannot issue a membership invitation for closed strand ${strandId}: its runtime is ` +
|
|
6580
|
+
'not live on this responder (not launched, hibernating, or quiescing)');
|
|
6581
|
+
}
|
|
6582
|
+
if (row.MemberPrivateKey) {
|
|
6583
|
+
await assertNotPreSplitStrand(db, strandId, strandMemberKeyPair(row.MemberPrivateKey).publicKeyB64);
|
|
6584
|
+
}
|
|
6585
|
+
return await issueInvite(db, {
|
|
6586
|
+
managerKeyPair: strandMemberKeyPair(partyKey),
|
|
6587
|
+
expiration: Date.now() + MEMBERSHIP_INVITE_TTL_MS
|
|
6588
|
+
});
|
|
6589
|
+
}
|
|
5700
6590
|
/**
|
|
5701
6591
|
* Remember the responder's strand-network addresses for a strand this node just
|
|
5702
6592
|
* formed, so the strand's discovery seed has something to work with when the app
|