@serfab/cadre-core 1.14.0 → 1.15.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 (171) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +64 -29
  3. package/dist/cadre-invite-protocol.js +207 -38
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +375 -79
  9. package/dist/cadre-node.js +758 -186
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-database.d.ts +92 -12
  12. package/dist/control-database.js +323 -142
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-schema.d.ts +1 -1
  15. package/dist/control-schema.js +19 -3
  16. package/dist/control-schema.js.map +1 -1
  17. package/dist/ed25519-key.d.ts +7 -9
  18. package/dist/ed25519-key.js +20 -10
  19. package/dist/ed25519-key.js.map +1 -1
  20. package/dist/fetch-impl.d.ts +12 -0
  21. package/dist/fetch-impl.js +23 -0
  22. package/dist/fetch-impl.js.map +1 -0
  23. package/dist/file-durable-slot.js +2 -1
  24. package/dist/file-durable-slot.js.map +1 -1
  25. package/dist/file-safe-name.d.ts +25 -0
  26. package/dist/file-safe-name.js +29 -0
  27. package/dist/file-safe-name.js.map +1 -0
  28. package/dist/formation-approval.js +5 -11
  29. package/dist/formation-approval.js.map +1 -1
  30. package/dist/fs-atomic.d.ts +0 -19
  31. package/dist/fs-atomic.js +3 -24
  32. package/dist/fs-atomic.js.map +1 -1
  33. package/dist/index.d.ts +12 -5
  34. package/dist/index.js +31 -6
  35. package/dist/index.js.map +1 -1
  36. package/dist/invitation-bootstrap.d.ts +50 -12
  37. package/dist/invitation-bootstrap.js +76 -13
  38. package/dist/invitation-bootstrap.js.map +1 -1
  39. package/dist/joined-strand-store.d.ts +7 -0
  40. package/dist/joined-strand-store.js +9 -0
  41. package/dist/joined-strand-store.js.map +1 -1
  42. package/dist/key-store-file.js +2 -1
  43. package/dist/key-store-file.js.map +1 -1
  44. package/dist/link-budget.d.ts +9 -4
  45. package/dist/link-budget.js +9 -4
  46. package/dist/link-budget.js.map +1 -1
  47. package/dist/machine-removal.d.ts +78 -0
  48. package/dist/machine-removal.js +166 -0
  49. package/dist/machine-removal.js.map +1 -0
  50. package/dist/membership-connection-gater.d.ts +6 -5
  51. package/dist/membership-connection-gater.js +6 -5
  52. package/dist/membership-connection-gater.js.map +1 -1
  53. package/dist/node-claim-payload.d.ts +55 -0
  54. package/dist/node-claim-payload.js +108 -4
  55. package/dist/node-claim-payload.js.map +1 -1
  56. package/dist/node-default-transports.d.ts +27 -0
  57. package/dist/node-default-transports.js +35 -0
  58. package/dist/node-default-transports.js.map +1 -0
  59. package/dist/node-transports.d.ts +30 -0
  60. package/dist/node-transports.js +40 -0
  61. package/dist/node-transports.js.map +1 -0
  62. package/dist/peer-dial.d.ts +124 -27
  63. package/dist/peer-dial.js +254 -30
  64. package/dist/peer-dial.js.map +1 -1
  65. package/dist/peer-join-backfill.js +6 -0
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/peer-record.d.ts +6 -45
  68. package/dist/peer-record.js +8 -50
  69. package/dist/peer-record.js.map +1 -1
  70. package/dist/primary-lan-address.d.ts +8 -0
  71. package/dist/primary-lan-address.js +46 -0
  72. package/dist/primary-lan-address.js.map +1 -0
  73. package/dist/provider/client.d.ts +131 -0
  74. package/dist/provider/client.js +226 -0
  75. package/dist/provider/client.js.map +1 -0
  76. package/dist/provider/descriptor.d.ts +41 -0
  77. package/dist/provider/descriptor.js +103 -0
  78. package/dist/provider/descriptor.js.map +1 -0
  79. package/dist/provider/index.d.ts +9 -0
  80. package/dist/provider/index.js +10 -0
  81. package/dist/provider/index.js.map +1 -0
  82. package/dist/provider/oauth.d.ts +125 -0
  83. package/dist/provider/oauth.js +199 -0
  84. package/dist/provider/oauth.js.map +1 -0
  85. package/dist/provider/rent-node.d.ts +101 -0
  86. package/dist/provider/rent-node.js +220 -0
  87. package/dist/provider/rent-node.js.map +1 -0
  88. package/dist/relay-addrs.d.ts +9 -4
  89. package/dist/relay-addrs.js +9 -4
  90. package/dist/relay-addrs.js.map +1 -1
  91. package/dist/relay-reservation.d.ts +51 -16
  92. package/dist/relay-reservation.js +107 -24
  93. package/dist/relay-reservation.js.map +1 -1
  94. package/dist/seed-bootstrap.d.ts +22 -5
  95. package/dist/seed-bootstrap.js +48 -25
  96. package/dist/seed-bootstrap.js.map +1 -1
  97. package/dist/strand-commit-durability.d.ts +54 -0
  98. package/dist/strand-commit-durability.js +74 -0
  99. package/dist/strand-commit-durability.js.map +1 -0
  100. package/dist/strand-database.d.ts +23 -8
  101. package/dist/strand-database.js +41 -13
  102. package/dist/strand-database.js.map +1 -1
  103. package/dist/strand-first-sync-gate.d.ts +7 -1
  104. package/dist/strand-first-sync-gate.js +7 -1
  105. package/dist/strand-first-sync-gate.js.map +1 -1
  106. package/dist/strand-formation-protocol.js +4 -1
  107. package/dist/strand-formation-protocol.js.map +1 -1
  108. package/dist/strand-fret-addrs.d.ts +19 -1
  109. package/dist/strand-fret-addrs.js +27 -9
  110. package/dist/strand-fret-addrs.js.map +1 -1
  111. package/dist/strand-instance-manager.d.ts +81 -19
  112. package/dist/strand-instance-manager.js +216 -105
  113. package/dist/strand-instance-manager.js.map +1 -1
  114. package/dist/strand-move.d.ts +124 -0
  115. package/dist/strand-move.js +438 -0
  116. package/dist/strand-move.js.map +1 -0
  117. package/dist/strand-network-config.js +7 -6
  118. package/dist/strand-network-config.js.map +1 -1
  119. package/dist/strand-peer-redial.d.ts +113 -0
  120. package/dist/strand-peer-redial.js +236 -0
  121. package/dist/strand-peer-redial.js.map +1 -0
  122. package/dist/strand-wake-protocol.js +10 -7
  123. package/dist/strand-wake-protocol.js.map +1 -1
  124. package/dist/types.d.ts +212 -4
  125. package/dist/types.js +10 -0
  126. package/dist/types.js.map +1 -1
  127. package/package.json +18 -10
  128. package/src/cadre-invite-protocol.ts +233 -45
  129. package/src/cadre-join-request.ts +167 -0
  130. package/src/cadre-node.ts +10003 -9381
  131. package/src/control-database.ts +4663 -4451
  132. package/src/control-schema.ts +19 -3
  133. package/src/ed25519-key.ts +20 -10
  134. package/src/fetch-impl.ts +23 -0
  135. package/src/file-durable-slot.ts +64 -63
  136. package/src/file-safe-name.ts +32 -0
  137. package/src/formation-approval.ts +604 -610
  138. package/src/fs-atomic.ts +92 -117
  139. package/src/index.ts +789 -722
  140. package/src/invitation-bootstrap.ts +91 -13
  141. package/src/joined-strand-store.ts +508 -498
  142. package/src/key-store-file.ts +142 -141
  143. package/src/link-budget.ts +9 -4
  144. package/src/machine-removal.ts +216 -0
  145. package/src/membership-connection-gater.ts +638 -637
  146. package/src/node-claim-payload.ts +138 -4
  147. package/src/node-default-transports.ts +38 -0
  148. package/src/node-transports.ts +49 -0
  149. package/src/peer-dial.ts +604 -336
  150. package/src/peer-join-backfill.ts +6 -0
  151. package/src/peer-record.ts +159 -202
  152. package/src/primary-lan-address.ts +47 -0
  153. package/src/provider/client.ts +326 -0
  154. package/src/provider/descriptor.ts +129 -0
  155. package/src/provider/index.ts +59 -0
  156. package/src/provider/oauth.ts +285 -0
  157. package/src/provider/rent-node.ts +275 -0
  158. package/src/relay-addrs.ts +9 -4
  159. package/src/relay-reservation.ts +1211 -1121
  160. package/src/seed-bootstrap.ts +1502 -1471
  161. package/src/strand-commit-durability.ts +116 -0
  162. package/src/strand-database.ts +46 -14
  163. package/src/strand-first-sync-gate.ts +7 -1
  164. package/src/strand-formation-protocol.ts +1039 -1036
  165. package/src/strand-fret-addrs.ts +179 -145
  166. package/src/strand-instance-manager.ts +2026 -1883
  167. package/src/strand-move.ts +585 -0
  168. package/src/strand-network-config.ts +7 -6
  169. package/src/strand-peer-redial.ts +307 -0
  170. package/src/strand-wake-protocol.ts +10 -7
  171. package/src/types.ts +220 -4
@@ -0,0 +1,307 @@
1
+ /**
2
+ * A running strand node redialing the strand's other members itself, when it holds a
3
+ * connection to none of them, with the dial budget the host's declared link gives a
4
+ * relayed connection.
5
+ *
6
+ * Why. Below cadre-core the only thing that redials a strand peer is FRET's maintenance:
7
+ * its pings and neighbour announcements open the connection themselves (`dial: true`) under
8
+ * a fixed 2 000 ms limit on the whole call (`MAINTENANCE_RPC_TIMEOUT_MS` in `p2p-fret`'s
9
+ * `fret-service.ts`). Opening a relayed connection takes longer than that on a slow link:
10
+ * about 9 s on a 20 ms per-frame cap with a 70%-busy event loop
11
+ * (`joiner-first-commit-over-slow-relay`, whose slow-restart arm reproduces this), where
12
+ * {@link relayedDialBudgetMs} allows 21.5 s at the default declaration. Each such redial is
13
+ * cut off part-way through the handshake, from both sides, for as long as the link stays that
14
+ * slow. And after a restart nothing else dials a strand peer: a cross-party strand's launch
15
+ * seed is empty, and cadre-core's address passes only fill the address book. Two phones that
16
+ * restarted then never reconnect, and a write either made alone never reaches the other.
17
+ *
18
+ * What it does. One loop per strand runtime, started in `StrandInstanceManager`'s runtime
19
+ * build and stopped with the runtime (stop, quiesce, failed build), so a hibernating strand
20
+ * never dials. Each look at the node:
21
+ * - a member connected: nothing to do; look again in {@link STRAND_REDIAL_CHECK_MS}, or as
22
+ * soon as a peer disconnects.
23
+ * - no member connected and none known: likewise nothing (a founder alone, a storage replica
24
+ * before anyone arrives).
25
+ * - no member connected and some known: dial every known member at once
26
+ * ({@link dialPeerAddrs}, direct addresses before relayed ones), each address with the
27
+ * relayed-dial budget. A round that connects to one goes back to watching; one that does
28
+ * not waits a backoff starting at that budget and doubling to
29
+ * {@link STRAND_REDIAL_MAX_BACKOFF_MS}.
30
+ *
31
+ * Who is known: every peer the node's FRET table holds a verified signed address record for
32
+ * (`strand-fret-addrs.ts`), plus the launch seed (sibling strand addresses and a formation's
33
+ * carried addresses). Who counts as connected: a peer FRET labels `'member'` (it has seen it
34
+ * speak this strand's protocols), or a seed peer. Neither ever includes a configured relay, a
35
+ * peer FRET labels `'foreign'`, or a peer the revocation gate refuses. The relay matters most:
36
+ * the strand node is connected to it throughout and FRET files it with a record like any
37
+ * peer, so counting it would leave this loop idle exactly when it is needed.
38
+ *
39
+ * NOTE: this compensates for FRET's 2 000 ms maintenance dial limit, which cannot open a
40
+ * relayed connection on a slow link (upstream half:
41
+ * `blocked/fret-maintenance-ping-dial-budget-below-a-relayed-dial`). If FRET's maintenance
42
+ * dial ever takes a limit derived from the declared link, this loop becomes a backstop and
43
+ * may be deleted, as `peer-join-backfill.ts` says of itself.
44
+ */
45
+
46
+ import debug from 'debug';
47
+ import type { Multiaddr } from '@multiformats/multiaddr';
48
+ import { relayedDialBudgetMs } from './link-budget.js';
49
+ import { CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS, SelfRelayOnlyError, dialPeerAddrs, type AddrDialer, type PeerDialBudget } from './peer-dial.js';
50
+ import { groupAddrsByPeerId } from './peer-addr-book.js';
51
+ import { strandFretMembership, strandFretPeerAddrs, type FretAddrHost, type FretMembership } from './strand-fret-addrs.js';
52
+ import { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
53
+
54
+ const log = debug('sereus:cadre:strand-redial');
55
+
56
+ /**
57
+ * Gap between looks while a member is connected or none is known. While a member is
58
+ * connected a look reads FRET's labels and the connection list and checks no signature
59
+ * ({@link strandFretMembership}); a disconnect also triggers a look at once.
60
+ */
61
+ export const STRAND_REDIAL_CHECK_MS = 5_000;
62
+
63
+ /**
64
+ * Ceiling on the wait between failed rounds. A member that is gone for good is dialed this
65
+ * often for as long as FRET keeps its record (up to 14 days after it was last confirmed).
66
+ */
67
+ export const STRAND_REDIAL_MAX_BACKOFF_MS = 5 * 60_000;
68
+
69
+ /** The loop's dial budget and cadence. */
70
+ export interface StrandRedialTimings {
71
+ /** Limit on one address's dial, and on one member's addresses together. */
72
+ budget: PeerDialBudget;
73
+ /** Wait after the first failed round; doubled after each further one. */
74
+ minBackoffMs: number;
75
+ /** Ceiling the backoff doubles up to. */
76
+ maxBackoffMs: number;
77
+ /** Gap between looks while nothing needs dialing. */
78
+ checkMs: number;
79
+ }
80
+
81
+ /**
82
+ * The timings at a declared link round trip: {@link relayedDialBudgetMs} per address, the
83
+ * same whole-budget count per member as the control cohort's dials (dead direct addresses
84
+ * a record lists ahead of the relayed one that works), and a first backoff of one per-address
85
+ * budget.
86
+ */
87
+ export function strandRedialTimings(linkRoundTripMs?: number): StrandRedialTimings {
88
+ const perAddressMs = relayedDialBudgetMs(linkRoundTripMs);
89
+ return {
90
+ budget: { perAddressMs, totalMs: CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS * perAddressMs },
91
+ minBackoffMs: perAddressMs,
92
+ maxBackoffMs: STRAND_REDIAL_MAX_BACKOFF_MS,
93
+ checkMs: STRAND_REDIAL_CHECK_MS
94
+ };
95
+ }
96
+
97
+ /** The slice of a strand's libp2p node the loop uses. Structurally satisfied by db-p2p's node. */
98
+ export interface RedialNode extends AddrDialer {
99
+ services?: FretAddrHost['services'];
100
+ getConnections(): ReadonlyArray<{ remotePeer: { toString(): string } }>;
101
+ addEventListener(type: 'peer:disconnect', listener: () => void): void;
102
+ removeEventListener(type: 'peer:disconnect', listener: () => void): void;
103
+ }
104
+
105
+ export interface StrandRedialDeps {
106
+ /** Names the strand in logs. */
107
+ label: string;
108
+ node: RedialNode;
109
+ /**
110
+ * The launch seed: multiaddr strings, each naming its peer in a trailing `/p2p/`.
111
+ *
112
+ * NOTE: read once, at runtime build. Addresses a re-formation carries while the strand runs
113
+ * reach the peerStore (`recordFormationStrandAddrs`) but not this loop until the runtime is
114
+ * rebuilt; if a re-formed strand is seen not reconnecting until relaunch, read the seed per round.
115
+ */
116
+ seed: readonly string[];
117
+ /** Peer ids never dialed nor counted as members: the relays the node reserves on. */
118
+ excluded: ReadonlySet<string>;
119
+ /** The revocation gate's current verdict, asked of peers and of this node itself; absent for an open strand. */
120
+ isRevoked?: (peerId: string) => boolean;
121
+ /** Omit for real (unref'd) timers. */
122
+ scheduler?: TimeoutScheduler;
123
+ }
124
+
125
+ /** A running redial loop; its only imperative is {@link stop}. */
126
+ export interface StrandPeerRedial {
127
+ /** Idempotent. No look or round starts after it; dials in flight run out on their own deadlines, and their result is discarded. */
128
+ stop(): void;
129
+ }
130
+
131
+ /** How a look ended: nothing needed dialing or a round connected (`'watch'`), or a round reached no member (`'retry'`). */
132
+ type LookOutcome = 'watch' | 'retry';
133
+
134
+ /** Start the loop; its first look runs at once. */
135
+ export function superviseStrandPeerRedial(deps: StrandRedialDeps, timings: StrandRedialTimings): StrandPeerRedial {
136
+ return new StrandPeerRedialLoop(deps, timings);
137
+ }
138
+
139
+ class StrandPeerRedialLoop implements StrandPeerRedial {
140
+ private readonly scheduler: TimeoutScheduler;
141
+ private readonly seed: Map<string, Multiaddr[]>;
142
+ private readonly selfId: string;
143
+ private backoffMs: number;
144
+ private timer: unknown = null;
145
+ /**
146
+ * Whether the pending wait is a plain gap between looks, which a disconnect cuts short. A
147
+ * backoff after a failed round is not cut short, or a flapping relay connection would turn
148
+ * the backoff into a dial on every flap; and while a look or round runs there is no wait.
149
+ */
150
+ private watching = false;
151
+ private stopped = false;
152
+ private readonly onDisconnect = (): void => {
153
+ if (this.watching && !this.stopped) {
154
+ this.cancelTimer();
155
+ void this.tick();
156
+ }
157
+ };
158
+
159
+ constructor(private readonly deps: StrandRedialDeps, private readonly timings: StrandRedialTimings) {
160
+ this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
161
+ this.seed = groupAddrsByPeerId([...deps.seed]);
162
+ this.selfId = deps.node.peerId.toString();
163
+ this.backoffMs = timings.minBackoffMs;
164
+ deps.node.addEventListener('peer:disconnect', this.onDisconnect);
165
+ void this.tick();
166
+ }
167
+
168
+ stop(): void {
169
+ if (this.stopped) {
170
+ return;
171
+ }
172
+ this.stopped = true;
173
+ this.cancelTimer();
174
+ this.deps.node.removeEventListener('peer:disconnect', this.onDisconnect);
175
+ }
176
+
177
+ private async tick(): Promise<void> {
178
+ if (this.stopped) {
179
+ return;
180
+ }
181
+ this.watching = false;
182
+ let outcome: LookOutcome = 'watch';
183
+ try {
184
+ outcome = await this.lookAndDial();
185
+ } catch (error) {
186
+ // A node torn down under the look throws from its table export or connection list.
187
+ log('strand %s: redial look failed (looking again in %dms): %o', this.deps.label, this.timings.checkMs, error);
188
+ }
189
+ if (this.stopped) {
190
+ return;
191
+ }
192
+ this.watching = outcome === 'watch';
193
+ const waitMs = outcome === 'watch' ? this.resetBackoff() : this.nextBackoff();
194
+ this.timer = this.scheduler.setTimeout(() => {
195
+ this.timer = null;
196
+ void this.tick();
197
+ }, waitMs);
198
+ }
199
+
200
+ /**
201
+ * One look, and a round of dials when it finds no member connected but some known. A node
202
+ * whose own party was removed dials nobody: the strand keeps running for the session, but a
203
+ * removed party must not keep dialing the strand's peers (`CadreNode.forgetRevokedJoin`).
204
+ */
205
+ private async lookAndDial(): Promise<LookOutcome> {
206
+ if (this.deps.isRevoked?.(this.selfId) === true) {
207
+ return 'watch';
208
+ }
209
+ const membership = strandFretMembership(this.deps.node);
210
+ if (this.memberConnected(membership)) {
211
+ return 'watch';
212
+ }
213
+ const known = await this.knownMembers(membership);
214
+ if (known.size === 0 || this.stopped) {
215
+ return 'watch';
216
+ }
217
+ log('strand %s: no member connected; dialing %d known member(s)', this.deps.label, known.size);
218
+ if (await this.dialAny(known)) {
219
+ log('strand %s: reconnected to a member', this.deps.label);
220
+ return 'watch';
221
+ }
222
+ log('strand %s: no known member answered; next round in %dms', this.deps.label, this.backoffMs);
223
+ return 'retry';
224
+ }
225
+
226
+ /** The gap between looks, with the backoff started over. */
227
+ private resetBackoff(): number {
228
+ this.backoffMs = this.timings.minBackoffMs;
229
+ return this.timings.checkMs;
230
+ }
231
+
232
+ /**
233
+ * The wait after a round that reached no member, doubling the next one up to the cap.
234
+ *
235
+ * NOTE: a round whose dials failed within milliseconds (a relay connection that is still
236
+ * closing, the member's relay reservation not back yet) waits as long as one whose dials ran
237
+ * out their budget. In `joiner-first-commit-over-slow-relay`'s third arm that first wait is what
238
+ * leaves the joiner writable with no member connected. If reconnecting within seconds of a cut
239
+ * ever matters, retry a round that failed that fast after a short delay instead.
240
+ *
241
+ * NOTE: one such fast failure is `ConnectionClosedError: The connection muxer is "closed"`. A
242
+ * relay that aborts its connection to this node cannot tell it so (`@libp2p/websockets`'
243
+ * listener resets with close code 1006, which the `ws` package refuses to send;
244
+ * `blocked/report-libp2p-websockets-buffered-amount`, second arm), so this node keeps a dead
245
+ * relay connection until its own monitor drops it, up to 65 s, and every relayed dial reuses
246
+ * it. If rounds keep failing that way, close a relay connection whose muxer is closed before
247
+ * the next round instead of waiting it out.
248
+ */
249
+ private nextBackoff(): number {
250
+ const waitMs = this.backoffMs;
251
+ this.backoffMs = Math.min(waitMs * 2, this.timings.maxBackoffMs);
252
+ return waitMs;
253
+ }
254
+
255
+ private eligible(peerId: string): boolean {
256
+ return peerId !== this.selfId && !this.deps.excluded.has(peerId) && this.deps.isRevoked?.(peerId) !== true;
257
+ }
258
+
259
+ /** Whether an open connection reaches a peer FRET has seen speak this strand's protocols, or a seed peer. */
260
+ private memberConnected(membership: ReadonlyMap<string, FretMembership>): boolean {
261
+ return this.deps.node.getConnections().some((connection) => {
262
+ const peerId = connection.remotePeer.toString();
263
+ return this.eligible(peerId) && (membership.get(peerId) === 'member' || this.seed.has(peerId));
264
+ });
265
+ }
266
+
267
+ /** The members to dial, each with its candidate addresses: verified FRET records first, then the seed's. */
268
+ private async knownMembers(membership: ReadonlyMap<string, FretMembership>): Promise<Map<string, Multiaddr[]>> {
269
+ const { peers } = await strandFretPeerAddrs(this.deps.node);
270
+ const known = new Map<string, Multiaddr[]>();
271
+ for (const [peerId, addrs] of [...peers, ...this.seed]) {
272
+ if (!this.eligible(peerId) || membership.get(peerId) === 'foreign') {
273
+ continue;
274
+ }
275
+ known.set(peerId, unionAddrs(known.get(peerId) ?? [], addrs));
276
+ }
277
+ return known;
278
+ }
279
+
280
+ /** Dial every member in `known` at once; resolves to whether any connected. */
281
+ private async dialAny(known: ReadonlyMap<string, Multiaddr[]>): Promise<boolean> {
282
+ const members = [...known];
283
+ const outcomes = await Promise.allSettled(members.map(([peerId, addrs]) =>
284
+ dialPeerAddrs(this.deps.node, peerId, addrs, this.timings.budget, `strand ${this.deps.label} redial of ${peerId}`)
285
+ ));
286
+ outcomes.forEach((outcome, i) => {
287
+ if (outcome.status === 'rejected') {
288
+ const reason = outcome.reason instanceof SelfRelayOnlyError ? 'reachable only by relaying through this node' : outcome.reason;
289
+ log('strand %s: redial of %s failed: %o', this.deps.label, members[i][0], reason);
290
+ }
291
+ });
292
+ return outcomes.some((outcome) => outcome.status === 'fulfilled');
293
+ }
294
+
295
+ private cancelTimer(): void {
296
+ if (this.timer !== null) {
297
+ this.scheduler.clearTimeout(this.timer);
298
+ this.timer = null;
299
+ }
300
+ }
301
+ }
302
+
303
+ /** `primary` then the entries of `extra` it lacks, compared as strings. */
304
+ function unionAddrs(primary: readonly Multiaddr[], extra: readonly Multiaddr[]): Multiaddr[] {
305
+ const seen = new Set(primary.map((addr) => addr.toString()));
306
+ return [...primary, ...extra.filter((addr) => !seen.has(addr.toString()))];
307
+ }
@@ -30,7 +30,7 @@ import type { Multiaddr } from '@multiformats/multiaddr';
30
30
  import type { StrandInstance, WakeRequest, WakeAck } from './types.js';
31
31
  import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
32
32
  import { type ControlStream, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
33
- import { tryAddrsInTurn } from './peer-dial.js';
33
+ import { dialAddr, tryAddrsInTurn } from './peer-dial.js';
34
34
  import { relayedRequestBudgetMs } from './link-budget.js';
35
35
 
36
36
  const log = debug('sereus:cadre:strand-wake');
@@ -356,12 +356,14 @@ export async function dialWake(
356
356
  }
357
357
 
358
358
  /**
359
- * Open one stream, send the request, half-close, and read the ack.
359
+ * Dial one address, open one stream, send the request, half-close, and read the ack.
360
360
  *
361
- * `signal` is the per-attempt deadline from {@link dialWake}: it goes to
362
- * `dialProtocol` so a timeout during connect aborts the dial, and into
363
- * {@link exchangeFrame} so a timeout after the stream is open resets it —
364
- * releasing the otherwise unbounded ack-read.
361
+ * The address is dialed outside libp2p's dial queue (`dialAddr`), so another dial of
362
+ * the target in flight cannot capture this attempt; an open connection is still reused.
363
+ *
364
+ * `signal` is the per-attempt deadline from {@link dialWake}: it goes to the dial so a
365
+ * timeout during connect aborts it, and into {@link exchangeFrame} so a timeout after
366
+ * the stream is open resets it — releasing the otherwise unbounded ack-read.
365
367
  */
366
368
  async function sendWake(
367
369
  node: Libp2p,
@@ -371,12 +373,13 @@ async function sendWake(
371
373
  timeoutMs: number,
372
374
  signal: AbortSignal,
373
375
  ): Promise<WakeAck> {
376
+ const connection = await dialAddr(node, addr, signal, 'Wake dial');
374
377
  // `runOnLimitedConnection: true`: the target may be reachable only over a
375
378
  // circuit-relay connection (the signaling-first addr), which libp2p marks
376
379
  // "limited". The wake exchange is a single tiny request→ack well within the
377
380
  // relay's data/duration cap, so opening it on the limited connection is safe
378
381
  // and is the whole point of dialing the relay address.
379
- const rawStream = await node.dialProtocol(addr, protocolId, { runOnLimitedConnection: true, signal });
382
+ const rawStream = await connection.newStream(protocolId, { runOnLimitedConnection: true, signal });
380
383
 
381
384
  const ack = await exchangeFrame(
382
385
  rawStream as unknown as ControlStream,
package/src/types.ts CHANGED
@@ -8,6 +8,7 @@ import type { StrandRevocationEnforcementConfig } from './strand-revocation-enfo
8
8
  import type { StrandMembershipReconciliationConfig } from './strand-membership-reconciler.js';
9
9
  import type { StrandFirstSyncConfig } from './strand-first-sync-gate.js';
10
10
  import type { StrandDatabase } from './strand-database.js';
11
+ import type { StrandCommitDurability, StrandCommitHeld } from './strand-commit-durability.js';
11
12
  import type { SeedTrustPolicy } from './seed-trust-policy.js';
12
13
  import type { KeyStore, KeyId } from './key-store.js';
13
14
  import type { TrustedOwnerStore, TrustSource } from './trusted-owner-store.js';
@@ -17,9 +18,10 @@ import type { EnrolledMachineStore } from './enrolled-machine-store.js';
17
18
  import type { JoinedStrandStore } from './joined-strand-store.js';
18
19
  import type { PushNotifier } from './push-notifier.js';
19
20
  import type { CadreInviteSignedFields, RevocableTable } from './control-authorization.js';
20
- import type { CadreInvitation } from './cadre-invite-protocol.js';
21
+ import type { CadreInvitation, CadreInviteAddressOutcome } from './cadre-invite-protocol.js';
21
22
  import type { ControlRetryAbandonment } from './control-retry.js';
22
23
  import type { FormationRejectionCode } from './strand-formation-rejection.js';
24
+ import type { ListenTransportOptions } from './relay-addrs.js';
23
25
 
24
26
  /**
25
27
  * Extended Libp2p node with the coordinatedRepo attached by db-p2p's
@@ -401,6 +403,20 @@ export interface NetworkConfig {
401
403
  * ```
402
404
  */
403
405
  transports?: Libp2pTransports;
406
+ /**
407
+ * Builds `@optimystic/db-p2p`'s default transport list, for the one case where cadre has to
408
+ * pass a list itself while {@link transports} is unset: a node with configured relays, which
409
+ * is built with libp2p's relay discovery switched off (`node-transports.ts`). Called with the
410
+ * switches the node's listen entries imply (`wsPort` when one names WebSocket). Ignored when
411
+ * {@link transports} is set.
412
+ *
413
+ * A Node host passes `nodeDefaultTransports` from the Node-only
414
+ * `@serfab/cadre-core/node-default-transports` subpath, as `cadre-cli` does. It is injected
415
+ * rather than built in, because that list names `@libp2p/tcp`, which must stay out of the
416
+ * graph React Native and browser bundles reach. Left unset, such a node is built from
417
+ * db-p2p's own list with relay discovery on.
418
+ */
419
+ defaultTransports?: (listen: ListenTransportOptions) => Libp2pTransports;
404
420
  /**
405
421
  * Crypto primitives for the Noise handshake and the encrypted connection that
406
422
  * follows, threaded to both the control node and every strand's cohort node — every
@@ -625,6 +641,16 @@ export interface NetworkConfig {
625
641
  * kept it (the same pair aborted at a 200ms stall only when the deadline was 300ms). An
626
642
  * interval strictly above the deadline is what makes the 30 seconds real.
627
643
  *
644
+ * WHY THE OTHER END'S PING HANDLER CAPS IT TOO. The node being pinged echoes through
645
+ * `@libp2p/ping`'s inbound handler, which aborts the ping stream when its own timer fires; the
646
+ * pinging node's monitor sees the reset and aborts the connection exactly as it would on its
647
+ * own timeout. So this deadline is only real up to the handler timeout of the node being
648
+ * pinged, which has to stay above it. The deployed relay sets its handler to 60 s for that
649
+ * reason (`ops/docker/libp2p-infra/src/main.ts`). Every cadre node's handler is Optimystic's
650
+ * bare `ping()`, `@libp2p/ping`'s 10 s, and nothing here can move it
651
+ * (`tickets/blocked/optimystic-ping-handler-timeout-below-the-monitor-deadline.md`): between
652
+ * two cadre nodes, a ping whose echo takes longer than 10 s still drops the connection.
653
+ *
628
654
  * WHAT THE LINK NEEDS OF IT. One ping opens a fresh stream and echoes over it: a protocol
629
655
  * negotiation plus the echo, two link round trips by `link-budget.ts`'s counts (a `newStream`
630
656
  * over a relayed circuit measured 3016 ms at 1500 ms one-way). At the slowest link sereus
@@ -1642,6 +1668,24 @@ export interface CadreNodeEvents {
1642
1668
  * watcher auto-launched) hangs its "waiting for the other member" screen on the pair.
1643
1669
  */
1644
1670
  'strand:writable': { strandId: string };
1671
+ /**
1672
+ * Emitted after every commit on a strand's database that wrote something, with whether
1673
+ * another member already holds it (`heldElsewhere`) and whether `strand:commit-held` will
1674
+ * say when it does (`followed`). Synchronous, inside the commit, before the app's statement
1675
+ * returns: keep handlers short. Covers every commit on the strand database, cadre-core's own
1676
+ * membership writes included, so filter by the ids you hold — the `StampId()` a
1677
+ * `returning StampId() as Tx` statement gave you. The same record is readable afterwards with
1678
+ * `StrandDatabase.commitDurability(tx)`. See docs/strands.md, "A commit's durability".
1679
+ */
1680
+ 'strand:commit-durability': StrandCommitDurability;
1681
+ /**
1682
+ * Emitted once when a commit reported `followed` below full replication becomes held by every
1683
+ * member of its cohort — the moment a write made while the others were away reaches them.
1684
+ * Synchronous, like `strand:commit-durability`, and can come right after it inside the same
1685
+ * commit. Never emitted for a commit made before your listener attached, or one forgotten
1686
+ * because its database closed, hibernated or the node restarted.
1687
+ */
1688
+ 'strand:commit-held': StrandCommitHeld;
1645
1689
  /**
1646
1690
  * Emitted when this node discovers it is no longer a member of a CLOSED
1647
1691
  * strand — its party was removed by a manager, or it left. Detected by the
@@ -1776,6 +1820,18 @@ export interface CadreNodeEvents {
1776
1820
  * matching predicate for a reader that subscribed too late to see the event.
1777
1821
  */
1778
1822
  'claim:accepted': NodeClaimRecord;
1823
+ /**
1824
+ * Emitted once per node object when this node learns that an owner removed it from the
1825
+ * cadre: a `CadrePeer` tombstone names its peer id, it has no live `CadrePeer` row, and a
1826
+ * membership refresh of this process had seen its live row before. `CadreNode.removedFromCadre`
1827
+ * is the matching query for a reader that subscribed too late.
1828
+ *
1829
+ * BEST-EFFORT: it fires only if the tombstone reached this node before the other machines
1830
+ * cut it off, so a machine that was offline at removal time usually never sees it. Nothing
1831
+ * is stopped or deleted for you; the node only stops its owner re-issue drains, which can no
1832
+ * longer succeed. Stopping the node and deleting the cadre's data are the embedder's.
1833
+ */
1834
+ 'cadre:removed': CadreRemoval;
1779
1835
  }
1780
1836
 
1781
1837
  // ============================================================================
@@ -1791,8 +1847,8 @@ export interface CadreNodeEvents {
1791
1847
  * PeerId by reading the record, re-verifying the self-signature against
1792
1848
  * `publicKey`, checking freshness, and applying a trust gate.
1793
1849
  *
1794
- * The signature covers `peerId`, the comma-joined `addrs`, and `updatedAt` — see
1795
- * `peerRecordSignedPayload`. `publicKey` is NOT inside the signed payload: it is
1850
+ * The signature covers `peerId`, the comma-joined `addrs`, `updatedAt` and `profile` —
1851
+ * see `peerRecordSignedPayload`. `publicKey` is NOT inside the signed payload: it is
1796
1852
  * the key the signature is verified *with*, and its libp2p-identity binding to
1797
1853
  * `peerId` is checked separately at resolve time, so re-signing it would be
1798
1854
  * redundant.
@@ -1806,6 +1862,12 @@ export interface PeerAddressRecord {
1806
1862
  addrs: string[];
1807
1863
  /** epoch ms — freshness; strictly increasing per peer. */
1808
1864
  updatedAt: number;
1865
+ /**
1866
+ * The publisher's own {@link CadreNodeConfig.profile}: `'transaction'` is an app (phone,
1867
+ * browser), `'storage'` an always-on node. Null on a row the machine has not yet
1868
+ * self-published (an owner or an invitation seated it).
1869
+ */
1870
+ profile: NodeProfile | null;
1809
1871
  /** ed25519 self-signature over the signed payload, base64url. */
1810
1872
  sig: string;
1811
1873
  }
@@ -1828,8 +1890,16 @@ export interface PeerAddressRecord {
1828
1890
  export interface CadrePeerRow {
1829
1891
  /** libp2p peer ID (base58btc) — the row key. */
1830
1892
  peerId: string;
1893
+ /** ed25519 public key (base64url) stored for the peer, or null (not ed25519, or a row written before the column). */
1894
+ publicKey: string | null;
1831
1895
  /** Comma-joined dialable multiaddrs as stored, or null when unpublished. */
1832
1896
  multiaddr: string | null;
1897
+ /** The machine's freshness stamp (epoch ms); 0 when it has never self-published. */
1898
+ updatedAt: number;
1899
+ /** The machine's self-declared node profile; null until it self-publishes. */
1900
+ profile: NodeProfile | null;
1901
+ /** The machine's self-signature over its address record (`peerRecordSignedPayload`); null until it self-publishes. */
1902
+ sig: string | null;
1833
1903
  /** Single-use anti-replay nonce the voucher signature, or the usage row, is bound to. */
1834
1904
  stampId: string | null;
1835
1905
  /** ed25519 public key (base64url) of the owner that vouched this row, or that issued the invitation that admitted it. */
@@ -1919,6 +1989,107 @@ export interface InvitationChain {
1919
1989
  invites: Map<string, CadreInviteRow>;
1920
1990
  }
1921
1991
 
1992
+ /**
1993
+ * One machine of the cadre as `CadreNode.listMachines` reports it: a live `CadrePeer` row,
1994
+ * or a live `OwnerKey` whose key derives a peer id with no `CadrePeer` row (an owner key
1995
+ * seated for a machine not yet admitted, or a founder that never self-published).
1996
+ *
1997
+ * `profile` and `lastPublishedAt` are the machine's own claim only when the row's
1998
+ * self-signature verifies; both are null otherwise, so an owner re-vouch that bumped
1999
+ * `UpdatedAt` without re-signing, or a row the machine never published, reports nothing
2000
+ * rather than someone else's stamp.
2001
+ */
2002
+ export interface CadreMachine {
2003
+ peerId: string;
2004
+ /** ed25519 public key (base64url) behind `peerId`; null when the id is not ed25519. */
2005
+ publicKey: string | null;
2006
+ /** This node itself. */
2007
+ isSelf: boolean;
2008
+ /** `publicKey` is a live `OwnerKey` here. */
2009
+ isOwner: boolean;
2010
+ /** Has a live `CadrePeer` row; false for an owner key with no machine row. */
2011
+ isMember: boolean;
2012
+ /** Passes this node's authorized-membership predicate now (`CadreNode.listAuthorizedMembers`; always false for self, which that set excludes). */
2013
+ trusted: boolean;
2014
+ /** The machine's self-published node profile; null until it publishes, or when its signature does not verify. */
2015
+ profile: NodeProfile | null;
2016
+ /** `UpdatedAt` of its self-signed record, re-stamped while it runs; null on the same terms as `profile`. */
2017
+ lastPublishedAt: number | null;
2018
+ /** `CadrePeer.VouchOwner` (or `OwnerKey.VouchOwner` for an owner-only entry): the owner that vouched it, or the issuer of the invitation that admitted it. */
2019
+ vouchedBy: string | null;
2020
+ }
2021
+
2022
+ /** Why `CadreNode.removeMachine` refuses by name (`MachineRemovalRefusedError.code`). */
2023
+ export type MachineRemovalRefusal =
2024
+ /** This node holds no enrolled owner key, so it cannot sign the removal. */
2025
+ | 'not-an-owner'
2026
+ /** The machine is this node: an owner cannot sign its own `OwnerKey` removal, so ask another owner. */
2027
+ | 'self'
2028
+ /** The machine holds the only live owner key here (`MinOneOwner`). */
2029
+ | 'last-owner';
2030
+
2031
+ /**
2032
+ * What `CadreNode.removeMachine` would do for one machine, read without writing
2033
+ * (`CadreNode.planMachineRemoval`), for an app's confirmation step.
2034
+ */
2035
+ export interface MachineRemovalPlan {
2036
+ /** The machine as listed, or null when no live row names it here (already removed). */
2037
+ machine: CadreMachine | null;
2038
+ refusal: MachineRemovalRefusal | null;
2039
+ /** The machine's key is a live owner key, so the removal also retires it. */
2040
+ removesOwnerKey: boolean;
2041
+ /** Rows the machine's owner key vouched, which stop passing the membership predicate everywhere once it is removed unless re-vouched first. Empty unless `removesOwnerKey`. */
2042
+ dependents: {
2043
+ /** Live `OwnerKey` rows whose `VouchOwner` is the machine's key, this node's own excluded. */
2044
+ owners: string[];
2045
+ /** Live `CadrePeer` rows whose `VouchOwner` is the machine's key, the machine itself and this node excluded. */
2046
+ peers: string[];
2047
+ };
2048
+ /** Live `CadreInvite` keys the machine could redeem to come back: targeted at it, or already redeemed by it with uses left. */
2049
+ invitations: string[];
2050
+ /** This node's own `OwnerKey` or `CadrePeer` row was vouched by the machine. It cannot re-vouch its own rows; another owner has to re-add it afterwards. */
2051
+ ownDependency: boolean;
2052
+ }
2053
+
2054
+ /** Options for `CadreNode.removeMachine`. */
2055
+ export interface RemoveMachineOptions {
2056
+ /** Dependents to re-vouch under this node's key before the removal; each must appear in the plan's `dependents`. */
2057
+ revouch?: {
2058
+ owners?: readonly string[];
2059
+ peers?: readonly string[];
2060
+ };
2061
+ }
2062
+
2063
+ /** What `CadreNode.removeMachine` wrote. All false and empty when no live row named the machine here. */
2064
+ export interface MachineRemovalResult {
2065
+ removedPeer: boolean;
2066
+ removedOwnerKey: boolean;
2067
+ removedDeviceToken: boolean;
2068
+ /** `CadreInvite` keys whose withdrawal tombstone this call filed. */
2069
+ withdrawnInvitations: string[];
2070
+ /** Dependents re-vouched under this node's key before the removal. */
2071
+ revouched: {
2072
+ owners: string[];
2073
+ peers: string[];
2074
+ };
2075
+ }
2076
+
2077
+ /**
2078
+ * This node's own removal from its cadre, as it learned it: the `CadrePeer` tombstone naming
2079
+ * its peer id, read after it had seen its own live row (`CadreNode.removedFromCadre`, the
2080
+ * `cadre:removed` event; "Removing a machine" in docs/architecture.md).
2081
+ */
2082
+ export interface CadreRemoval {
2083
+ /** The cadre this node was removed from (`CadreNode.partyId`). */
2084
+ partyId: string;
2085
+ /** The `StampId` the tombstone retired. */
2086
+ stampId: string;
2087
+ /** The owner key that filed the tombstone, when its signature verifies and the key is in this node's trusted-owner anchor; null otherwise. */
2088
+ signerKey: string | null;
2089
+ /** When this node learned of the removal (epoch ms). */
2090
+ at: number;
2091
+ }
2092
+
1922
2093
  /**
1923
2094
  * One {@link CadreInviteRow} with its standing, as `ControlDatabase.listCadreInviteStatuses`
1924
2095
  * (and `CadreNode.listCadreInvitations`) reports it. `live` is the redemption's own test:
@@ -2433,13 +2604,58 @@ export interface CreateCadreInvitationOptions {
2433
2604
  uses?: number;
2434
2605
  }
2435
2606
 
2607
+ /**
2608
+ * Where a phone could redeem a cadre invitation from, judged on the addresses of machines
2609
+ * other than the one that minted it — the answer to "can the new app join once this one is
2610
+ * closed?". A minting machine that is itself always on can therefore read `issuer-only`.
2611
+ */
2612
+ export type CadreInvitationReach =
2613
+ /** Another machine has a relayed (`/p2p-circuit`), public-name or public-IP WebSocket address. */
2614
+ | 'anywhere'
2615
+ /** Other machines are named, but only at private or loopback addresses: works on their network only. */
2616
+ | 'local-network'
2617
+ /** The bundle names no other machine at an address a phone can dial. */
2618
+ | 'issuer-only';
2619
+
2436
2620
  /** What `CadreNode.createCadreInvitation` returns: the bundle, and the same encoded for sharing. */
2437
2621
  export interface CreateCadreInvitationResult {
2438
2622
  invitation: CadreInvitation;
2439
- /** `encodeCadreInvitation(invitation)`: base64url JSON for a QR code, a paste or a link. */
2623
+ /** `encodeCadreInvitation(invitation)`: `sereus-invite:1.` and base64url JSON, for a QR code, a paste or a link. */
2440
2624
  encoded: string;
2625
+ /** Whether a device could redeem it once the minting machine is gone; see {@link CadreInvitationReach}. */
2626
+ reach: CadreInvitationReach;
2441
2627
  }
2442
2628
 
2629
+ /** How `CadreNode.redeemCadreInvitation` runs. */
2630
+ export interface RedeemCadreInvitationOptions {
2631
+ /**
2632
+ * Keep making passes over the bundle's addresses while every answer is retryable, until a
2633
+ * member accepts, one refuses finally, the invitation expires, or `signal` aborts.
2634
+ * Default false: one pass.
2635
+ */
2636
+ waitForMember?: boolean;
2637
+ /** Ends the pause and the pass in flight; the call then throws the signal's reason. */
2638
+ signal?: AbortSignal;
2639
+ /** Called at each pass and each pause; an exception it throws is logged and ignored. */
2640
+ onProgress?: (progress: CadreJoinProgress) => void;
2641
+ }
2642
+
2643
+ /** A redemption's progress, as `RedeemCadreInvitationOptions.onProgress` reports it. */
2644
+ export type CadreJoinProgress =
2645
+ | { state: 'redeeming'; pass: number }
2646
+ | {
2647
+ state: 'waiting';
2648
+ pass: number;
2649
+ /** Epoch ms of the next pass. */
2650
+ retryAtMs: number;
2651
+ /**
2652
+ * `'no-member-answered'`: every address failed to dial or answer. `'member-not-ready'`: at
2653
+ * least one member answered with a retryable refusal (issuer-unknown, busy, conflict, internal).
2654
+ */
2655
+ reason: 'no-member-answered' | 'member-not-ready';
2656
+ outcomes: readonly CadreInviteAddressOutcome[];
2657
+ };
2658
+
2443
2659
  /** What `CadreNode.redeemCadreInvitation` returns once a member accepted. */
2444
2660
  export interface RedeemCadreInvitationResult {
2445
2661
  /** The member machine that admitted this device, when its address named one. */