@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
package/src/cadre-node.ts CHANGED
@@ -36,7 +36,9 @@ import type {
36
36
  CadrePeerVoucherFields,
37
37
  CadrePeerRow,
38
38
  PeerAddressRecord,
39
- ResolveDeviceTokenOpts
39
+ ResolveDeviceTokenOpts,
40
+ PendingJoinRow,
41
+ PendingJoinStatus
40
42
  } from './types.js';
41
43
  import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS, DEFAULT_CONNECTION_MONITOR } from './types.js';
42
44
  import { sign } from '@optimystic/quereus-plugin-crypto';
@@ -103,10 +105,18 @@ import {
103
105
  CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS,
104
106
  type PeerDialBudget
105
107
  } from './peer-dial.js';
106
- import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
108
+ import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
107
109
  import { EnrollmentService } from './enrollment.js';
108
110
  import { HibernationManager, type HibernationCallbacks } from './hibernation-manager.js';
109
- import { ControlDatabase, isStrandIdConflict, type RevokedRowRef } from './control-database.js';
111
+ import { ControlDatabase, isPendingJoinConflict, isStrandIdConflict, pendingJoinId, type RevokedRowRef } from './control-database.js';
112
+ import { FormationPostApprovalError } from './strand-formation-rejection.js';
113
+ import {
114
+ PendingJoinRunner,
115
+ membershipInvitesToStage,
116
+ parseStoredDisclosure,
117
+ requestedPendingJoin,
118
+ type PendingJoinFields
119
+ } from './pending-join-runner.js';
110
120
  import type { ControlRetryAbandonment } from './control-retry.js';
111
121
  import { SeedBootstrapService, type SeedEventCallbacks, type SeedBootstrapConfig } from './seed-bootstrap.js';
112
122
  import type { SeedTrustPolicy } from './seed-trust-policy.js';
@@ -114,6 +124,8 @@ import {
114
124
  StrandSolicitationService,
115
125
  type StrandSolicitationServiceOptions
116
126
  } from './strand-solicitation.js';
127
+ import { ControlFormationUsageRecorder } from './control-formation-recorder.js';
128
+ import { selectInvitationSiblingAddrs, type InvitationSibling } from './invitation-bootstrap.js';
117
129
  import {
118
130
  createMembershipConnectionGater,
119
131
  DEFAULT_ENROLLMENT_WINDOW_MS,
@@ -378,6 +390,15 @@ export class CadreNode implements SAppIdLookup {
378
390
  private enrollmentService: EnrollmentService;
379
391
  private seedBootstrapService: SeedBootstrapService | null = null;
380
392
  private strandSolicitationService: StrandSolicitationService | null = null;
393
+ /**
394
+ * The service whose formation handler is registered on the control node. Trails
395
+ * {@link strandSolicitationService} while {@link initializeStrandSolicitation} swaps a new
396
+ * one in: the field is set at once so concurrent callers reuse it, the handler only when
397
+ * its queued swap runs.
398
+ */
399
+ private registeredSolicitation: StrandSolicitationService | null = null;
400
+ /** Tail of the queued formation-handler swaps ({@link swapFormationResponder}). */
401
+ private solicitationSwaps: Promise<void> = Promise.resolve();
381
402
  private strandWakeService: StrandWakeService | null = null;
382
403
  /**
383
404
  * Control-network strand-address responder. Answers a co-cadre sibling's
@@ -696,6 +717,22 @@ export class CadreNode implements SAppIdLookup {
696
717
  */
697
718
  private readonly pendingMembershipInvites = new Map<string, StrandMembershipInvite>();
698
719
 
720
+ /**
721
+ * Invite keys of the membership invitations this process has staged, by
722
+ * {@link adoptFormationMembershipInvite} or from a `joined` `PendingJoin` row
723
+ * ({@link stageMembershipInvitesFromPendingJoins}), so one the reconciler has settled is not
724
+ * staged again by the next pending-join pass. Same lifetime as
725
+ * {@link pendingMembershipInvites}: a restarted process stages each live one once more, and the
726
+ * reconciler settles it again (spent, or the party already seated).
727
+ */
728
+ private readonly stagedMembershipInviteKeys = new Set<string>();
729
+
730
+ /**
731
+ * The pending-join retry loop (`pending-join-runner.ts`): built by {@link start}, stopped by
732
+ * {@link cleanup}. It acts only while this machine is an enrolled owner.
733
+ */
734
+ private pendingJoinRunner: PendingJoinRunner | null = null;
735
+
699
736
  /**
700
737
  * Founder launches refused because the strand was founded before the per-party
701
738
  * identity split (`PreSplitStrandIdentityError`), keyed by strandId. Read by
@@ -1266,6 +1303,12 @@ export class CadreNode implements SAppIdLookup {
1266
1303
  });
1267
1304
  await this.strandAddrService.initialize(this.controlNode);
1268
1305
 
1306
+ // Answer strand formation for this party from now on. Every machine of a party
1307
+ // holds the replicated FormationInvite/FormationUsage rows, so an always-on one can
1308
+ // check a token for an inviter that is offline. After the control database is up,
1309
+ // so the responder never reads a database that cannot answer yet.
1310
+ await this.installDefaultFormationResponder();
1311
+
1269
1312
  // Server-side push-wake fan-out: only when push is configured.
1270
1313
  if (this.config.push) {
1271
1314
  this.pushFanoutService = this.buildPushFanout(this.config.push);
@@ -1306,6 +1349,9 @@ export class CadreNode implements SAppIdLookup {
1306
1349
  // Schedule self-registration in background
1307
1350
  this.scheduleSelfRegistration();
1308
1351
 
1352
+ // Last: its first pass may dial another party's machines.
1353
+ this.startPendingJoinRunner();
1354
+
1309
1355
  } catch (error) {
1310
1356
  log('Failed to start CadreNode: %o', error);
1311
1357
  // `driveControlRelayReservation` throws AFTER `control:connected` was emitted,
@@ -1904,11 +1950,15 @@ export class CadreNode implements SAppIdLookup {
1904
1950
  // replaces the default whole (see DEFAULT_CONNECTION_MONITOR).
1905
1951
  connectionMonitor: network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
1906
1952
  // The declared link every cadre budget is derived from, stated to Optimystic too so it
1907
- // derives its own deadlines from the same number: its request dials and responses, its
1908
- // block pushes, and libp2p's `dialTimeout` and `inboundUpgradeTimeout`. Always stated,
1909
- // default included: undeclared, Optimystic keeps LAN deadlines that cannot open a
1910
- // relayed connection at the link sereus supports (`link-budget.ts`).
1953
+ // derives its own deadlines from the same number: its request responses, its block
1954
+ // pushes, and libp2p's `inboundUpgradeTimeout`. Always stated, default included:
1955
+ // undeclared, Optimystic keeps LAN deadlines that cannot open a relayed connection at the
1956
+ // link sereus supports (`link-budget.ts`).
1911
1957
  linkRoundTripMs: resolveLinkRoundTripMs(network?.linkRoundTripMs),
1958
+ // The three limits on opening a connection (libp2p's per-address and whole-dial limits,
1959
+ // and Optimystic's request dial): Optimystic's derivation from the same link plus the
1960
+ // relay's and the called machine's admission decisions, which run on this dial's clock.
1961
+ ...optimysticDialLimits(network?.linkRoundTripMs),
1912
1962
  // `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
1913
1963
  // `{}` when `network.transports` is set, since the embedder owns transport policy
1914
1964
  // then. Spread NEXT to `transports` because the two answer the same question.
@@ -1991,11 +2041,9 @@ export class CadreNode implements SAppIdLookup {
1991
2041
  * `StrandSolicitationService.hasOutstandingInvitation`). A formation
1992
2042
  * initiator is another party's peer by design and its token is only
1993
2043
  * checkable inside the protocol, so the gate asks the coarser question
1994
- * "does this node expect a stranger at all?". Merely REGISTERING the
1995
- * responder ({@link initializeStrandSolicitation}) no longer suspends
1996
- * stranger denial — eager registration (as `reference-app-rn` does at
1997
- * bring-up) and `formStrand`'s lazy initialization both leave the gate
1998
- * armed, because neither mints an invitation.
2044
+ * "does this node expect a stranger at all?". REGISTERING the responder
2045
+ * does not suspend stranger denial: every node registers one at
2046
+ * {@link start}, and registering mints no invitation.
1999
2047
  *
2000
2048
  * Ordering is semantically free (the checks are OR'd) but decides who pays:
2001
2049
  * checks 1-3 are in-memory, 4/5 share one control-DB read, and only a peer
@@ -2027,6 +2075,11 @@ export class CadreNode implements SAppIdLookup {
2027
2075
  * layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
2028
2076
  * read is safe here — unlike the per-stream gate, which must consult the
2029
2077
  * materialized {@link authorizedControlPeers} snapshot instead.
2078
+ * NOTE: check 6 adds a second control read (`hasOutstandingFormationInvite`) for a
2079
+ * stranger with no locally minted invitation in play, on every node now that every
2080
+ * node runs the responder — a relay-enabled storage node included. If stranger
2081
+ * connections to such a node ever arrive fast enough for that read to show, cache
2082
+ * the answer for a few seconds.
2030
2083
  * NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
2031
2084
  * replicated here is denied until the row converges (typically via the
2032
2085
  * owner); either side's next outbound reconcile dial (outbound is never
@@ -2918,6 +2971,15 @@ export class CadreNode implements SAppIdLookup {
2918
2971
  * handling, is what keeps a mixed list from taking a whole peer offline.
2919
2972
  */
2920
2973
  async resolvePeerAddrs(peerId: string, opts: ResolveOpts = {}): Promise<Multiaddr[]> {
2974
+ return (await this.resolvePeerRecord(peerId, opts))?.addrs ?? [];
2975
+ }
2976
+
2977
+ /**
2978
+ * {@link resolvePeerAddrs} with the record's `UpdatedAt` kept beside the addresses, for a
2979
+ * caller that ranks peers by how recently they published. `null` wherever that returns `[]`
2980
+ * for a failed gate.
2981
+ */
2982
+ private async resolvePeerRecord(peerId: string, opts: ResolveOpts): Promise<{ addrs: Multiaddr[]; updatedAt: number } | null> {
2921
2983
  if (!this.controlDatabase) {
2922
2984
  throw new Error('CadreNode must be started before resolving peer addrs');
2923
2985
  }
@@ -2925,14 +2987,14 @@ export class CadreNode implements SAppIdLookup {
2925
2987
  const record = await this.controlDatabase.queryPeerRecord(peerId);
2926
2988
  if (!record) {
2927
2989
  log('resolvePeerAddrs: no record for %s', peerId);
2928
- return [];
2990
+ return null;
2929
2991
  }
2930
2992
 
2931
2993
  // publicKey <-> peerId binding: the stored key must be the one embedded in
2932
2994
  // the requested Ed25519 peer id (also rejects a non-Ed25519 / missing key).
2933
2995
  if (!record.publicKey || ed25519PublicKeyB64FromPeerId(peerId) !== record.publicKey) {
2934
2996
  log('resolvePeerAddrs: publicKey does not match peerId for %s', peerId);
2935
- return [];
2997
+ return null;
2936
2998
  }
2937
2999
 
2938
3000
  // Self-signature over (peerId, addrs, updatedAt).
@@ -2943,14 +3005,14 @@ export class CadreNode implements SAppIdLookup {
2943
3005
  log('resolvePeerAddrs: signature verification failed for %s (updatedAt=%d, addrs=%o, sig=%s)',
2944
3006
  peerId, record.updatedAt, record.addrs,
2945
3007
  record.sig ? `${record.sig.slice(0, 16)}…` : '(empty)');
2946
- return [];
3008
+ return null;
2947
3009
  }
2948
3010
 
2949
3011
  // Freshness: never hand back a dead relay reservation.
2950
3012
  const maxAgeMs = opts.maxAgeMs ?? DEFAULT_PEER_RECORD_MAX_AGE_MS;
2951
3013
  if (!isPeerRecordFresh(record.updatedAt, maxAgeMs, Date.now())) {
2952
3014
  log('resolvePeerAddrs: record for %s is stale (updatedAt=%d, maxAgeMs=%d)', peerId, record.updatedAt, maxAgeMs);
2953
- return [];
3015
+ return null;
2954
3016
  }
2955
3017
 
2956
3018
  // Pluggable trust gate (defaults to current-member).
@@ -2963,7 +3025,7 @@ export class CadreNode implements SAppIdLookup {
2963
3025
  });
2964
3026
  if (!trusted) {
2965
3027
  log('resolvePeerAddrs: trust policy rejected %s', peerId);
2966
- return [];
3028
+ return null;
2967
3029
  }
2968
3030
 
2969
3031
  // Order signaling-first (the on-record order was what we verified above),
@@ -2974,7 +3036,7 @@ export class CadreNode implements SAppIdLookup {
2974
3036
  if (opts.signalingOnly) {
2975
3037
  addrs = addrs.filter(isSignalingAddr);
2976
3038
  }
2977
- return this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId);
3039
+ return { addrs: this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId), updatedAt: record.updatedAt };
2978
3040
  }
2979
3041
 
2980
3042
  /**
@@ -3535,15 +3597,18 @@ export class CadreNode implements SAppIdLookup {
3535
3597
  *
3536
3598
  * The list may name transports this node cannot dial (a lent node reports TCP
3537
3599
  * and `/ws` addresses to a phone that dials WebSockets only). That needs no
3538
- * filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.1.3)
3600
+ * filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.3.11)
3539
3601
  * rejects an address no transport can dial before touching the network, so
3540
3602
  * such an address costs a log line.
3541
3603
  *
3542
3604
  * The list is NOT handed to one `dial()`. libp2p tries a multi-address dial's
3543
- * addresses one after another under a single deadline, with no limit per
3544
- * address, and sorts loopback addresses last — so one or two addresses that
3545
- * never answer used up the whole deadline before the address that worked was
3546
- * tried. {@link dialPeerAddrs} dials each address on its own time limit instead.
3605
+ * addresses one after another under a single deadline, and sorts loopback
3606
+ * addresses last — so one or two addresses that never answer use up most of
3607
+ * the deadline before the address that works is tried. libp2p 3.3's
3608
+ * per-address `addressDialTimeout` does not prevent that: cadre sizes it for a
3609
+ * cold relayed open through two admission decisions (`optimysticDialLimits`,
3610
+ * 39 s at the default declared link).
3611
+ * {@link dialPeerAddrs} dials each address on its own time limit instead.
3547
3612
  */
3548
3613
  private async resolveControlDialAddrs(peerId: string, resolved: Multiaddr[]): Promise<Multiaddr[]> {
3549
3614
  if (resolved.length > 0) {
@@ -3932,6 +3997,9 @@ export class CadreNode implements SAppIdLookup {
3932
3997
 
3933
3998
  // 4. In-session pending owner authorize writes.
3934
3999
  await this.drainPendingPeerWrites();
4000
+
4001
+ // 5. `PendingJoin` rows this process wrote while alone. Their tombstones went out in step 1.
4002
+ await this.pendingJoinRunner?.reissueWritesMadeAlone();
3935
4003
  }
3936
4004
 
3937
4005
  /**
@@ -4611,6 +4679,10 @@ export class CadreNode implements SAppIdLookup {
4611
4679
  // it is stopped here.
4612
4680
  this.relayReserveSupervisor?.stop();
4613
4681
  this.relayReserveSupervisor = null;
4682
+ // Before anything it reads is torn down. An attempt in flight is not awaited (NOTE at
4683
+ // `PendingJoinRunner.stop`).
4684
+ this.pendingJoinRunner?.stop();
4685
+ this.pendingJoinRunner = null;
4614
4686
  // Drop the rest of the relay-reservation posture: a torn-down node holds no
4615
4687
  // reservation, so neither a restarted instance nor a caller inspecting a failed
4616
4688
  // start reports the previous attempt's `reserved`.
@@ -4658,11 +4730,14 @@ export class CadreNode implements SAppIdLookup {
4658
4730
  this.pushFanoutService = null;
4659
4731
  }
4660
4732
 
4661
- // Unregister strand solicitation service
4662
- if (this.strandSolicitationService && this.controlNode) {
4663
- await this.strandSolicitationService.unregisterResponder(this.controlNode);
4664
- this.strandSolicitationService = null;
4733
+ // Unregister the formation responder once any queued swap has run, so no swap
4734
+ // registers a handler after this. A restarted node installs a fresh one.
4735
+ await this.solicitationSwaps;
4736
+ if (this.registeredSolicitation && this.controlNode) {
4737
+ await this.registeredSolicitation.unregisterResponder(this.controlNode);
4665
4738
  }
4739
+ this.registeredSolicitation = null;
4740
+ this.strandSolicitationService = null;
4666
4741
 
4667
4742
  // Stop strand watcher
4668
4743
  if (this.strandWatcher) {
@@ -6258,9 +6333,9 @@ export class CadreNode implements SAppIdLookup {
6258
6333
  * caller runs the drive regardless).
6259
6334
  *
6260
6335
  * NOTE: against a relay that is DOWN this hook costs up to two strand-addr
6261
- * timeouts (dial by peer id, then by addr; 23 s each at the default declared link round trip)
6336
+ * timeouts (dial by peer id, then by addr; 28.5 s each at the default declared link round trip)
6262
6337
  * before the reservation drive even starts, so one failed re-drive holds the supervisor
6263
- * `driving` for those 46 s plus the drive's own deadline — 64 s at the default, and longer on
6338
+ * `driving` for those 57 s plus the drive's own deadline — 75 s at the default, and longer on
6264
6339
  * a host that declared a slower one (`link-budget.ts`).
6265
6340
  * Bounded and harmless while the relay is unreachable anyway; if recovery
6266
6341
  * latency after a relay comes back ever matters, skip the announce when the
@@ -7253,6 +7328,9 @@ export class CadreNode implements SAppIdLookup {
7253
7328
  ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
7254
7329
  }), this.controlNode, this.controlDatabase);
7255
7330
  log('Seed bootstrap service initialized');
7331
+ // Embedders wire the owner key after start(), so the runner's first pass usually found
7332
+ // this machine not yet an owner; without this it would wait a full poll interval.
7333
+ this.pendingJoinRunner?.kick();
7256
7334
  }
7257
7335
 
7258
7336
  /**
@@ -7973,20 +8051,33 @@ export class CadreNode implements SAppIdLookup {
7973
8051
  // ============================================================================
7974
8052
 
7975
8053
  /**
7976
- * Initialize the strand solicitation service.
7977
- * This enables forming strands with other parties via open invitations.
8054
+ * Replace this node's strand solicitation service — the formation responder every node
8055
+ * installs at {@link start}, and the initiator side of {@link formStrand}. Optional: call it
8056
+ * only to customize the responder (an approver, a provisioner, formation deadlines). The
8057
+ * new service's handler replaces the previous one's on the control node, and invitations
8058
+ * the previous service minted stay outstanding.
8059
+ *
8060
+ * Without `options.formationUsageRecorder`, tokens are checked against this party's
8061
+ * replicated `FormationInvite`/`FormationUsage` rows ({@link ControlFormationUsageRecorder}).
8062
+ * A responder that accepts every token is reachable only by constructing
8063
+ * {@link StrandSolicitationService} directly.
7978
8064
  *
7979
8065
  * @param options - Configuration for the solicitation service
7980
8066
  */
7981
8067
  async initializeStrandSolicitation(options?: StrandSolicitationServiceOptions): Promise<void> {
7982
- if (!this.controlNode) {
8068
+ const controlNode = this.controlNode;
8069
+ const controlDatabase = this.controlDatabase;
8070
+ if (!controlNode || !controlDatabase) {
7983
8071
  throw new Error('CadreNode must be started before initializing strand solicitation');
7984
8072
  }
7985
8073
 
7986
8074
  const service = new StrandSolicitationService({
7987
8075
  ...options,
8076
+ formationUsageRecorder: options?.formationUsageRecorder ?? new ControlFormationUsageRecorder(controlDatabase),
7988
8077
  partyId: this.config.controlNetwork.partyId,
7989
- cadrePeerAddrs: this.getMultiaddrs(),
8078
+ // Read per formation: the responder is installed during start, before a relay
8079
+ // reservation gives a relay-only node any address, and addresses change after.
8080
+ cadrePeerAddrs: () => this.getMultiaddrs(),
7990
8081
  // Every formation deadline derives from this node's declared link, like every other
7991
8082
  // dial budget here; a caller's own declaration on the formation config still wins.
7992
8083
  formationConfig: {
@@ -8004,24 +8095,61 @@ export class CadreNode implements SAppIdLookup {
8004
8095
  this.issueStrandMembershipInvite(strandId, signal)
8005
8096
  });
8006
8097
 
8007
- // Set before the registration is awaited, so a concurrent createOpenInvitation /
8008
- // formStrand uses this service instead of building a second one whose handler
8009
- // would collide; a failed registration puts the previous service back.
8010
- // NOTE: same stray-handler caveat as installSeedBootstrapService — a failed
8011
- // peer-store merge leaves the formation handler registered, so a retry is a duplicate.
8012
8098
  const previous = this.strandSolicitationService;
8099
+ if (previous) {
8100
+ service.adoptMintedInvitations(previous);
8101
+ }
8102
+ // Set before the swap is awaited, so a concurrent createOpenInvitation / formStrand
8103
+ // uses this service instead of building another.
8013
8104
  this.strandSolicitationService = service;
8105
+ const swap = this.solicitationSwaps.then(() => this.swapFormationResponder(service, controlNode));
8106
+ // The queue only orders swaps; a failed one rejects this call's own await below.
8107
+ this.solicitationSwaps = swap.catch(() => undefined);
8108
+ await swap;
8109
+ log('Strand solicitation service initialized');
8110
+ }
8111
+
8112
+ /**
8113
+ * Answer formation from start. Log-and-continue: a node that cannot is still useful, and
8114
+ * {@link createOpenInvitation} / {@link formStrand} install the responder when it is missing.
8115
+ */
8116
+ private async installDefaultFormationResponder(): Promise<void> {
8117
+ try {
8118
+ await this.initializeStrandSolicitation();
8119
+ } catch (error) {
8120
+ log('Formation responder not installed at start; createOpenInvitation/formStrand will retry: %o', error);
8121
+ }
8122
+ }
8123
+
8124
+ /**
8125
+ * Move the control node's formation handler from the registered service to `service`.
8126
+ * Queued by {@link initializeStrandSolicitation} so swaps run one at a time: libp2p allows
8127
+ * one handler per protocol id, and two overlapping swaps would each find the other's
8128
+ * handler in the way. A failed swap registers the previous service again, points
8129
+ * {@link strandSolicitationService} back at it unless a later call has replaced it, and
8130
+ * rethrows.
8131
+ *
8132
+ * NOTE: same stray-handler caveat as {@link installSeedBootstrapService} — a failed
8133
+ * peer-store merge leaves the formation handler registered, so the restore below (and a
8134
+ * retry) rejects as a duplicate.
8135
+ */
8136
+ private async swapFormationResponder(service: StrandSolicitationService, controlNode: Libp2p): Promise<void> {
8137
+ const previous = this.registeredSolicitation;
8014
8138
  try {
8015
- await service.registerResponder(this.controlNode);
8139
+ await previous?.unregisterResponder(controlNode);
8140
+ await service.registerResponder(controlNode);
8141
+ this.registeredSolicitation = service;
8016
8142
  } catch (error) {
8017
8143
  if (this.strandSolicitationService === service) this.strandSolicitationService = previous;
8144
+ await previous?.registerResponder(controlNode).catch((restoreError: unknown) =>
8145
+ log('Re-registering the previous formation responder failed; none answers until the next initializeStrandSolicitation: %o', restoreError));
8018
8146
  throw error;
8019
8147
  }
8020
- log('Strand solicitation service initialized');
8021
8148
  }
8022
8149
 
8023
8150
  /**
8024
- * Get the strand solicitation service (for advanced use)
8151
+ * Get the strand solicitation service (for advanced use). Non-null on a started node unless
8152
+ * the install at {@link start} failed.
8025
8153
  */
8026
8154
  getStrandSolicitationService(): StrandSolicitationService | null {
8027
8155
  return this.strandSolicitationService;
@@ -8030,20 +8158,25 @@ export class CadreNode implements SAppIdLookup {
8030
8158
  /**
8031
8159
  * Create an open invitation for others to form strands with this party.
8032
8160
  *
8161
+ * The invitation's bootstrap list names this machine first (it is the one most likely to
8162
+ * run the host strand), then a few of the party's other machines
8163
+ * ({@link siblingInvitationAddrs}), so a joiner can still form while this one is offline.
8164
+ *
8033
8165
  * @param sAppId - The sApp to use for formed strands
8034
8166
  * @param expirationMs - How long the invitation is valid (ms from now)
8035
8167
  * @returns The open invitation to share out-of-band
8168
+ * @throws when neither this machine nor any other machine of the party has an address
8036
8169
  */
8037
8170
  async createOpenInvitation(
8038
8171
  sAppId: string,
8039
8172
  expirationMs: number = 24 * 60 * 60 * 1000 // 24 hours default
8040
8173
  ): Promise<OpenInvitation> {
8041
8174
  if (!this.strandSolicitationService) {
8042
- // Create a temporary service for creating invitations
8175
+ // Only when the install at start failed.
8043
8176
  await this.initializeStrandSolicitation();
8044
8177
  }
8045
8178
 
8046
- const bootstrap = this.getMultiaddrs();
8179
+ const bootstrap = [...this.getMultiaddrs(), ...await this.siblingInvitationAddrs()];
8047
8180
  if (bootstrap.length === 0) {
8048
8181
  throw new Error('No multiaddrs available for invitation');
8049
8182
  }
@@ -8056,11 +8189,44 @@ export class CadreNode implements SAppIdLookup {
8056
8189
  }
8057
8190
 
8058
8191
  /**
8059
- * Form a strand with a responder via an open invitation.
8192
+ * Addresses of the party's other machines for an invitation's bootstrap list
8193
+ * ({@link selectInvitationSiblingAddrs} picks which). The source is each authorized
8194
+ * member's signed `CadrePeer` record ({@link resolvePeerRecord}), so a machine whose record
8195
+ * is missing, stale or untrusted is left out; live connections only order them.
8196
+ *
8197
+ * Best-effort: a failed read leaves the invitation naming this machine alone, which is what
8198
+ * it named before siblings were added.
8199
+ */
8200
+ private async siblingInvitationAddrs(): Promise<string[]> {
8201
+ const controlNode = this.controlNode;
8202
+ if (!controlNode || !this.controlDatabase) {
8203
+ return [];
8204
+ }
8205
+ try {
8206
+ const siblings: InvitationSibling[] = [];
8207
+ for (const { peerId } of await this.listAuthorizedMembers()) {
8208
+ const resolved = await this.resolvePeerRecord(peerId, {});
8209
+ if (resolved && resolved.addrs.length > 0) {
8210
+ siblings.push({ peerId, updatedAt: resolved.updatedAt, addrs: resolved.addrs.map(String) });
8211
+ }
8212
+ }
8213
+ const connected = new Set(controlNode.getConnections().map((c) => c.remotePeer.toString()));
8214
+ return selectInvitationSiblingAddrs(siblings, connected);
8215
+ } catch (error) {
8216
+ log('createOpenInvitation: resolving the other machines failed; the invitation names only this one: %o', error);
8217
+ return [];
8218
+ }
8219
+ }
8220
+
8221
+ /**
8222
+ * Form a strand with a responder via an open invitation: one attempt, from this machine, with
8223
+ * nothing recorded party-wide. {@link requestJoin} is the path that keeps trying.
8060
8224
  *
8061
8225
  * @param invitation - The open invitation received out-of-band
8062
8226
  * @param disclosure - Identity/context information to share with the responder
8063
8227
  * @returns The member key and strand info if successful
8228
+ * @throws `FormationRejectedError` or `FormationUnreachableError` when the formation fails, and
8229
+ * `FormationPostApprovalError` when it was approved and a step on this machine then failed
8064
8230
  */
8065
8231
  async formStrand(
8066
8232
  invitation: OpenInvitation,
@@ -8091,6 +8257,190 @@ export class CadreNode implements SAppIdLookup {
8091
8257
  return result;
8092
8258
  }
8093
8259
 
8260
+ /**
8261
+ * Ask to join through `invitation` and have the party keep trying until the join works, the
8262
+ * invitation is used up or expires, or the request is dismissed: across restarts, and from
8263
+ * any owner machine of the party (`docs/strands.md` → "Joining while the inviter is
8264
+ * offline"). Records a party-wide `PendingJoin` row, runs one attempt on this machine at
8265
+ * once, and returns the status after it: `'joined'` when the inviter answered yes, `'waiting'`
8266
+ * when it was unreachable or not ready, `'failed'` when it refused for good.
8267
+ *
8268
+ * Asking again for an invitation whose request is still pending adopts that request as it
8269
+ * is; asking again after it finished starts a fresh one. Later changes arrive as
8270
+ * `pendingJoin:changed`. A joined strand is offered through `strand:discovered` like any
8271
+ * {@link formStrand} join. Do not also call {@link formStrand} with the same invitation:
8272
+ * nothing coordinates the two, and one of them gets `token-spent`.
8273
+ *
8274
+ * @throws on a machine that is not an enrolled owner (the row is owner-signed), and on an
8275
+ * invitation already past its expiration
8276
+ */
8277
+ async requestJoin(invitation: OpenInvitation, disclosure: StrandFormationDisclosure = {}): Promise<PendingJoinStatus> {
8278
+ const { database, runner } = this.requirePendingJoins();
8279
+ const signer = await this.requireOwnerSigner(
8280
+ 'requestJoin needs an enrolled owner machine of this party, because the party-wide join request is ' +
8281
+ 'owner-signed. formStrand(invitation, disclosure) joins once from this machine, with no retries.'
8282
+ );
8283
+ const now = Date.now();
8284
+ const expiresAt = invitation.expiration.getTime();
8285
+ if (!Number.isFinite(expiresAt)) {
8286
+ throw new Error('The invitation carries no valid expiration');
8287
+ }
8288
+ if (now >= expiresAt) {
8289
+ throw new Error(`The invitation expired at ${invitation.expiration.toISOString()}; ask the inviter for a fresh one`);
8290
+ }
8291
+ const requested = requestedPendingJoin(
8292
+ pendingJoinId(invitation.token), this.encodeInvitation(invitation), disclosure, expiresAt, now);
8293
+ const row = await this.recordJoinRequest(database, requested, signer);
8294
+ log('requestJoin: pending join %s recorded; attempting now', row.Id);
8295
+ return runner.attemptNow(row);
8296
+ }
8297
+
8298
+ /**
8299
+ * Every join asked for with {@link requestJoin} and not dismissed, pending or finished, as this
8300
+ * machine sees it (see `PendingJoinStatus` for which states are machine-local).
8301
+ */
8302
+ async listPendingJoins(): Promise<PendingJoinStatus[]> {
8303
+ const { database, runner } = this.requirePendingJoins();
8304
+ return (await database.queryPendingJoins()).map((row) => runner.statusOf(row));
8305
+ }
8306
+
8307
+ /**
8308
+ * Remove a join request party-wide. On a pending request this cancels it: every owner machine
8309
+ * stops at its next read, and an attempt already running does not record its outcome (a join
8310
+ * it made stays on that machine, as any {@link formStrand} join does). Owner machines only.
8311
+ * Resolves `false` when there was no such request.
8312
+ */
8313
+ async dismissPendingJoin(id: string): Promise<boolean> {
8314
+ const { database, runner } = this.requirePendingJoins();
8315
+ const { ownerKey, signMessage } = await this.requireOwnerSigner(
8316
+ 'dismissPendingJoin needs an enrolled owner machine of this party: the removal is owner-signed');
8317
+ const removed = await database.deletePendingJoin(id, ownerKey, signMessage);
8318
+ runner.forgetRow(id);
8319
+ return removed;
8320
+ }
8321
+
8322
+ private requirePendingJoins(): { database: ControlDatabase; runner: PendingJoinRunner } {
8323
+ if (!this._running || !this.controlDatabase || !this.pendingJoinRunner) {
8324
+ throw new Error('CadreNode must be started before using pending joins');
8325
+ }
8326
+ return { database: this.controlDatabase, runner: this.pendingJoinRunner };
8327
+ }
8328
+
8329
+ /** This machine's owner key and signer for an owner-signed control write, or a throw carrying `refusal`. */
8330
+ private async requireOwnerSigner(refusal: string): Promise<{ ownerKey: string; signMessage: (message: Uint8Array) => string }> {
8331
+ const signingKey = await this.enrolledOwnerSigningKey();
8332
+ if (!signingKey) {
8333
+ throw new Error(refusal);
8334
+ }
8335
+ return { ownerKey: signingKey.publicKeyB64, signMessage: signMessageWith(signingKey.privateKeyB64) };
8336
+ }
8337
+
8338
+ /**
8339
+ * Write a join request's row, or adopt the party's existing row for the same invitation: a
8340
+ * pending one as it is, a finished one replaced by `requested` (the user asked again).
8341
+ */
8342
+ private async recordJoinRequest(
8343
+ database: ControlDatabase,
8344
+ requested: PendingJoinFields,
8345
+ { ownerKey, signMessage }: { ownerKey: string; signMessage: (message: Uint8Array) => string }
8346
+ ): Promise<PendingJoinRow> {
8347
+ let written: PendingJoinRow;
8348
+ try {
8349
+ written = await database.insertPendingJoin(requested, ownerKey, signMessage);
8350
+ } catch (error) {
8351
+ if (!isPendingJoinConflict(error)) {
8352
+ throw error;
8353
+ }
8354
+ const existing = await database.queryPendingJoin(requested.Id);
8355
+ if (!existing) {
8356
+ throw new Error(
8357
+ `A removed join request for this invitation (${requested.Id}) is still held on this machine until its ` +
8358
+ 'clean-up runs, which needs a connection to the party. Ask again once connected.',
8359
+ { cause: error }
8360
+ );
8361
+ }
8362
+ if (existing.Outcome === null) {
8363
+ return existing;
8364
+ }
8365
+ written = await database.replacePendingJoin(existing.StampId, requested, ownerKey, signMessage);
8366
+ }
8367
+ this.pendingJoinRunner?.noteWritten(written);
8368
+ return written;
8369
+ }
8370
+
8371
+ /** Build and start the pending-join retry loop over this node (see {@link pendingJoinRunner}). */
8372
+ private startPendingJoinRunner(): void {
8373
+ const database = (): ControlDatabase => {
8374
+ if (!this.controlDatabase) {
8375
+ throw new Error('CadreNode is stopped: no control database for pending joins');
8376
+ }
8377
+ return this.controlDatabase;
8378
+ };
8379
+ const outcomeSigner = (): Promise<{ ownerKey: string; signMessage: (message: Uint8Array) => string }> =>
8380
+ this.requireOwnerSigner('Cannot record a pending join outcome: this machine is not an enrolled owner of the party');
8381
+ this.pendingJoinRunner = new PendingJoinRunner({
8382
+ selfId: this.controlNode!.peerId.toString(),
8383
+ linkRoundTripMs: this.config.network?.linkRoundTripMs,
8384
+ isOwner: async () => (await this.enrolledOwnerSigningKey()) !== null,
8385
+ // NOTE: one owner-key read and one `PendingJoin` read per pass (30 s); for a party that never
8386
+ // asked for a join the second reads a never-written block, which consults the cohort. If it
8387
+ // shows up in a device profile, poll more slowly while the table reads empty.
8388
+ readRows: () => database().queryPendingJoins(),
8389
+ readRow: (id) => database().queryPendingJoin(id),
8390
+ attempt: (row) => this.formStrand(this.pendingJoinInvitation(row), parseStoredDisclosure(row)),
8391
+ replace: async (expectedStampId, next) => {
8392
+ const { ownerKey, signMessage } = await outcomeSigner();
8393
+ return database().replacePendingJoin(expectedStampId, next, ownerKey, signMessage);
8394
+ },
8395
+ remove: async (id) => {
8396
+ const { ownerKey, signMessage } = await outcomeSigner();
8397
+ return database().deletePendingJoin(id, ownerKey, signMessage);
8398
+ },
8399
+ isAlone: () => this.committedAlone(),
8400
+ sAppIdOf: (row) => this.pendingJoinSAppId(row),
8401
+ observeRows: (rows) => this.stageMembershipInvitesFromPendingJoins(rows),
8402
+ emit: (status) => this.emit('pendingJoin:changed', status),
8403
+ });
8404
+ this.pendingJoinRunner.start();
8405
+ }
8406
+
8407
+ /** The invitation a `PendingJoin` row stores. A failure names the row, never the invitation, which is a bearer credential. */
8408
+ private pendingJoinInvitation(row: PendingJoinRow): OpenInvitation {
8409
+ try {
8410
+ return this.decodeInvitation(row.Invitation);
8411
+ } catch {
8412
+ throw new Error(`PendingJoin ${row.Id}: the stored invitation does not decode`);
8413
+ }
8414
+ }
8415
+
8416
+ private pendingJoinSAppId(row: PendingJoinRow): string {
8417
+ try {
8418
+ return this.decodeInvitation(row.Invitation).sAppId;
8419
+ } catch {
8420
+ log('PendingJoin %s: the stored invitation does not decode; reporting no sApp', row.Id);
8421
+ return '';
8422
+ }
8423
+ }
8424
+
8425
+ /**
8426
+ * Stage the membership invitation of each `joined` row this process has not staged yet, so a
8427
+ * closed strand joined on another owner machine is seated by whichever machine launches it
8428
+ * first; the party key it admits is the replicated `StrandPartyKey` row the finishing machine
8429
+ * seated. A strand that already has an invitation staged keeps it, for the reconciler to
8430
+ * settle first.
8431
+ */
8432
+ private stageMembershipInvitesFromPendingJoins(rows: readonly PendingJoinRow[]): void {
8433
+ for (const { rowId, strandId, invite } of membershipInvitesToStage(rows, this.stagedMembershipInviteKeys, Date.now())) {
8434
+ this.stagedMembershipInviteKeys.add(invite.inviteKey);
8435
+ if (this.pendingMembershipInvites.has(strandId)) {
8436
+ continue;
8437
+ }
8438
+ this.pendingMembershipInvites.set(strandId, invite);
8439
+ log('pending join %s: staged its membership invitation for strand %s', rowId, strandId);
8440
+ this.strandManager.notifyMembershipInviteStaged(strandId);
8441
+ }
8442
+ }
8443
+
8094
8444
  /**
8095
8445
  * Remember the strand a formation just joined (see {@link joinedStrandStore}), so it
8096
8446
  * is re-offered after a restart even when the app is killed before its `addStrand`, and
@@ -8111,7 +8461,8 @@ export class CadreNode implements SAppIdLookup {
8111
8461
  joinedAt: Date.now()
8112
8462
  });
8113
8463
  } catch (error) {
8114
- throw new Error(
8464
+ throw new FormationPostApprovalError(
8465
+ result.strandId,
8115
8466
  `Formation for strand ${result.strandId} was approved (its one-time token is spent), but ` +
8116
8467
  'remembering the join in this node\'s joined-strand store failed, so the strand would not ' +
8117
8468
  'come back after a restart. Fix the store (usually the configured keyStore), then redeem a ' +
@@ -8147,7 +8498,8 @@ export class CadreNode implements SAppIdLookup {
8147
8498
  try {
8148
8499
  await this.ensureStrandPartyKey(strandId);
8149
8500
  } catch (error) {
8150
- throw new Error(
8501
+ throw new FormationPostApprovalError(
8502
+ strandId,
8151
8503
  `Formation for strand ${strandId} was approved (its one-time token is spent), but ` +
8152
8504
  'persisting this party\'s membership identity (StrandPartyKey) failed — the joiner ' +
8153
8505
  'cannot become a member without it. Fix the underlying cause, then redeem a fresh ' +
@@ -8156,6 +8508,7 @@ export class CadreNode implements SAppIdLookup {
8156
8508
  );
8157
8509
  }
8158
8510
  this.pendingMembershipInvites.set(strandId, invite);
8511
+ this.stagedMembershipInviteKeys.add(invite.inviteKey);
8159
8512
  log('formStrand: staged membership invitation for strand %s (party key persisted)', strandId);
8160
8513
  this.strandManager.notifyMembershipInviteStaged(strandId);
8161
8514
  }