@serfab/cadre-core 1.9.0 → 1.10.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.
Files changed (87) hide show
  1. package/dist/cadre-node.d.ts +148 -17
  2. package/dist/cadre-node.js +361 -45
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-authorization.d.ts +2 -2
  5. package/dist/control-authorization.js +1 -0
  6. package/dist/control-authorization.js.map +1 -1
  7. package/dist/control-cohort.d.ts +1 -1
  8. package/dist/control-cohort.js +1 -1
  9. package/dist/control-database.d.ts +105 -14
  10. package/dist/control-database.js +242 -73
  11. package/dist/control-database.js.map +1 -1
  12. package/dist/control-formation-recorder.d.ts +6 -6
  13. package/dist/control-formation-recorder.js +7 -8
  14. package/dist/control-formation-recorder.js.map +1 -1
  15. package/dist/control-schema.d.ts +1 -1
  16. package/dist/control-schema.js +90 -3
  17. package/dist/control-schema.js.map +1 -1
  18. package/dist/control-write-retry.d.ts +21 -15
  19. package/dist/control-write-retry.js +21 -15
  20. package/dist/control-write-retry.js.map +1 -1
  21. package/dist/index.d.ts +5 -3
  22. package/dist/index.js +6 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/invitation-bootstrap.d.ts +32 -0
  25. package/dist/invitation-bootstrap.js +30 -0
  26. package/dist/invitation-bootstrap.js.map +1 -0
  27. package/dist/link-budget.d.ts +204 -119
  28. package/dist/link-budget.js +208 -120
  29. package/dist/link-budget.js.map +1 -1
  30. package/dist/membership-connection-gater.d.ts +3 -3
  31. package/dist/membership-connection-gater.js +3 -3
  32. package/dist/peer-dial.d.ts +18 -14
  33. package/dist/peer-dial.js +18 -14
  34. package/dist/peer-dial.js.map +1 -1
  35. package/dist/peer-join-backfill.d.ts +3 -3
  36. package/dist/pending-join-runner.d.ts +186 -0
  37. package/dist/pending-join-runner.js +649 -0
  38. package/dist/pending-join-runner.js.map +1 -0
  39. package/dist/push-fanout.d.ts +1 -1
  40. package/dist/push-fanout.js +1 -1
  41. package/dist/seed-bootstrap.d.ts +2 -2
  42. package/dist/seed-bootstrap.js +1 -1
  43. package/dist/strand-addr-protocol.js +2 -2
  44. package/dist/strand-formation-deadlines.d.ts +5 -5
  45. package/dist/strand-formation-deadlines.js +5 -5
  46. package/dist/strand-formation-manager.d.ts +18 -4
  47. package/dist/strand-formation-manager.js +48 -33
  48. package/dist/strand-formation-manager.js.map +1 -1
  49. package/dist/strand-formation-protocol.d.ts +71 -14
  50. package/dist/strand-formation-protocol.js +222 -47
  51. package/dist/strand-formation-protocol.js.map +1 -1
  52. package/dist/strand-formation-rejection.d.ts +94 -0
  53. package/dist/strand-formation-rejection.js +77 -0
  54. package/dist/strand-formation-rejection.js.map +1 -0
  55. package/dist/strand-instance-manager.js +4 -1
  56. package/dist/strand-instance-manager.js.map +1 -1
  57. package/dist/strand-solicitation.d.ts +11 -3
  58. package/dist/strand-solicitation.js +9 -0
  59. package/dist/strand-solicitation.js.map +1 -1
  60. package/dist/types.d.ts +103 -17
  61. package/dist/types.js +5 -9
  62. package/dist/types.js.map +1 -1
  63. package/package.json +22 -22
  64. package/src/cadre-node.ts +399 -46
  65. package/src/control-authorization.ts +117 -116
  66. package/src/control-cohort.ts +1 -1
  67. package/src/control-database.ts +303 -82
  68. package/src/control-formation-recorder.ts +8 -9
  69. package/src/control-schema.ts +90 -3
  70. package/src/control-write-retry.ts +21 -15
  71. package/src/index.ts +21 -1
  72. package/src/invitation-bootstrap.ts +40 -0
  73. package/src/link-budget.ts +221 -120
  74. package/src/membership-connection-gater.ts +3 -3
  75. package/src/peer-dial.ts +18 -14
  76. package/src/peer-join-backfill.ts +3 -3
  77. package/src/pending-join-runner.ts +735 -0
  78. package/src/push-fanout.ts +1 -1
  79. package/src/seed-bootstrap.ts +2 -2
  80. package/src/strand-addr-protocol.ts +2 -2
  81. package/src/strand-formation-deadlines.ts +5 -5
  82. package/src/strand-formation-manager.ts +62 -37
  83. package/src/strand-formation-protocol.ts +1036 -802
  84. package/src/strand-formation-rejection.ts +129 -0
  85. package/src/strand-instance-manager.ts +4 -1
  86. package/src/strand-solicitation.ts +17 -4
  87. package/src/types.ts +99 -17
@@ -32,12 +32,16 @@ import { PeerJoinBackfill } from './peer-join-backfill.js';
32
32
  import { deriveCohortMembers } from './strand-cohort.js';
33
33
  import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
34
34
  import { dialPeerAddrs, SelfRelayOnlyError, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS } from './peer-dial.js';
35
- import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
35
+ import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
36
36
  import { EnrollmentService } from './enrollment.js';
37
37
  import { HibernationManager } from './hibernation-manager.js';
38
- import { ControlDatabase, isStrandIdConflict } from './control-database.js';
38
+ import { ControlDatabase, isPendingJoinConflict, isStrandIdConflict, pendingJoinId } from './control-database.js';
39
+ import { FormationPostApprovalError } from './strand-formation-rejection.js';
40
+ import { PendingJoinRunner, membershipInvitesToStage, parseStoredDisclosure, requestedPendingJoin } from './pending-join-runner.js';
39
41
  import { SeedBootstrapService } from './seed-bootstrap.js';
40
42
  import { StrandSolicitationService } from './strand-solicitation.js';
43
+ import { ControlFormationUsageRecorder } from './control-formation-recorder.js';
44
+ import { selectInvitationSiblingAddrs } from './invitation-bootstrap.js';
41
45
  import { createMembershipConnectionGater, DEFAULT_ENROLLMENT_WINDOW_MS, UnauthorizedReservationBudget } from './membership-connection-gater.js';
42
46
  import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
43
47
  import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
@@ -250,6 +254,15 @@ export class CadreNode {
250
254
  this.strandWatcher = null;
251
255
  this.seedBootstrapService = null;
252
256
  this.strandSolicitationService = null;
257
+ /**
258
+ * The service whose formation handler is registered on the control node. Trails
259
+ * {@link strandSolicitationService} while {@link initializeStrandSolicitation} swaps a new
260
+ * one in: the field is set at once so concurrent callers reuse it, the handler only when
261
+ * its queued swap runs.
262
+ */
263
+ this.registeredSolicitation = null;
264
+ /** Tail of the queued formation-handler swaps ({@link swapFormationResponder}). */
265
+ this.solicitationSwaps = Promise.resolve();
253
266
  this.strandWakeService = null;
254
267
  /**
255
268
  * Control-network strand-address responder. Answers a co-cadre sibling's
@@ -517,6 +530,20 @@ export class CadreNode {
517
530
  * `unpublishStrand` / `forgetJoinedStrand` as {@link formationStrandAddrs} is.
518
531
  */
519
532
  this.pendingMembershipInvites = new Map();
533
+ /**
534
+ * Invite keys of the membership invitations this process has staged, by
535
+ * {@link adoptFormationMembershipInvite} or from a `joined` `PendingJoin` row
536
+ * ({@link stageMembershipInvitesFromPendingJoins}), so one the reconciler has settled is not
537
+ * staged again by the next pending-join pass. Same lifetime as
538
+ * {@link pendingMembershipInvites}: a restarted process stages each live one once more, and the
539
+ * reconciler settles it again (spent, or the party already seated).
540
+ */
541
+ this.stagedMembershipInviteKeys = new Set();
542
+ /**
543
+ * The pending-join retry loop (`pending-join-runner.ts`): built by {@link start}, stopped by
544
+ * {@link cleanup}. It acts only while this machine is an enrolled owner.
545
+ */
546
+ this.pendingJoinRunner = null;
520
547
  /**
521
548
  * Founder launches refused because the strand was founded before the per-party
522
549
  * identity split (`PreSplitStrandIdentityError`), keyed by strandId. Read by
@@ -1010,6 +1037,11 @@ export class CadreNode {
1010
1037
  onDelegateAnnounce: (announcer, strandId, delegate) => this.grantDelegateAdmission(announcer, strandId, delegate),
1011
1038
  });
1012
1039
  await this.strandAddrService.initialize(this.controlNode);
1040
+ // Answer strand formation for this party from now on. Every machine of a party
1041
+ // holds the replicated FormationInvite/FormationUsage rows, so an always-on one can
1042
+ // check a token for an inviter that is offline. After the control database is up,
1043
+ // so the responder never reads a database that cannot answer yet.
1044
+ await this.installDefaultFormationResponder();
1013
1045
  // Server-side push-wake fan-out: only when push is configured.
1014
1046
  if (this.config.push) {
1015
1047
  this.pushFanoutService = this.buildPushFanout(this.config.push);
@@ -1043,6 +1075,8 @@ export class CadreNode {
1043
1075
  await this.driveControlRelayReservation();
1044
1076
  // Schedule self-registration in background
1045
1077
  this.scheduleSelfRegistration();
1078
+ // Last: its first pass may dial another party's machines.
1079
+ this.startPendingJoinRunner();
1046
1080
  }
1047
1081
  catch (error) {
1048
1082
  log('Failed to start CadreNode: %o', error);
@@ -1593,11 +1627,15 @@ export class CadreNode {
1593
1627
  // replaces the default whole (see DEFAULT_CONNECTION_MONITOR).
1594
1628
  connectionMonitor: network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
1595
1629
  // The declared link every cadre budget is derived from, stated to Optimystic too so it
1596
- // derives its own deadlines from the same number: its request dials and responses, its
1597
- // block pushes, and libp2p's `dialTimeout` and `inboundUpgradeTimeout`. Always stated,
1598
- // default included: undeclared, Optimystic keeps LAN deadlines that cannot open a
1599
- // relayed connection at the link sereus supports (`link-budget.ts`).
1630
+ // derives its own deadlines from the same number: its request responses, its block
1631
+ // pushes, and libp2p's `inboundUpgradeTimeout`. Always stated, default included:
1632
+ // undeclared, Optimystic keeps LAN deadlines that cannot open a relayed connection at the
1633
+ // link sereus supports (`link-budget.ts`).
1600
1634
  linkRoundTripMs: resolveLinkRoundTripMs(network?.linkRoundTripMs),
1635
+ // The three limits on opening a connection (libp2p's per-address and whole-dial limits,
1636
+ // and Optimystic's request dial): Optimystic's derivation from the same link plus the
1637
+ // relay's and the called machine's admission decisions, which run on this dial's clock.
1638
+ ...optimysticDialLimits(network?.linkRoundTripMs),
1601
1639
  // `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
1602
1640
  // `{}` when `network.transports` is set, since the embedder owns transport policy
1603
1641
  // then. Spread NEXT to `transports` because the two answer the same question.
@@ -1670,11 +1708,9 @@ export class CadreNode {
1670
1708
  * `StrandSolicitationService.hasOutstandingInvitation`). A formation
1671
1709
  * initiator is another party's peer by design and its token is only
1672
1710
  * checkable inside the protocol, so the gate asks the coarser question
1673
- * "does this node expect a stranger at all?". Merely REGISTERING the
1674
- * responder ({@link initializeStrandSolicitation}) no longer suspends
1675
- * stranger denial — eager registration (as `reference-app-rn` does at
1676
- * bring-up) and `formStrand`'s lazy initialization both leave the gate
1677
- * armed, because neither mints an invitation.
1711
+ * "does this node expect a stranger at all?". REGISTERING the responder
1712
+ * does not suspend stranger denial: every node registers one at
1713
+ * {@link start}, and registering mints no invitation.
1678
1714
  *
1679
1715
  * Ordering is semantically free (the checks are OR'd) but decides who pays:
1680
1716
  * checks 1-3 are in-memory, 4/5 share one control-DB read, and only a peer
@@ -1706,6 +1742,11 @@ export class CadreNode {
1706
1742
  * layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
1707
1743
  * read is safe here — unlike the per-stream gate, which must consult the
1708
1744
  * materialized {@link authorizedControlPeers} snapshot instead.
1745
+ * NOTE: check 6 adds a second control read (`hasOutstandingFormationInvite`) for a
1746
+ * stranger with no locally minted invitation in play, on every node now that every
1747
+ * node runs the responder — a relay-enabled storage node included. If stranger
1748
+ * connections to such a node ever arrive fast enough for that read to show, cache
1749
+ * the answer for a few seconds.
1709
1750
  * NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
1710
1751
  * replicated here is denied until the row converges (typically via the
1711
1752
  * owner); either side's next outbound reconcile dial (outbound is never
@@ -2549,19 +2590,27 @@ export class CadreNode {
2549
2590
  * handling, is what keeps a mixed list from taking a whole peer offline.
2550
2591
  */
2551
2592
  async resolvePeerAddrs(peerId, opts = {}) {
2593
+ return (await this.resolvePeerRecord(peerId, opts))?.addrs ?? [];
2594
+ }
2595
+ /**
2596
+ * {@link resolvePeerAddrs} with the record's `UpdatedAt` kept beside the addresses, for a
2597
+ * caller that ranks peers by how recently they published. `null` wherever that returns `[]`
2598
+ * for a failed gate.
2599
+ */
2600
+ async resolvePeerRecord(peerId, opts) {
2552
2601
  if (!this.controlDatabase) {
2553
2602
  throw new Error('CadreNode must be started before resolving peer addrs');
2554
2603
  }
2555
2604
  const record = await this.controlDatabase.queryPeerRecord(peerId);
2556
2605
  if (!record) {
2557
2606
  log('resolvePeerAddrs: no record for %s', peerId);
2558
- return [];
2607
+ return null;
2559
2608
  }
2560
2609
  // publicKey <-> peerId binding: the stored key must be the one embedded in
2561
2610
  // the requested Ed25519 peer id (also rejects a non-Ed25519 / missing key).
2562
2611
  if (!record.publicKey || ed25519PublicKeyB64FromPeerId(peerId) !== record.publicKey) {
2563
2612
  log('resolvePeerAddrs: publicKey does not match peerId for %s', peerId);
2564
- return [];
2613
+ return null;
2565
2614
  }
2566
2615
  // Self-signature over (peerId, addrs, updatedAt).
2567
2616
  if (!verifyPeerRecordSignature(record)) {
@@ -2569,13 +2618,13 @@ export class CadreNode {
2569
2618
  // no addrs — a not-yet-self-published row)" from a genuinely corrupt or
2570
2619
  // mixed-revision row. Sig/addrs are public replicated data.
2571
2620
  log('resolvePeerAddrs: signature verification failed for %s (updatedAt=%d, addrs=%o, sig=%s)', peerId, record.updatedAt, record.addrs, record.sig ? `${record.sig.slice(0, 16)}…` : '(empty)');
2572
- return [];
2621
+ return null;
2573
2622
  }
2574
2623
  // Freshness: never hand back a dead relay reservation.
2575
2624
  const maxAgeMs = opts.maxAgeMs ?? DEFAULT_PEER_RECORD_MAX_AGE_MS;
2576
2625
  if (!isPeerRecordFresh(record.updatedAt, maxAgeMs, Date.now())) {
2577
2626
  log('resolvePeerAddrs: record for %s is stale (updatedAt=%d, maxAgeMs=%d)', peerId, record.updatedAt, maxAgeMs);
2578
- return [];
2627
+ return null;
2579
2628
  }
2580
2629
  // Pluggable trust gate (defaults to current-member).
2581
2630
  const trustPolicy = opts.trustPolicy ?? currentMemberTrustPolicy();
@@ -2587,7 +2636,7 @@ export class CadreNode {
2587
2636
  });
2588
2637
  if (!trusted) {
2589
2638
  log('resolvePeerAddrs: trust policy rejected %s', peerId);
2590
- return [];
2639
+ return null;
2591
2640
  }
2592
2641
  // Order signaling-first (the on-record order was what we verified above),
2593
2642
  // optionally restrict to signaling addrs, then parse — dropping any addr
@@ -2597,7 +2646,7 @@ export class CadreNode {
2597
2646
  if (opts.signalingOnly) {
2598
2647
  addrs = addrs.filter(isSignalingAddr);
2599
2648
  }
2600
- return this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId);
2649
+ return { addrs: this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId), updatedAt: record.updatedAt };
2601
2650
  }
2602
2651
  /**
2603
2652
  * Guarantee every address handed to a control-network dial terminates in
@@ -3131,15 +3180,18 @@ export class CadreNode {
3131
3180
  *
3132
3181
  * The list may name transports this node cannot dial (a lent node reports TCP
3133
3182
  * and `/ws` addresses to a phone that dials WebSockets only). That needs no
3134
- * filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.1.3)
3183
+ * filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.3.11)
3135
3184
  * rejects an address no transport can dial before touching the network, so
3136
3185
  * such an address costs a log line.
3137
3186
  *
3138
3187
  * The list is NOT handed to one `dial()`. libp2p tries a multi-address dial's
3139
- * addresses one after another under a single deadline, with no limit per
3140
- * address, and sorts loopback addresses last — so one or two addresses that
3141
- * never answer used up the whole deadline before the address that worked was
3142
- * tried. {@link dialPeerAddrs} dials each address on its own time limit instead.
3188
+ * addresses one after another under a single deadline, and sorts loopback
3189
+ * addresses last — so one or two addresses that never answer use up most of
3190
+ * the deadline before the address that works is tried. libp2p 3.3's
3191
+ * per-address `addressDialTimeout` does not prevent that: cadre sizes it for a
3192
+ * cold relayed open through two admission decisions (`optimysticDialLimits`,
3193
+ * 39 s at the default declared link).
3194
+ * {@link dialPeerAddrs} dials each address on its own time limit instead.
3143
3195
  */
3144
3196
  async resolveControlDialAddrs(peerId, resolved) {
3145
3197
  if (resolved.length > 0) {
@@ -3504,6 +3556,8 @@ export class CadreNode {
3504
3556
  }
3505
3557
  // 4. In-session pending owner authorize writes.
3506
3558
  await this.drainPendingPeerWrites();
3559
+ // 5. `PendingJoin` rows this process wrote while alone. Their tombstones went out in step 1.
3560
+ await this.pendingJoinRunner?.reissueWritesMadeAlone();
3507
3561
  }
3508
3562
  /**
3509
3563
  * Re-touch this node's own `DeviceToken` row (re-sign + bump `UpdatedAt` via
@@ -4147,6 +4201,10 @@ export class CadreNode {
4147
4201
  // it is stopped here.
4148
4202
  this.relayReserveSupervisor?.stop();
4149
4203
  this.relayReserveSupervisor = null;
4204
+ // Before anything it reads is torn down. An attempt in flight is not awaited (NOTE at
4205
+ // `PendingJoinRunner.stop`).
4206
+ this.pendingJoinRunner?.stop();
4207
+ this.pendingJoinRunner = null;
4150
4208
  // Drop the rest of the relay-reservation posture: a torn-down node holds no
4151
4209
  // reservation, so neither a restarted instance nor a caller inspecting a failed
4152
4210
  // start reports the previous attempt's `reserved`.
@@ -4185,11 +4243,14 @@ export class CadreNode {
4185
4243
  await this.pushFanoutService.close().catch((err) => log('Push fan-out close failed: %o', err));
4186
4244
  this.pushFanoutService = null;
4187
4245
  }
4188
- // Unregister strand solicitation service
4189
- if (this.strandSolicitationService && this.controlNode) {
4190
- await this.strandSolicitationService.unregisterResponder(this.controlNode);
4191
- this.strandSolicitationService = null;
4246
+ // Unregister the formation responder once any queued swap has run, so no swap
4247
+ // registers a handler after this. A restarted node installs a fresh one.
4248
+ await this.solicitationSwaps;
4249
+ if (this.registeredSolicitation && this.controlNode) {
4250
+ await this.registeredSolicitation.unregisterResponder(this.controlNode);
4192
4251
  }
4252
+ this.registeredSolicitation = null;
4253
+ this.strandSolicitationService = null;
4193
4254
  // Stop strand watcher
4194
4255
  if (this.strandWatcher) {
4195
4256
  await this.strandWatcher.stop();
@@ -5658,9 +5719,9 @@ export class CadreNode {
5658
5719
  * caller runs the drive regardless).
5659
5720
  *
5660
5721
  * NOTE: against a relay that is DOWN this hook costs up to two strand-addr
5661
- * timeouts (dial by peer id, then by addr; 23 s each at the default declared link round trip)
5722
+ * timeouts (dial by peer id, then by addr; 28.5 s each at the default declared link round trip)
5662
5723
  * before the reservation drive even starts, so one failed re-drive holds the supervisor
5663
- * `driving` for those 46 s plus the drive's own deadline — 64 s at the default, and longer on
5724
+ * `driving` for those 57 s plus the drive's own deadline — 75 s at the default, and longer on
5664
5725
  * a host that declared a slower one (`link-budget.ts`).
5665
5726
  * Bounded and harmless while the relay is unreachable anyway; if recovery
5666
5727
  * latency after a relay comes back ever matters, skip the announce when the
@@ -6581,6 +6642,9 @@ export class CadreNode {
6581
6642
  ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
6582
6643
  }), this.controlNode, this.controlDatabase);
6583
6644
  log('Seed bootstrap service initialized');
6645
+ // Embedders wire the owner key after start(), so the runner's first pass usually found
6646
+ // this machine not yet an owner; without this it would wait a full poll interval.
6647
+ this.pendingJoinRunner?.kick();
6584
6648
  }
6585
6649
  /**
6586
6650
  * Push the multiaddrs that future invites should advertise. Pass `null` to
@@ -7260,19 +7324,32 @@ export class CadreNode {
7260
7324
  // Strand Solicitation API (native cadre-core formation transport)
7261
7325
  // ============================================================================
7262
7326
  /**
7263
- * Initialize the strand solicitation service.
7264
- * This enables forming strands with other parties via open invitations.
7327
+ * Replace this node's strand solicitation service — the formation responder every node
7328
+ * installs at {@link start}, and the initiator side of {@link formStrand}. Optional: call it
7329
+ * only to customize the responder (an approver, a provisioner, formation deadlines). The
7330
+ * new service's handler replaces the previous one's on the control node, and invitations
7331
+ * the previous service minted stay outstanding.
7332
+ *
7333
+ * Without `options.formationUsageRecorder`, tokens are checked against this party's
7334
+ * replicated `FormationInvite`/`FormationUsage` rows ({@link ControlFormationUsageRecorder}).
7335
+ * A responder that accepts every token is reachable only by constructing
7336
+ * {@link StrandSolicitationService} directly.
7265
7337
  *
7266
7338
  * @param options - Configuration for the solicitation service
7267
7339
  */
7268
7340
  async initializeStrandSolicitation(options) {
7269
- if (!this.controlNode) {
7341
+ const controlNode = this.controlNode;
7342
+ const controlDatabase = this.controlDatabase;
7343
+ if (!controlNode || !controlDatabase) {
7270
7344
  throw new Error('CadreNode must be started before initializing strand solicitation');
7271
7345
  }
7272
7346
  const service = new StrandSolicitationService({
7273
7347
  ...options,
7348
+ formationUsageRecorder: options?.formationUsageRecorder ?? new ControlFormationUsageRecorder(controlDatabase),
7274
7349
  partyId: this.config.controlNetwork.partyId,
7275
- cadrePeerAddrs: this.getMultiaddrs(),
7350
+ // Read per formation: the responder is installed during start, before a relay
7351
+ // reservation gives a relay-only node any address, and addresses change after.
7352
+ cadrePeerAddrs: () => this.getMultiaddrs(),
7276
7353
  // Every formation deadline derives from this node's declared link, like every other
7277
7354
  // dial budget here; a caller's own declaration on the formation config still wins.
7278
7355
  formationConfig: {
@@ -7288,25 +7365,60 @@ export class CadreNode {
7288
7365
  // issuer here would admit joiners under someone else's authority.
7289
7366
  issueMembershipInvite: (strandId, signal) => this.issueStrandMembershipInvite(strandId, signal)
7290
7367
  });
7291
- // Set before the registration is awaited, so a concurrent createOpenInvitation /
7292
- // formStrand uses this service instead of building a second one whose handler
7293
- // would collide; a failed registration puts the previous service back.
7294
- // NOTE: same stray-handler caveat as installSeedBootstrapService — a failed
7295
- // peer-store merge leaves the formation handler registered, so a retry is a duplicate.
7296
7368
  const previous = this.strandSolicitationService;
7369
+ if (previous) {
7370
+ service.adoptMintedInvitations(previous);
7371
+ }
7372
+ // Set before the swap is awaited, so a concurrent createOpenInvitation / formStrand
7373
+ // uses this service instead of building another.
7297
7374
  this.strandSolicitationService = service;
7375
+ const swap = this.solicitationSwaps.then(() => this.swapFormationResponder(service, controlNode));
7376
+ // The queue only orders swaps; a failed one rejects this call's own await below.
7377
+ this.solicitationSwaps = swap.catch(() => undefined);
7378
+ await swap;
7379
+ log('Strand solicitation service initialized');
7380
+ }
7381
+ /**
7382
+ * Answer formation from start. Log-and-continue: a node that cannot is still useful, and
7383
+ * {@link createOpenInvitation} / {@link formStrand} install the responder when it is missing.
7384
+ */
7385
+ async installDefaultFormationResponder() {
7386
+ try {
7387
+ await this.initializeStrandSolicitation();
7388
+ }
7389
+ catch (error) {
7390
+ log('Formation responder not installed at start; createOpenInvitation/formStrand will retry: %o', error);
7391
+ }
7392
+ }
7393
+ /**
7394
+ * Move the control node's formation handler from the registered service to `service`.
7395
+ * Queued by {@link initializeStrandSolicitation} so swaps run one at a time: libp2p allows
7396
+ * one handler per protocol id, and two overlapping swaps would each find the other's
7397
+ * handler in the way. A failed swap registers the previous service again, points
7398
+ * {@link strandSolicitationService} back at it unless a later call has replaced it, and
7399
+ * rethrows.
7400
+ *
7401
+ * NOTE: same stray-handler caveat as {@link installSeedBootstrapService} — a failed
7402
+ * peer-store merge leaves the formation handler registered, so the restore below (and a
7403
+ * retry) rejects as a duplicate.
7404
+ */
7405
+ async swapFormationResponder(service, controlNode) {
7406
+ const previous = this.registeredSolicitation;
7298
7407
  try {
7299
- await service.registerResponder(this.controlNode);
7408
+ await previous?.unregisterResponder(controlNode);
7409
+ await service.registerResponder(controlNode);
7410
+ this.registeredSolicitation = service;
7300
7411
  }
7301
7412
  catch (error) {
7302
7413
  if (this.strandSolicitationService === service)
7303
7414
  this.strandSolicitationService = previous;
7415
+ await previous?.registerResponder(controlNode).catch((restoreError) => log('Re-registering the previous formation responder failed; none answers until the next initializeStrandSolicitation: %o', restoreError));
7304
7416
  throw error;
7305
7417
  }
7306
- log('Strand solicitation service initialized');
7307
7418
  }
7308
7419
  /**
7309
- * Get the strand solicitation service (for advanced use)
7420
+ * Get the strand solicitation service (for advanced use). Non-null on a started node unless
7421
+ * the install at {@link start} failed.
7310
7422
  */
7311
7423
  getStrandSolicitationService() {
7312
7424
  return this.strandSolicitationService;
@@ -7314,28 +7426,66 @@ export class CadreNode {
7314
7426
  /**
7315
7427
  * Create an open invitation for others to form strands with this party.
7316
7428
  *
7429
+ * The invitation's bootstrap list names this machine first (it is the one most likely to
7430
+ * run the host strand), then a few of the party's other machines
7431
+ * ({@link siblingInvitationAddrs}), so a joiner can still form while this one is offline.
7432
+ *
7317
7433
  * @param sAppId - The sApp to use for formed strands
7318
7434
  * @param expirationMs - How long the invitation is valid (ms from now)
7319
7435
  * @returns The open invitation to share out-of-band
7436
+ * @throws when neither this machine nor any other machine of the party has an address
7320
7437
  */
7321
7438
  async createOpenInvitation(sAppId, expirationMs = 24 * 60 * 60 * 1000 // 24 hours default
7322
7439
  ) {
7323
7440
  if (!this.strandSolicitationService) {
7324
- // Create a temporary service for creating invitations
7441
+ // Only when the install at start failed.
7325
7442
  await this.initializeStrandSolicitation();
7326
7443
  }
7327
- const bootstrap = this.getMultiaddrs();
7444
+ const bootstrap = [...this.getMultiaddrs(), ...await this.siblingInvitationAddrs()];
7328
7445
  if (bootstrap.length === 0) {
7329
7446
  throw new Error('No multiaddrs available for invitation');
7330
7447
  }
7331
7448
  return await this.strandSolicitationService.createOpenInvitation(sAppId, expirationMs, bootstrap);
7332
7449
  }
7333
7450
  /**
7334
- * Form a strand with a responder via an open invitation.
7451
+ * Addresses of the party's other machines for an invitation's bootstrap list
7452
+ * ({@link selectInvitationSiblingAddrs} picks which). The source is each authorized
7453
+ * member's signed `CadrePeer` record ({@link resolvePeerRecord}), so a machine whose record
7454
+ * is missing, stale or untrusted is left out; live connections only order them.
7455
+ *
7456
+ * Best-effort: a failed read leaves the invitation naming this machine alone, which is what
7457
+ * it named before siblings were added.
7458
+ */
7459
+ async siblingInvitationAddrs() {
7460
+ const controlNode = this.controlNode;
7461
+ if (!controlNode || !this.controlDatabase) {
7462
+ return [];
7463
+ }
7464
+ try {
7465
+ const siblings = [];
7466
+ for (const { peerId } of await this.listAuthorizedMembers()) {
7467
+ const resolved = await this.resolvePeerRecord(peerId, {});
7468
+ if (resolved && resolved.addrs.length > 0) {
7469
+ siblings.push({ peerId, updatedAt: resolved.updatedAt, addrs: resolved.addrs.map(String) });
7470
+ }
7471
+ }
7472
+ const connected = new Set(controlNode.getConnections().map((c) => c.remotePeer.toString()));
7473
+ return selectInvitationSiblingAddrs(siblings, connected);
7474
+ }
7475
+ catch (error) {
7476
+ log('createOpenInvitation: resolving the other machines failed; the invitation names only this one: %o', error);
7477
+ return [];
7478
+ }
7479
+ }
7480
+ /**
7481
+ * Form a strand with a responder via an open invitation: one attempt, from this machine, with
7482
+ * nothing recorded party-wide. {@link requestJoin} is the path that keeps trying.
7335
7483
  *
7336
7484
  * @param invitation - The open invitation received out-of-band
7337
7485
  * @param disclosure - Identity/context information to share with the responder
7338
7486
  * @returns The member key and strand info if successful
7487
+ * @throws `FormationRejectedError` or `FormationUnreachableError` when the formation fails, and
7488
+ * `FormationPostApprovalError` when it was approved and a step on this machine then failed
7339
7489
  */
7340
7490
  async formStrand(invitation, disclosure = {}) {
7341
7491
  if (!this.controlNode) {
@@ -7356,6 +7506,171 @@ export class CadreNode {
7356
7506
  await this.rememberFormedStrand(result);
7357
7507
  return result;
7358
7508
  }
7509
+ /**
7510
+ * Ask to join through `invitation` and have the party keep trying until the join works, the
7511
+ * invitation is used up or expires, or the request is dismissed: across restarts, and from
7512
+ * any owner machine of the party (`docs/strands.md` → "Joining while the inviter is
7513
+ * offline"). Records a party-wide `PendingJoin` row, runs one attempt on this machine at
7514
+ * once, and returns the status after it: `'joined'` when the inviter answered yes, `'waiting'`
7515
+ * when it was unreachable or not ready, `'failed'` when it refused for good.
7516
+ *
7517
+ * Asking again for an invitation whose request is still pending adopts that request as it
7518
+ * is; asking again after it finished starts a fresh one. Later changes arrive as
7519
+ * `pendingJoin:changed`. A joined strand is offered through `strand:discovered` like any
7520
+ * {@link formStrand} join. Do not also call {@link formStrand} with the same invitation:
7521
+ * nothing coordinates the two, and one of them gets `token-spent`.
7522
+ *
7523
+ * @throws on a machine that is not an enrolled owner (the row is owner-signed), and on an
7524
+ * invitation already past its expiration
7525
+ */
7526
+ async requestJoin(invitation, disclosure = {}) {
7527
+ const { database, runner } = this.requirePendingJoins();
7528
+ const signer = await this.requireOwnerSigner('requestJoin needs an enrolled owner machine of this party, because the party-wide join request is ' +
7529
+ 'owner-signed. formStrand(invitation, disclosure) joins once from this machine, with no retries.');
7530
+ const now = Date.now();
7531
+ const expiresAt = invitation.expiration.getTime();
7532
+ if (!Number.isFinite(expiresAt)) {
7533
+ throw new Error('The invitation carries no valid expiration');
7534
+ }
7535
+ if (now >= expiresAt) {
7536
+ throw new Error(`The invitation expired at ${invitation.expiration.toISOString()}; ask the inviter for a fresh one`);
7537
+ }
7538
+ const requested = requestedPendingJoin(pendingJoinId(invitation.token), this.encodeInvitation(invitation), disclosure, expiresAt, now);
7539
+ const row = await this.recordJoinRequest(database, requested, signer);
7540
+ log('requestJoin: pending join %s recorded; attempting now', row.Id);
7541
+ return runner.attemptNow(row);
7542
+ }
7543
+ /**
7544
+ * Every join asked for with {@link requestJoin} and not dismissed, pending or finished, as this
7545
+ * machine sees it (see `PendingJoinStatus` for which states are machine-local).
7546
+ */
7547
+ async listPendingJoins() {
7548
+ const { database, runner } = this.requirePendingJoins();
7549
+ return (await database.queryPendingJoins()).map((row) => runner.statusOf(row));
7550
+ }
7551
+ /**
7552
+ * Remove a join request party-wide. On a pending request this cancels it: every owner machine
7553
+ * stops at its next read, and an attempt already running does not record its outcome (a join
7554
+ * it made stays on that machine, as any {@link formStrand} join does). Owner machines only.
7555
+ * Resolves `false` when there was no such request.
7556
+ */
7557
+ async dismissPendingJoin(id) {
7558
+ const { database, runner } = this.requirePendingJoins();
7559
+ const { ownerKey, signMessage } = await this.requireOwnerSigner('dismissPendingJoin needs an enrolled owner machine of this party: the removal is owner-signed');
7560
+ const removed = await database.deletePendingJoin(id, ownerKey, signMessage);
7561
+ runner.forgetRow(id);
7562
+ return removed;
7563
+ }
7564
+ requirePendingJoins() {
7565
+ if (!this._running || !this.controlDatabase || !this.pendingJoinRunner) {
7566
+ throw new Error('CadreNode must be started before using pending joins');
7567
+ }
7568
+ return { database: this.controlDatabase, runner: this.pendingJoinRunner };
7569
+ }
7570
+ /** This machine's owner key and signer for an owner-signed control write, or a throw carrying `refusal`. */
7571
+ async requireOwnerSigner(refusal) {
7572
+ const signingKey = await this.enrolledOwnerSigningKey();
7573
+ if (!signingKey) {
7574
+ throw new Error(refusal);
7575
+ }
7576
+ return { ownerKey: signingKey.publicKeyB64, signMessage: signMessageWith(signingKey.privateKeyB64) };
7577
+ }
7578
+ /**
7579
+ * Write a join request's row, or adopt the party's existing row for the same invitation: a
7580
+ * pending one as it is, a finished one replaced by `requested` (the user asked again).
7581
+ */
7582
+ async recordJoinRequest(database, requested, { ownerKey, signMessage }) {
7583
+ let written;
7584
+ try {
7585
+ written = await database.insertPendingJoin(requested, ownerKey, signMessage);
7586
+ }
7587
+ catch (error) {
7588
+ if (!isPendingJoinConflict(error)) {
7589
+ throw error;
7590
+ }
7591
+ const existing = await database.queryPendingJoin(requested.Id);
7592
+ if (!existing) {
7593
+ throw new Error(`A removed join request for this invitation (${requested.Id}) is still held on this machine until its ` +
7594
+ 'clean-up runs, which needs a connection to the party. Ask again once connected.', { cause: error });
7595
+ }
7596
+ if (existing.Outcome === null) {
7597
+ return existing;
7598
+ }
7599
+ written = await database.replacePendingJoin(existing.StampId, requested, ownerKey, signMessage);
7600
+ }
7601
+ this.pendingJoinRunner?.noteWritten(written);
7602
+ return written;
7603
+ }
7604
+ /** Build and start the pending-join retry loop over this node (see {@link pendingJoinRunner}). */
7605
+ startPendingJoinRunner() {
7606
+ const database = () => {
7607
+ if (!this.controlDatabase) {
7608
+ throw new Error('CadreNode is stopped: no control database for pending joins');
7609
+ }
7610
+ return this.controlDatabase;
7611
+ };
7612
+ const outcomeSigner = () => this.requireOwnerSigner('Cannot record a pending join outcome: this machine is not an enrolled owner of the party');
7613
+ this.pendingJoinRunner = new PendingJoinRunner({
7614
+ selfId: this.controlNode.peerId.toString(),
7615
+ linkRoundTripMs: this.config.network?.linkRoundTripMs,
7616
+ isOwner: async () => (await this.enrolledOwnerSigningKey()) !== null,
7617
+ // NOTE: one owner-key read and one `PendingJoin` read per pass (30 s); for a party that never
7618
+ // asked for a join the second reads a never-written block, which consults the cohort. If it
7619
+ // shows up in a device profile, poll more slowly while the table reads empty.
7620
+ readRows: () => database().queryPendingJoins(),
7621
+ readRow: (id) => database().queryPendingJoin(id),
7622
+ attempt: (row) => this.formStrand(this.pendingJoinInvitation(row), parseStoredDisclosure(row)),
7623
+ replace: async (expectedStampId, next) => {
7624
+ const { ownerKey, signMessage } = await outcomeSigner();
7625
+ return database().replacePendingJoin(expectedStampId, next, ownerKey, signMessage);
7626
+ },
7627
+ remove: async (id) => {
7628
+ const { ownerKey, signMessage } = await outcomeSigner();
7629
+ return database().deletePendingJoin(id, ownerKey, signMessage);
7630
+ },
7631
+ isAlone: () => this.committedAlone(),
7632
+ sAppIdOf: (row) => this.pendingJoinSAppId(row),
7633
+ observeRows: (rows) => this.stageMembershipInvitesFromPendingJoins(rows),
7634
+ emit: (status) => this.emit('pendingJoin:changed', status),
7635
+ });
7636
+ this.pendingJoinRunner.start();
7637
+ }
7638
+ /** The invitation a `PendingJoin` row stores. A failure names the row, never the invitation, which is a bearer credential. */
7639
+ pendingJoinInvitation(row) {
7640
+ try {
7641
+ return this.decodeInvitation(row.Invitation);
7642
+ }
7643
+ catch {
7644
+ throw new Error(`PendingJoin ${row.Id}: the stored invitation does not decode`);
7645
+ }
7646
+ }
7647
+ pendingJoinSAppId(row) {
7648
+ try {
7649
+ return this.decodeInvitation(row.Invitation).sAppId;
7650
+ }
7651
+ catch {
7652
+ log('PendingJoin %s: the stored invitation does not decode; reporting no sApp', row.Id);
7653
+ return '';
7654
+ }
7655
+ }
7656
+ /**
7657
+ * Stage the membership invitation of each `joined` row this process has not staged yet, so a
7658
+ * closed strand joined on another owner machine is seated by whichever machine launches it
7659
+ * first; the party key it admits is the replicated `StrandPartyKey` row the finishing machine
7660
+ * seated. A strand that already has an invitation staged keeps it, for the reconciler to
7661
+ * settle first.
7662
+ */
7663
+ stageMembershipInvitesFromPendingJoins(rows) {
7664
+ for (const { rowId, strandId, invite } of membershipInvitesToStage(rows, this.stagedMembershipInviteKeys, Date.now())) {
7665
+ this.stagedMembershipInviteKeys.add(invite.inviteKey);
7666
+ if (this.pendingMembershipInvites.has(strandId)) {
7667
+ continue;
7668
+ }
7669
+ this.pendingMembershipInvites.set(strandId, invite);
7670
+ log('pending join %s: staged its membership invitation for strand %s', rowId, strandId);
7671
+ this.strandManager.notifyMembershipInviteStaged(strandId);
7672
+ }
7673
+ }
7359
7674
  /**
7360
7675
  * Remember the strand a formation just joined (see {@link joinedStrandStore}), so it
7361
7676
  * is re-offered after a restart even when the app is killed before its `addStrand`, and
@@ -7377,7 +7692,7 @@ export class CadreNode {
7377
7692
  });
7378
7693
  }
7379
7694
  catch (error) {
7380
- throw new Error(`Formation for strand ${result.strandId} was approved (its one-time token is spent), but ` +
7695
+ throw new FormationPostApprovalError(result.strandId, `Formation for strand ${result.strandId} was approved (its one-time token is spent), but ` +
7381
7696
  'remembering the join in this node\'s joined-strand store failed, so the strand would not ' +
7382
7697
  'come back after a restart. Fix the store (usually the configured keyStore), then redeem a ' +
7383
7698
  'fresh invitation.', { cause: error });
@@ -7410,12 +7725,13 @@ export class CadreNode {
7410
7725
  await this.ensureStrandPartyKey(strandId);
7411
7726
  }
7412
7727
  catch (error) {
7413
- throw new Error(`Formation for strand ${strandId} was approved (its one-time token is spent), but ` +
7728
+ throw new FormationPostApprovalError(strandId, `Formation for strand ${strandId} was approved (its one-time token is spent), but ` +
7414
7729
  'persisting this party\'s membership identity (StrandPartyKey) failed — the joiner ' +
7415
7730
  'cannot become a member without it. Fix the underlying cause, then redeem a fresh ' +
7416
7731
  'invitation (the delivered one dies with this error).', { cause: error });
7417
7732
  }
7418
7733
  this.pendingMembershipInvites.set(strandId, invite);
7734
+ this.stagedMembershipInviteKeys.add(invite.inviteKey);
7419
7735
  log('formStrand: staged membership invitation for strand %s (party key persisted)', strandId);
7420
7736
  this.strandManager.notifyMembershipInviteStaged(strandId);
7421
7737
  }