@serfab/cadre-core 1.8.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 (126) hide show
  1. package/dist/cadre-node.d.ts +301 -121
  2. package/dist/cadre-node.js +588 -244
  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 +7 -7
  22. package/dist/index.js +16 -13
  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/node-local-snapshot.d.ts +6 -4
  33. package/dist/node-local-snapshot.js +7 -3
  34. package/dist/node-local-snapshot.js.map +1 -1
  35. package/dist/peer-dial.d.ts +18 -14
  36. package/dist/peer-dial.js +18 -14
  37. package/dist/peer-dial.js.map +1 -1
  38. package/dist/peer-join-backfill.d.ts +3 -12
  39. package/dist/peer-join-backfill.js +2 -3
  40. package/dist/peer-join-backfill.js.map +1 -1
  41. package/dist/pending-join-runner.d.ts +186 -0
  42. package/dist/pending-join-runner.js +649 -0
  43. package/dist/pending-join-runner.js.map +1 -0
  44. package/dist/push-fanout.d.ts +1 -1
  45. package/dist/push-fanout.js +1 -1
  46. package/dist/seed-bootstrap.d.ts +2 -2
  47. package/dist/seed-bootstrap.js +1 -1
  48. package/dist/strand-addr-protocol.js +2 -2
  49. package/dist/strand-formation-deadlines.d.ts +5 -5
  50. package/dist/strand-formation-deadlines.js +5 -5
  51. package/dist/strand-formation-manager.d.ts +18 -4
  52. package/dist/strand-formation-manager.js +48 -33
  53. package/dist/strand-formation-manager.js.map +1 -1
  54. package/dist/strand-formation-protocol.d.ts +71 -25
  55. package/dist/strand-formation-protocol.js +225 -51
  56. package/dist/strand-formation-protocol.js.map +1 -1
  57. package/dist/strand-formation-rejection.d.ts +94 -0
  58. package/dist/strand-formation-rejection.js +77 -0
  59. package/dist/strand-formation-rejection.js.map +1 -0
  60. package/dist/strand-fret-addrs.d.ts +54 -0
  61. package/dist/strand-fret-addrs.js +108 -0
  62. package/dist/strand-fret-addrs.js.map +1 -0
  63. package/dist/strand-instance-manager.d.ts +9 -38
  64. package/dist/strand-instance-manager.js +17 -74
  65. package/dist/strand-instance-manager.js.map +1 -1
  66. package/dist/strand-network-state-file.d.ts +12 -0
  67. package/dist/strand-network-state-file.js +38 -0
  68. package/dist/strand-network-state-file.js.map +1 -0
  69. package/dist/strand-network-state.d.ts +111 -0
  70. package/dist/strand-network-state.js +191 -0
  71. package/dist/strand-network-state.js.map +1 -0
  72. package/dist/strand-solicitation.d.ts +11 -3
  73. package/dist/strand-solicitation.js +9 -0
  74. package/dist/strand-solicitation.js.map +1 -1
  75. package/dist/types.d.ts +123 -40
  76. package/dist/types.js +5 -9
  77. package/dist/types.js.map +1 -1
  78. package/package.json +25 -24
  79. package/src/cadre-node.ts +637 -251
  80. package/src/control-authorization.ts +117 -116
  81. package/src/control-cohort.ts +1 -1
  82. package/src/control-database.ts +303 -82
  83. package/src/control-formation-recorder.ts +8 -9
  84. package/src/control-schema.ts +90 -3
  85. package/src/control-write-retry.ts +21 -15
  86. package/src/index.ts +36 -56
  87. package/src/invitation-bootstrap.ts +40 -0
  88. package/src/link-budget.ts +221 -120
  89. package/src/membership-connection-gater.ts +3 -3
  90. package/src/node-local-snapshot.ts +12 -7
  91. package/src/peer-dial.ts +18 -14
  92. package/src/peer-join-backfill.ts +5 -6
  93. package/src/pending-join-runner.ts +735 -0
  94. package/src/push-fanout.ts +1 -1
  95. package/src/seed-bootstrap.ts +2 -2
  96. package/src/strand-addr-protocol.ts +2 -2
  97. package/src/strand-formation-deadlines.ts +5 -5
  98. package/src/strand-formation-manager.ts +62 -37
  99. package/src/strand-formation-protocol.ts +1036 -803
  100. package/src/strand-formation-rejection.ts +129 -0
  101. package/src/strand-fret-addrs.ts +145 -0
  102. package/src/strand-instance-manager.ts +24 -97
  103. package/src/strand-network-state-file.ts +40 -0
  104. package/src/strand-network-state.ts +243 -0
  105. package/src/strand-solicitation.ts +17 -4
  106. package/src/types.ts +119 -40
  107. package/dist/strand-peer-book-file.d.ts +0 -12
  108. package/dist/strand-peer-book-file.js +0 -37
  109. package/dist/strand-peer-book-file.js.map +0 -1
  110. package/dist/strand-peer-book-protocol.d.ts +0 -168
  111. package/dist/strand-peer-book-protocol.js +0 -356
  112. package/dist/strand-peer-book-protocol.js.map +0 -1
  113. package/dist/strand-peer-book-swap.d.ts +0 -104
  114. package/dist/strand-peer-book-swap.js +0 -284
  115. package/dist/strand-peer-book-swap.js.map +0 -1
  116. package/dist/strand-peer-book.d.ts +0 -159
  117. package/dist/strand-peer-book.js +0 -361
  118. package/dist/strand-peer-book.js.map +0 -1
  119. package/dist/strand-peer-observer.d.ts +0 -88
  120. package/dist/strand-peer-observer.js +0 -180
  121. package/dist/strand-peer-observer.js.map +0 -1
  122. package/src/strand-peer-book-file.ts +0 -39
  123. package/src/strand-peer-book-protocol.ts +0 -466
  124. package/src/strand-peer-book-swap.ts +0 -338
  125. package/src/strand-peer-book.ts +0 -475
  126. package/src/strand-peer-observer.ts +0 -235
@@ -0,0 +1,94 @@
1
+ /**
2
+ * How a formation dial fails, as types a joining device can branch on without reading the
3
+ * human-facing wording: a responder's refusal carries a fixed {@link FormationRejectionCode}
4
+ * on the wire and surfaces as a {@link FormationRejectedError}; a dial that never got an
5
+ * answer surfaces as a {@link FormationUnreachableError}.
6
+ */
7
+ /**
8
+ * Why a formation responder refused a join. Sent beside the free-text `reason` on every
9
+ * `approved: false` result frame (`FormationResultMessage.code`).
10
+ */
11
+ export type FormationRejectionCode =
12
+ /**
13
+ * No invitation row for this token on THIS responder. Retryable: a sibling machine of the
14
+ * inviter may not have received the row yet. Leaks nothing — only a holder of the token can
15
+ * ask, and an unknown token means the same as one never minted.
16
+ */
17
+ 'token-unknown'
18
+ /**
19
+ * The invitation expired, or every allowed use is recorded. The loser of a redemption race
20
+ * and a latecomer both get this code, so neither can tell the two apart.
21
+ */
22
+ | 'token-spent'
23
+ /** The joiner's consent signature does not verify (or `partyId` does not embed `peerKey`). */
24
+ | 'consent-invalid'
25
+ /** The responder's disclosure validator refused the disclosure. */
26
+ | 'disclosure-invalid' | 'disclosure-too-large'
27
+ /** The invitation's outside approval hook answered no. */
28
+ | 'approval-refused'
29
+ /** The invitation's outside approval hook could not be reached or did not answer in time. */
30
+ | 'approval-unavailable'
31
+ /** The inviter's approval setup is wrong: a malformed answer, an unenrolled key, or a misconfigured hook. */
32
+ | 'approval-invalid'
33
+ /**
34
+ * The host strand is not running (or not yet replicated) on this responder, or issuing the
35
+ * joiner's membership invitation into it failed.
36
+ */
37
+ | 'host-strand-unavailable'
38
+ /** The host strand predates the per-party identity split and can never admit a joiner. */
39
+ | 'host-strand-must-be-recreated'
40
+ /** The responder is at its cap on concurrent formation sessions. */
41
+ | 'busy'
42
+ /** Provisioning outran the responder's budget; the invitation is left unspent. */
43
+ | 'provisioning-timeout'
44
+ /** The consent write failed for a reason other than a spent invitation. */
45
+ | 'conflict'
46
+ /** An unexpected responder-side failure. */
47
+ | 'internal';
48
+ /**
49
+ * Which rejections are worth retrying — the single place that says so. A retry repeats the
50
+ * same redemption later (or against another of the inviter's machines); a final code means
51
+ * no retry of this invitation can succeed.
52
+ */
53
+ export declare const FORMATION_REJECTION_RETRYABLE: Readonly<Record<FormationRejectionCode, boolean>>;
54
+ /** Is `value` a code this build knows? Own keys only, so a peer's `'constructor'` does not pass. */
55
+ export declare function isFormationRejectionCode(value: unknown): value is FormationRejectionCode;
56
+ /**
57
+ * The responder answered and refused the join.
58
+ *
59
+ * `code` is `'unrecognized'` when the frame carried no code or one this build does not know
60
+ * — formation runs between two parties on different versions — and such a rejection counts
61
+ * as retryable: retrying an unknown answer until the invitation expires is safe, while giving
62
+ * up on one that would have succeeded is not.
63
+ */
64
+ export declare class FormationRejectedError extends Error {
65
+ readonly code: FormationRejectionCode | 'unrecognized';
66
+ readonly reason: string;
67
+ readonly retryable: boolean;
68
+ /** Both arguments come straight off the wire, so neither is trusted to have its declared type. */
69
+ constructor(code: unknown, reason: unknown);
70
+ }
71
+ /**
72
+ * The dial ended before a result frame was read: no responder address parsed, every address
73
+ * failed to dial, a deadline passed, or the stream closed early. Always retryable. Keeps the
74
+ * underlying failure's message, and the failure itself as `cause`.
75
+ */
76
+ export declare class FormationUnreachableError extends Error {
77
+ readonly retryable = true;
78
+ constructor(message: string, options?: {
79
+ cause?: unknown;
80
+ });
81
+ }
82
+ /**
83
+ * The responder approved the join, so the invitation's one-time token is spent, and then a
84
+ * step on the joining machine failed: persisting the party's membership identity, or
85
+ * remembering the join. Never retryable: the same invitation can only answer `token-spent`
86
+ * from now on, so recovery is fixing the local cause and redeeming a fresh invitation.
87
+ */
88
+ export declare class FormationPostApprovalError extends Error {
89
+ readonly strandId: string;
90
+ readonly retryable = false;
91
+ constructor(strandId: string, message: string, options?: {
92
+ cause?: unknown;
93
+ });
94
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * How a formation dial fails, as types a joining device can branch on without reading the
3
+ * human-facing wording: a responder's refusal carries a fixed {@link FormationRejectionCode}
4
+ * on the wire and surfaces as a {@link FormationRejectedError}; a dial that never got an
5
+ * answer surfaces as a {@link FormationUnreachableError}.
6
+ */
7
+ /**
8
+ * Which rejections are worth retrying — the single place that says so. A retry repeats the
9
+ * same redemption later (or against another of the inviter's machines); a final code means
10
+ * no retry of this invitation can succeed.
11
+ */
12
+ export const FORMATION_REJECTION_RETRYABLE = {
13
+ 'token-unknown': true,
14
+ 'token-spent': false,
15
+ 'consent-invalid': false,
16
+ 'disclosure-invalid': false,
17
+ 'disclosure-too-large': false,
18
+ 'approval-refused': false,
19
+ 'approval-unavailable': true,
20
+ 'approval-invalid': false,
21
+ 'host-strand-unavailable': true,
22
+ 'host-strand-must-be-recreated': false,
23
+ 'busy': true,
24
+ 'provisioning-timeout': true,
25
+ 'conflict': true,
26
+ 'internal': true
27
+ };
28
+ /** Is `value` a code this build knows? Own keys only, so a peer's `'constructor'` does not pass. */
29
+ export function isFormationRejectionCode(value) {
30
+ return typeof value === 'string' && Object.prototype.hasOwnProperty.call(FORMATION_REJECTION_RETRYABLE, value);
31
+ }
32
+ /**
33
+ * The responder answered and refused the join.
34
+ *
35
+ * `code` is `'unrecognized'` when the frame carried no code or one this build does not know
36
+ * — formation runs between two parties on different versions — and such a rejection counts
37
+ * as retryable: retrying an unknown answer until the invitation expires is safe, while giving
38
+ * up on one that would have succeeded is not.
39
+ */
40
+ export class FormationRejectedError extends Error {
41
+ /** Both arguments come straight off the wire, so neither is trusted to have its declared type. */
42
+ constructor(code, reason) {
43
+ const text = typeof reason === 'string' ? reason : 'no reason provided';
44
+ super(`Formation rejected: ${text}`);
45
+ this.name = 'FormationRejectedError';
46
+ this.code = isFormationRejectionCode(code) ? code : 'unrecognized';
47
+ this.reason = text;
48
+ this.retryable = this.code === 'unrecognized' || FORMATION_REJECTION_RETRYABLE[this.code];
49
+ }
50
+ }
51
+ /**
52
+ * The dial ended before a result frame was read: no responder address parsed, every address
53
+ * failed to dial, a deadline passed, or the stream closed early. Always retryable. Keeps the
54
+ * underlying failure's message, and the failure itself as `cause`.
55
+ */
56
+ export class FormationUnreachableError extends Error {
57
+ constructor(message, options) {
58
+ super(message, options);
59
+ this.retryable = true;
60
+ this.name = 'FormationUnreachableError';
61
+ }
62
+ }
63
+ /**
64
+ * The responder approved the join, so the invitation's one-time token is spent, and then a
65
+ * step on the joining machine failed: persisting the party's membership identity, or
66
+ * remembering the join. Never retryable: the same invitation can only answer `token-spent`
67
+ * from now on, so recovery is fixing the local cause and redeeming a fresh invitation.
68
+ */
69
+ export class FormationPostApprovalError extends Error {
70
+ constructor(strandId, message, options) {
71
+ super(message, options);
72
+ this.strandId = strandId;
73
+ this.retryable = false;
74
+ this.name = 'FormationPostApprovalError';
75
+ }
76
+ }
77
+ //# sourceMappingURL=strand-formation-rejection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strand-formation-rejection.js","sourceRoot":"","sources":["../src/strand-formation-rejection.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AA6CH;;;;GAIG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAsD;IAC9F,eAAe,EAAE,IAAI;IACrB,aAAa,EAAE,KAAK;IACpB,iBAAiB,EAAE,KAAK;IACxB,oBAAoB,EAAE,KAAK;IAC3B,sBAAsB,EAAE,KAAK;IAC7B,kBAAkB,EAAE,KAAK;IACzB,sBAAsB,EAAE,IAAI;IAC5B,kBAAkB,EAAE,KAAK;IACzB,yBAAyB,EAAE,IAAI;IAC/B,+BAA+B,EAAE,KAAK;IACtC,MAAM,EAAE,IAAI;IACZ,sBAAsB,EAAE,IAAI;IAC5B,UAAU,EAAE,IAAI;IAChB,UAAU,EAAE,IAAI;CACjB,CAAC;AAEF,oGAAoG;AACpG,MAAM,UAAU,wBAAwB,CAAC,KAAc;IACrD,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAC;AACjH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,OAAO,sBAAuB,SAAQ,KAAK;IAK/C,kGAAkG;IAClG,YAAY,IAAa,EAAE,MAAe;QACxC,MAAM,IAAI,GAAG,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,oBAAoB,CAAC;QACxE,KAAK,CAAC,uBAAuB,IAAI,EAAE,CAAC,CAAC;QACrC,IAAI,CAAC,IAAI,GAAG,wBAAwB,CAAC;QACrC,IAAI,CAAC,IAAI,GAAG,wBAAwB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,cAAc,CAAC;QACnE,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACnB,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,IAAI,KAAK,cAAc,IAAI,6BAA6B,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC5F,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,OAAO,yBAA0B,SAAQ,KAAK;IAGlD,YAAY,OAAe,EAAE,OAA6B;QACxD,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAHjB,cAAS,GAAG,IAAI,CAAC;QAIxB,IAAI,CAAC,IAAI,GAAG,2BAA2B,CAAC;IAC1C,CAAC;CACF;AAED;;;;;GAKG;AACH,MAAM,OAAO,0BAA2B,SAAQ,KAAK;IAGnD,YAAqB,QAAgB,EAAE,OAAe,EAAE,OAA6B;QACnF,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QADL,aAAQ,GAAR,QAAQ,CAAQ;QAF5B,cAAS,GAAG,KAAK,CAAC;QAIzB,IAAI,CAAC,IAAI,GAAG,4BAA4B,CAAC;IAC3C,CAAC;CACF"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Reading the signed address records a running strand node's FRET routing table holds,
3
+ * as per-peer addresses ready for that node's own address book (`peer-addr-book.ts`).
4
+ *
5
+ * Why a strand node needs this. libp2p's peerStore hides every address one hour after
6
+ * it was first observed (the `NOTE:` on `mergePeerAddrs`), and FRET does not re-apply
7
+ * the records it holds: it hands a record to the peerStore when a neighbour's snapshot
8
+ * delivers it or a saved table is imported, and the peerStore refuses a record whose
9
+ * sequence number it already has. So a strand node that has been up for more than an
10
+ * hour would have no address for a peer whose connection then drops, although FRET
11
+ * still holds that peer's record. `CadreNode.refreshStrandPeerAddrs` re-merges the
12
+ * records on every pass, which is what keeps another party's strand nodes dialable;
13
+ * own-party siblings are re-resolved separately, over the strand-addr RPC.
14
+ *
15
+ * How long a record is available here is FRET's rule, not this module's: while FRET
16
+ * holds the entry, and at most 14 days after the record was last confirmed (a peer
17
+ * connected at export time counts as confirmed then).
18
+ *
19
+ * Every record is opened and verified here rather than taken on FRET's word. A record
20
+ * is self-certifying, the table is reached through a structural type rather than an
21
+ * interface this package depends on, and the address book write is attributed from what
22
+ * the record proves, not from the id the table filed it under.
23
+ */
24
+ import { type Multiaddr } from '@multiformats/multiaddr';
25
+ /**
26
+ * The slice of a strand's libp2p node this module touches. Structurally satisfied by
27
+ * `Libp2p`; db-p2p registers FRET as `services.fret`.
28
+ */
29
+ export interface FretAddrHost {
30
+ peerId: {
31
+ toString(): string;
32
+ };
33
+ services?: Record<string, unknown>;
34
+ }
35
+ /** What one read of a strand node's FRET table yields. */
36
+ export interface FretPeerAddrs {
37
+ /** Peer id → the addresses its record lists, each bound to that peer. Table order. */
38
+ peers: Map<string, Multiaddr[]>;
39
+ /**
40
+ * Records left out because they did not decode, did not verify, or were not signed
41
+ * by and about the peer the table filed them under.
42
+ */
43
+ rejected: number;
44
+ }
45
+ /**
46
+ * The addresses of every peer `host`'s FRET table holds a usable signed record for,
47
+ * `host` itself excluded. A node with no FRET service (a db-p2p build without it, a
48
+ * test double) reads as an empty table. One bad record never costs the others: it is
49
+ * counted in `rejected` and skipped.
50
+ *
51
+ * Throws only what `exportTable()` throws, which a caller treats like any other
52
+ * failure of a pass over a node that may be mid-teardown.
53
+ */
54
+ export declare function strandFretPeerAddrs(host: FretAddrHost): Promise<FretPeerAddrs>;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Reading the signed address records a running strand node's FRET routing table holds,
3
+ * as per-peer addresses ready for that node's own address book (`peer-addr-book.ts`).
4
+ *
5
+ * Why a strand node needs this. libp2p's peerStore hides every address one hour after
6
+ * it was first observed (the `NOTE:` on `mergePeerAddrs`), and FRET does not re-apply
7
+ * the records it holds: it hands a record to the peerStore when a neighbour's snapshot
8
+ * delivers it or a saved table is imported, and the peerStore refuses a record whose
9
+ * sequence number it already has. So a strand node that has been up for more than an
10
+ * hour would have no address for a peer whose connection then drops, although FRET
11
+ * still holds that peer's record. `CadreNode.refreshStrandPeerAddrs` re-merges the
12
+ * records on every pass, which is what keeps another party's strand nodes dialable;
13
+ * own-party siblings are re-resolved separately, over the strand-addr RPC.
14
+ *
15
+ * How long a record is available here is FRET's rule, not this module's: while FRET
16
+ * holds the entry, and at most 14 days after the record was last confirmed (a peer
17
+ * connected at export time counts as confirmed then).
18
+ *
19
+ * Every record is opened and verified here rather than taken on FRET's word. A record
20
+ * is self-certifying, the table is reached through a structural type rather than an
21
+ * interface this package depends on, and the address book write is attributed from what
22
+ * the record proves, not from the id the table filed it under.
23
+ */
24
+ import debug from 'debug';
25
+ import { PeerRecord, RecordEnvelope } from '@libp2p/peer-record';
26
+ import { peerIdFromPublicKey } from '@libp2p/peer-id';
27
+ import { multiaddr } from '@multiformats/multiaddr';
28
+ import { fromString as uint8ArrayFromString } from 'uint8arrays';
29
+ import { withTrailingPeerId } from './peer-record.js';
30
+ const log = debug('sereus:cadre:strand-fret-addrs');
31
+ /**
32
+ * The addresses of every peer `host`'s FRET table holds a usable signed record for,
33
+ * `host` itself excluded. A node with no FRET service (a db-p2p build without it, a
34
+ * test double) reads as an empty table. One bad record never costs the others: it is
35
+ * counted in `rejected` and skipped.
36
+ *
37
+ * Throws only what `exportTable()` throws, which a caller treats like any other
38
+ * failure of a pass over a node that may be mid-teardown.
39
+ */
40
+ export async function strandFretPeerAddrs(host) {
41
+ const result = { peers: new Map(), rejected: 0 };
42
+ const fret = host.services?.fret;
43
+ if (!isFretTableSource(fret)) {
44
+ return result;
45
+ }
46
+ const selfId = host.peerId.toString();
47
+ for (const entry of fret.exportTable().entries) {
48
+ if (entry.id === selfId || entry.addressRecord === undefined) {
49
+ continue;
50
+ }
51
+ const addrs = await verifiedRecordAddrs(entry.id, entry.addressRecord.envelope);
52
+ if (addrs === null) {
53
+ result.rejected++;
54
+ }
55
+ else if (addrs.length > 0) {
56
+ result.peers.set(entry.id, addrs);
57
+ }
58
+ }
59
+ return result;
60
+ }
61
+ function isFretTableSource(service) {
62
+ return typeof service === 'object' && service !== null
63
+ && typeof service.exportTable === 'function';
64
+ }
65
+ /**
66
+ * The addresses one record lists, bound to `peerId`; null when the record must not be
67
+ * used. `openAndCertify` proves only that the envelope's own key signed it, so the
68
+ * signer and the peer the payload describes are each held against `peerId` here:
69
+ * without that, any peer could sign a record rewriting another peer's addresses.
70
+ */
71
+ async function verifiedRecordAddrs(peerId, envelope) {
72
+ try {
73
+ const opened = await RecordEnvelope.openAndCertify(uint8ArrayFromString(envelope, 'base64url'), PeerRecord.DOMAIN);
74
+ const record = PeerRecord.createFromProtobuf(opened.payload);
75
+ const signer = peerIdFromPublicKey(opened.publicKey).toString();
76
+ const subject = record.peerId.toString();
77
+ if (signer !== peerId || subject !== peerId) {
78
+ log('skipping the address record filed under %s: signed by %s about %s', peerId, signer, subject);
79
+ return null;
80
+ }
81
+ return bindToPeer(record.multiaddrs.map((addr) => addr.toString()), peerId);
82
+ }
83
+ catch (error) {
84
+ log('skipping the address record filed under %s: %o', peerId, error);
85
+ return null;
86
+ }
87
+ }
88
+ /**
89
+ * A record names its peer once, in `peerId`, so its addresses carry no trailing
90
+ * `/p2p/<peerId>` and a relay-only peer's circuit address ends at the relay hop
91
+ * (`…/p2p/<relay>/p2p-circuit`). Each is given the suffix that makes it a complete
92
+ * dial target; one already terminating in a different peer id does not reach this peer
93
+ * and is dropped.
94
+ *
95
+ * Takes strings because `@libp2p/peer-record` carries its own `@multiformats/multiaddr`
96
+ * copy, a structurally different type from the one `withTrailingPeerId` takes.
97
+ */
98
+ function bindToPeer(addrs, peerId) {
99
+ const bound = [];
100
+ for (const addr of addrs) {
101
+ const withPeer = withTrailingPeerId(multiaddr(addr), peerId);
102
+ if (withPeer !== null) {
103
+ bound.push(withPeer);
104
+ }
105
+ }
106
+ return bound;
107
+ }
108
+ //# sourceMappingURL=strand-fret-addrs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strand-fret-addrs.js","sourceRoot":"","sources":["../src/strand-fret-addrs.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,6 @@
1
1
  import type { PrivateKey } from '@libp2p/interface';
2
2
  import { type PeerJoinBackfillConfig } from './peer-join-backfill.js';
3
- import { type StrandPeerObservation } from './strand-peer-observer.js';
4
- import type { StrandPeerBookStore } from './strand-peer-book.js';
3
+ import { type StrandNetworkStateStore } from './strand-network-state.js';
5
4
  import { type StrandRevocationEnforcementConfig } from './strand-revocation-enforcer.js';
6
5
  import { type PendingMembershipInviteSource, type StrandMembershipReconciliationConfig } from './strand-membership-reconciler.js';
7
6
  import { type StrandFirstSyncConfig } from './strand-first-sync-gate.js';
@@ -210,25 +209,14 @@ export interface StartStrandConfig {
210
209
  */
211
210
  announceDelegateToRelay?: (strandId: string, relayAddr: string, delegatePeerId: string) => Promise<void>;
212
211
  /**
213
- * Called when the strand's libp2p node identifies a STRAND peer — one whose
214
- * identify result names this strand's block-transfer protocol, so never the
215
- * circuit relay or a bootstrap node — with the addresses it is dialable at
216
- * (`strand-peer-observer.ts`). Throttled per peer; must not throw. `CadreNode`
217
- * wires it to the node-local strand peer book, which is what lets a restarted
218
- * machine dial the peers it was talking to. Absent ⇒ nothing is observed.
219
- * Retained with the launch config, so a hibernation wake re-arms it on the
220
- * rebuilt node.
221
- */
222
- onStrandPeerIdentified?: (strandId: string, observation: StrandPeerObservation) => void;
223
- /**
224
- * The node-local strand peer book the book SWAP reads and writes
225
- * (`strand-peer-book-swap.ts`): the strand node signs its own addresses with
226
- * {@link privateKey}, exchanges signed entries with every strand peer it identifies,
227
- * and files what verifies. Absent ⇒ no swap runs — a peer that dials this node's
228
- * swap handler gets no answer. `CadreNode` passes its book store. Retained with the
229
- * launch config, so a hibernation wake re-arms the swap on the rebuilt node.
230
- */
231
- strandPeerBook?: StrandPeerBookStore;
212
+ * The node-local strand network state (`strand-network-state.ts`): this strand's
213
+ * slice of it is handed to db-p2p as `persistence`, so the strand node saves its
214
+ * FRET routing table (with each peer's signed address record) on every connection
215
+ * and re-imports it when it is next built. Absent ⇒ nothing is saved and the node
216
+ * starts with an empty table. `CadreNode` passes its store. Retained with the launch
217
+ * config, so a hibernation wake rebuilds the node over the same state.
218
+ */
219
+ networkState?: StrandNetworkStateStore;
232
220
  }
233
221
  /**
234
222
  * Volatile inputs re-resolved when resuming a quiesced strand — the ones that can
@@ -288,23 +276,6 @@ export declare class StrandInstanceManager {
288
276
  * missed writes).
289
277
  */
290
278
  private backfills;
291
- /**
292
- * The per-strand peer-book observation writer (`strand-peer-observer.ts`), keyed
293
- * by strand id. Same lifecycle rationale as {@link backfills}: started in
294
- * `buildStrandRuntime` right after the libp2p node exists, stopped and dropped in
295
- * `releaseRuntime`, so quiesce → resume re-arms it on the rebuilt node. Only
296
- * present when the launch config supplied `onStrandPeerIdentified`.
297
- */
298
- private peerObservers;
299
- /**
300
- * Per-strand strand peer book SWAP driver ({@link StrandPeerBookSwap}) — the signed
301
- * exchange of book entries between strand peers. Same lifetime as the peer-book
302
- * observer above: armed in `buildStrandRuntime` right after the libp2p node exists
303
- * (its protocol handler must be registered before an early peer dials it), stopped
304
- * and dropped in `releaseRuntime`. Only present when the launch config supplied
305
- * `strandPeerBook`.
306
- */
307
- private peerBookSwaps;
308
279
  /**
309
280
  * The per-strand revoked-peer enforcer (closed strands only), keyed by strand
310
281
  * id. Same lifecycle rationale as {@link backfills}: created in
@@ -3,8 +3,7 @@ import { createLibp2pNode } from '@optimystic/db-p2p';
3
3
  import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
4
4
  import { StrandDatabase } from './strand-database.js';
5
5
  import { PeerJoinBackfill } from './peer-join-backfill.js';
6
- import { StrandPeerObserver } from './strand-peer-observer.js';
7
- import { StrandPeerBookSwap } from './strand-peer-book-swap.js';
6
+ import { strandNetworkStatePersistence } from './strand-network-state.js';
8
7
  import { StrandRevocationEnforcer, createRevocationConnectionGater, readStrandRevocationRows } from './strand-revocation-enforcer.js';
9
8
  import { StrandMembershipReconciler } from './strand-membership-reconciler.js';
10
9
  import { removeMemberPeer } from './strand-membership-writer.js';
@@ -16,7 +15,7 @@ import { DEFAULT_CONNECTION_MONITOR, resolveStrandClusterSize, strandClusterPoli
16
15
  import { strandNodeAddrs } from './strand-network-config.js';
17
16
  import { resolveRelayServer } from './relay-server.js';
18
17
  import { superviseRelayReservation } from './relay-reservation.js';
19
- import { declaredCohortReadDeadlineMs, peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
18
+ import { declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
20
19
  const log = debug('sereus:cadre:strand-manager');
21
20
  const timing = debug('sereus:cadre:timing');
22
21
  /**
@@ -140,23 +139,6 @@ export class StrandInstanceManager {
140
139
  * missed writes).
141
140
  */
142
141
  this.backfills = new Map();
143
- /**
144
- * The per-strand peer-book observation writer (`strand-peer-observer.ts`), keyed
145
- * by strand id. Same lifecycle rationale as {@link backfills}: started in
146
- * `buildStrandRuntime` right after the libp2p node exists, stopped and dropped in
147
- * `releaseRuntime`, so quiesce → resume re-arms it on the rebuilt node. Only
148
- * present when the launch config supplied `onStrandPeerIdentified`.
149
- */
150
- this.peerObservers = new Map();
151
- /**
152
- * Per-strand strand peer book SWAP driver ({@link StrandPeerBookSwap}) — the signed
153
- * exchange of book entries between strand peers. Same lifetime as the peer-book
154
- * observer above: armed in `buildStrandRuntime` right after the libp2p node exists
155
- * (its protocol handler must be registered before an early peer dials it), stopped
156
- * and dropped in `releaseRuntime`. Only present when the launch config supplied
157
- * `strandPeerBook`.
158
- */
159
- this.peerBookSwaps = new Map();
160
142
  /**
161
143
  * The per-strand revoked-peer enforcer (closed strands only), keyed by strand
162
144
  * id. Same lifecycle rationale as {@link backfills}: created in
@@ -505,6 +487,18 @@ export class StrandInstanceManager {
505
487
  enableRingZulu: config.profile === 'storage'
506
488
  },
507
489
  ...(config.privateKey && { privateKey: config.privateKey }),
490
+ // Built per runtime, not per launch: the adapter stops saving once the strand's
491
+ // state is forgotten, and a rebuilt runtime is what starts it saving again.
492
+ //
493
+ // NOTE: the saved state also restores which peers db-p2p saw serving the strand,
494
+ // and it counts them in the cohort until FRET finds them unreachable. A node built
495
+ // while every other member is offline therefore fails its first writes ("Failed to
496
+ // get super-majority") — for 4-6 s on loopback, the window a running node already
497
+ // sees when a member drops. If apps report failed sends after relaunching alone on
498
+ // a slow relayed link, retry the write in the app or raise it upstream (db-p2p's
499
+ // remembered-serving fallback); dropping `persistence` brings back the split
500
+ // restart.
501
+ ...(config.networkState && { persistence: strandNetworkStatePersistence(config.networkState, strandId) }),
508
502
  ...(config.network?.transports && { transports: config.network.transports }),
509
503
  ...(config.network?.noiseCrypto && { noiseCrypto: config.network.noiseCrypto }),
510
504
  // Unconditional, and the same default the control node takes: every node of
@@ -516,6 +510,9 @@ export class StrandInstanceManager {
516
510
  // node is also the listener for every other member's strand node, and Optimystic
517
511
  // derives that listener's limit from this (`link-budget.ts`).
518
512
  linkRoundTripMs: resolveLinkRoundTripMs(config.network?.linkRoundTripMs),
513
+ // The same dial limits as the control node: a strand node dials the same machines,
514
+ // through the same relays and gates.
515
+ ...optimysticDialLimits(config.network?.linkRoundTripMs),
519
516
  // Listen entries plus the WebSocket transport switch they imply — a strand node
520
517
  // announces nothing the operator configured (`strand-network-config.ts`), and
521
518
  // spreads AFTER `transports` above because the switch is a no-op whenever the
@@ -533,46 +530,6 @@ export class StrandInstanceManager {
533
530
  });
534
531
  timing('[buildStrandRuntime:%s] createLibp2pNode: %dms', strandId, Math.round(performance.now() - t0));
535
532
  instance.libp2pNode = node;
536
- // The peer-book observation writer, armed BEFORE the database bring-up below
537
- // (unlike the backfill) because the bootstrap dials that follow `libp2p.start()`
538
- // land during that bring-up, and they are exactly the peers worth remembering:
539
- // identify for an early peer may already have fired by the time anything after
540
- // the database initializes runs. Registered in the map immediately so the
541
- // failure rollback (releaseRuntime) stops it like every other runtime component.
542
- const onStrandPeerIdentified = config.onStrandPeerIdentified;
543
- if (onStrandPeerIdentified) {
544
- const observer = new StrandPeerObserver({
545
- label: strandId,
546
- libp2p: node,
547
- protocolPrefix,
548
- onObserved: (observation) => onStrandPeerIdentified(strandId, observation)
549
- });
550
- this.peerObservers.set(strandId, observer);
551
- observer.start();
552
- }
553
- // The strand peer book SWAP, armed at the same moment and for the same reason as
554
- // the observer: a peer that dials this node's swap handler before it is registered
555
- // fails its exchange and sits out a ten-minute throttle. The own entry is signed
556
- // now and re-signed on every address change, which covers the relay reservation
557
- // landing below (`self:peer:update` fires when the circuit addr is added), so no
558
- // separate post-relay step is needed. Registered in the map immediately so the
559
- // failure rollback (releaseRuntime) stops it like every other runtime component.
560
- //
561
- // NOTE: this is a third `peer:identify` listener per running strand, beside the
562
- // observer's and the backfill's — see the backfill's NOTE below for when to fold
563
- // them into one dispatcher.
564
- if (config.strandPeerBook) {
565
- const swap = new StrandPeerBookSwap({
566
- strandId,
567
- libp2p: node,
568
- protocolPrefix,
569
- store: config.strandPeerBook,
570
- privateKey: config.privateKey,
571
- linkRoundTripMs: config.network?.linkRoundTripMs
572
- });
573
- this.peerBookSwaps.set(strandId, swap);
574
- swap.start();
575
- }
576
533
  // One reservation supervisor PER RELAY, started now so the first attempts
577
534
  // overlap the database bring-up below (the relay is never in a strand's
578
535
  // Optimystic cohort — its protocol ids are namespaced `/optimystic/strand-<id>/…`
@@ -781,20 +738,6 @@ export class StrandInstanceManager {
781
738
  backfill.stop();
782
739
  this.backfills.delete(instance.strandId);
783
740
  }
784
- // The peer-book observer goes with the node it listened on; a resume re-arms
785
- // one on the rebuilt node.
786
- const peerObserver = this.peerObservers.get(instance.strandId);
787
- if (peerObserver) {
788
- peerObserver.stop();
789
- this.peerObservers.delete(instance.strandId);
790
- }
791
- // The book swap likewise: unsubscribed and its handler unregistered while the node is
792
- // still up; a resume re-arms one, which re-signs the own entry over the new addresses.
793
- const peerBookSwap = this.peerBookSwaps.get(instance.strandId);
794
- if (peerBookSwap) {
795
- this.peerBookSwaps.delete(instance.strandId);
796
- await peerBookSwap.stop();
797
- }
798
741
  // The revoked-peer enforcer goes with the runtime it gated: a resume
799
742
  // rebuilds it with a fresh (initially empty, fail-open) snapshot.
800
743
  const revocationEnforcer = this.revocationEnforcers.get(instance.strandId);