@serfab/cadre-core 1.6.0 → 1.7.0

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