@serfab/cadre-core 0.10.0 → 0.12.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 (145) hide show
  1. package/README.md +255 -255
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +484 -58
  6. package/dist/cadre-node.js +953 -182
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +26 -0
  9. package/dist/control-cohort.js +26 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +222 -125
  12. package/dist/control-database.js +356 -266
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +8 -7
  15. package/dist/control-formation-recorder.js +8 -7
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +103 -0
  18. package/dist/control-read-retry.js +145 -0
  19. package/dist/control-read-retry.js.map +1 -0
  20. package/dist/control-retry.d.ts +101 -0
  21. package/dist/control-retry.js +108 -0
  22. package/dist/control-retry.js.map +1 -0
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +805 -774
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +65 -31
  27. package/dist/control-write-retry.js +33 -103
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +17 -15
  30. package/dist/delegate-admission.js +17 -15
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/index.d.ts +7 -5
  33. package/dist/index.js +18 -9
  34. package/dist/index.js.map +1 -1
  35. package/dist/membership-connection-gater.d.ts +211 -25
  36. package/dist/membership-connection-gater.js +309 -29
  37. package/dist/membership-connection-gater.js.map +1 -1
  38. package/dist/peer-addr-book.d.ts +92 -0
  39. package/dist/peer-addr-book.js +220 -0
  40. package/dist/peer-addr-book.js.map +1 -0
  41. package/dist/peer-record.d.ts +26 -0
  42. package/dist/peer-record.js +32 -0
  43. package/dist/peer-record.js.map +1 -1
  44. package/dist/relay-addrs.d.ts +76 -26
  45. package/dist/relay-addrs.js +115 -27
  46. package/dist/relay-addrs.js.map +1 -1
  47. package/dist/relay-reservation.d.ts +20 -15
  48. package/dist/relay-reservation.js +18 -14
  49. package/dist/relay-reservation.js.map +1 -1
  50. package/dist/seed-bootstrap.d.ts +7 -0
  51. package/dist/seed-bootstrap.js +15 -13
  52. package/dist/seed-bootstrap.js.map +1 -1
  53. package/dist/strand-addr-protocol.js +16 -0
  54. package/dist/strand-addr-protocol.js.map +1 -1
  55. package/dist/strand-backfill.js +13 -4
  56. package/dist/strand-backfill.js.map +1 -1
  57. package/dist/strand-cohort.d.ts +4 -37
  58. package/dist/strand-cohort.js +4 -15
  59. package/dist/strand-cohort.js.map +1 -1
  60. package/dist/strand-database.d.ts +11 -16
  61. package/dist/strand-database.js +19 -7
  62. package/dist/strand-database.js.map +1 -1
  63. package/dist/strand-formation-manager.d.ts +18 -17
  64. package/dist/strand-formation-manager.js +25 -27
  65. package/dist/strand-formation-manager.js.map +1 -1
  66. package/dist/strand-formation-protocol.d.ts +7 -7
  67. package/dist/strand-formation-protocol.js +8 -8
  68. package/dist/strand-formation-protocol.js.map +1 -1
  69. package/dist/strand-instance-manager.d.ts +53 -21
  70. package/dist/strand-instance-manager.js +128 -37
  71. package/dist/strand-instance-manager.js.map +1 -1
  72. package/dist/strand-membership-writer.d.ts +113 -19
  73. package/dist/strand-membership-writer.js +219 -51
  74. package/dist/strand-membership-writer.js.map +1 -1
  75. package/dist/strand-solicitation.d.ts +3 -3
  76. package/dist/strand-solicitation.js +4 -5
  77. package/dist/strand-solicitation.js.map +1 -1
  78. package/dist/strand-wake-protocol.d.ts +47 -2
  79. package/dist/strand-wake-protocol.js +76 -6
  80. package/dist/strand-wake-protocol.js.map +1 -1
  81. package/dist/types.d.ts +107 -54
  82. package/dist/types.js.map +1 -1
  83. package/package.json +8 -7
  84. package/src/announce-addrs.ts +93 -0
  85. package/src/arachnode-stub.ts +159 -159
  86. package/src/bootstrap-peer-store-file.ts +44 -44
  87. package/src/bootstrap-peer-store.ts +172 -172
  88. package/src/cadre-node.ts +1026 -194
  89. package/src/canonical-json.ts +30 -30
  90. package/src/control-authorization.ts +114 -114
  91. package/src/control-cohort.ts +130 -103
  92. package/src/control-database.ts +376 -298
  93. package/src/control-formation-recorder.ts +305 -304
  94. package/src/control-read-retry.ts +167 -0
  95. package/src/control-retry.ts +190 -0
  96. package/src/control-schema.ts +817 -786
  97. package/src/control-stream.ts +205 -205
  98. package/src/control-write-retry.ts +383 -473
  99. package/src/delegate-admission.ts +304 -302
  100. package/src/device-token.ts +94 -94
  101. package/src/diagnostics/connection-path.ts +277 -277
  102. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  103. package/src/ed25519-key.ts +134 -134
  104. package/src/enrollment.ts +199 -199
  105. package/src/file-durable-slot.ts +61 -61
  106. package/src/formation-approval.ts +604 -604
  107. package/src/fs-atomic.ts +107 -107
  108. package/src/hibernation-manager.ts +437 -437
  109. package/src/identity-key.ts +109 -109
  110. package/src/index.ts +435 -406
  111. package/src/key-store-file.ts +132 -132
  112. package/src/key-store.ts +99 -99
  113. package/src/membership-connection-gater.ts +368 -39
  114. package/src/node-local-snapshot.ts +260 -260
  115. package/src/peer-addr-book.ts +250 -0
  116. package/src/peer-authorization.ts +259 -259
  117. package/src/peer-record.ts +197 -164
  118. package/src/push-credentials.ts +84 -84
  119. package/src/push-node.ts +64 -64
  120. package/src/push-notifier-apns.ts +247 -247
  121. package/src/push-notifier-fcm.ts +220 -220
  122. package/src/push-notifier-shared.ts +28 -28
  123. package/src/push-notifier.ts +52 -52
  124. package/src/relay-addrs.ts +134 -27
  125. package/src/relay-reservation.ts +22 -17
  126. package/src/seed-bootstrap.ts +15 -15
  127. package/src/seed-trust-policy.ts +140 -140
  128. package/src/strand-addr-protocol.ts +458 -441
  129. package/src/strand-backfill.ts +28 -5
  130. package/src/strand-cohort.ts +38 -77
  131. package/src/strand-database.ts +202 -204
  132. package/src/strand-formation-manager.ts +26 -28
  133. package/src/strand-formation-protocol.ts +692 -692
  134. package/src/strand-instance-manager.ts +636 -553
  135. package/src/strand-member-key.ts +39 -39
  136. package/src/strand-member-registry.ts +182 -182
  137. package/src/strand-membership-writer.ts +1627 -1430
  138. package/src/strand-solicitation.ts +471 -472
  139. package/src/strand-transport-key.ts +58 -58
  140. package/src/strand-wake-payload.ts +32 -32
  141. package/src/strand-wake-protocol.ts +400 -314
  142. package/src/strand-watcher.ts +314 -314
  143. package/src/trusted-owner-store-file.ts +41 -41
  144. package/src/trusted-owner-store.ts +171 -171
  145. package/src/types.ts +1354 -1303
@@ -1,314 +1,400 @@
1
- /**
2
- * Control-network push-wake transport.
3
- *
4
- * Lets a same-cadre peer — typically an always-on server that participates in a
5
- * strand and sees new activity — signal a hibernating cadre peer to bring that
6
- * strand online, pull pending activity, and re-hibernate. Push-wake travels the
7
- * **control network** (the per-party network connecting this party's own cadre
8
- * nodes), which is the only network a hibernating peer keeps connected.
9
- *
10
- * Modeled directly on `seed-bootstrap.ts`: a dedicated libp2p protocol id,
11
- * 4-byte big-endian length-prefixed JSON frames, `node.handle` for the receiver,
12
- * `node.dialProtocol` for the sender, and the shared `ControlStream` primitives
13
- * from `control-stream.ts`. The exchange is a single request → single ack on one
14
- * stream (like seed delivery), so each side reads to EOF (under a read timeout)
15
- * and decodes one frame via the shared {@link decodeLengthPrefixedFrame} guard.
16
- *
17
- * **Authorization (v1):** a wake is low-risk — it only causes the receiver to
18
- * spend resources coming online for a strand it already participates in — so the
19
- * receiver carries no per-request signature and instead defers entirely to the
20
- * injected `isMember` predicate. `CadreNode` injects its AUTHORIZED-membership
21
- * predicate there (`isAuthorizedMember`: the sender's `CadrePeer` row must carry
22
- * a voucher that verifies against an owner key in the receiver's node-local
23
- * trusted-owner anchor), so a peer that merely published rows into the replicated
24
- * control DB is refused. This module stays agnostic: it enforces whatever
25
- * predicate it is given.
26
- */
27
-
28
- import debug from 'debug';
29
- import type { Libp2p, Connection } from '@libp2p/interface';
30
- import type { Multiaddr } from '@multiformats/multiaddr';
31
- import type { StrandInstance, WakeRequest, WakeAck } from './types.js';
32
- import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
33
- import { type ControlStream, writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
34
-
35
- const log = debug('sereus:cadre:strand-wake');
36
-
37
- /** Protocol id for control-network push-wake (parallel to `/sereus/seed/1.0.0`). */
38
- export const WAKE_PROTOCOL = '/sereus/strand-wake/1.0.0';
39
-
40
- /**
41
- * Maximum wake frame size. Wake messages are tiny (a strand id + short reason),
42
- * so this is a defensive cap — far below the 1MB seed ceiling — that bounds the
43
- * bytes a peer can make the receiver buffer per stream.
44
- */
45
- const MAX_WAKE_SIZE = 64 * 1024;
46
-
47
- /** Default time to wait for the ack before abandoning a wake dial (ms). */
48
- const DEFAULT_WAKE_TIMEOUT_MS = 10_000;
49
-
50
- /** Default time the receiver waits for an inbound wake frame before aborting (ms). */
51
- const DEFAULT_WAKE_READ_TIMEOUT_MS = 10_000;
52
-
53
- /** Default cap on concurrent inbound wake streams a single peer can pin open. */
54
- const DEFAULT_MAX_CONCURRENT_WAKES = 100;
55
-
56
- /**
57
- * Read a libp2p stream to EOF and decode the single length-prefixed JSON frame
58
- * it carries. Bounded by `timeoutMs` (a never-half-closing peer is aborted, not
59
- * awaited forever) and capped at {@link MAX_WAKE_SIZE}, reusing the shared
60
- * {@link decodeLengthPrefixedFrame} guard for the prefix/length checks.
61
- */
62
- async function readFrame<T>(stream: ControlStream, timeoutMs: number): Promise<T> {
63
- const data = await readStreamToEnd(stream, { maxBytes: MAX_WAKE_SIZE, timeoutMs, label: 'Wake' });
64
- const body = decodeLengthPrefixedFrame(data, MAX_WAKE_SIZE);
65
- return JSON.parse(new TextDecoder().decode(body)) as T;
66
- }
67
-
68
- /**
69
- * Dependencies the {@link StrandWakeService} receiver needs from its host
70
- * (`CadreNode`), injected so the service is testable without a full node.
71
- */
72
- export interface StrandWakeServiceOptions {
73
- /** Membership gate: is the remote peer a `CadrePeer` member of this cadre? */
74
- isMember(remotePeerId: string): Promise<boolean>;
75
- /** Look up a local strand instance by id (undefined if not participated in). */
76
- getStrand(strandId: string): StrandInstance | undefined;
77
- /**
78
- * Trigger the local wake path for a hibernating/idle strand. Wired to
79
- * `CadreNode.wakeStrand` (→ `HibernationManager` → `resumeStrand`), whose
80
- * resume coalescing prevents a push-wake racing a concurrent check-in.
81
- */
82
- wake(strandId: string): Promise<void>;
83
- /**
84
- * Time to wait for the inbound wake frame before aborting the read (ms).
85
- * Defaults to {@link DEFAULT_WAKE_READ_TIMEOUT_MS}. Bounds a buggy/compromised
86
- * own-cadre node that opens a stream and never half-closes its write end.
87
- */
88
- readTimeoutMs?: number;
89
- /**
90
- * Cap on concurrent inbound wake streams (defaults to
91
- * {@link DEFAULT_MAX_CONCURRENT_WAKES}). Over the cap, a non-accepting ack is
92
- * returned without invoking the wake path.
93
- */
94
- maxConcurrent?: number;
95
- }
96
-
97
- /**
98
- * Receiver side of the push-wake protocol. Registers a `WAKE_PROTOCOL` handler
99
- * on the control node and, for each inbound {@link WakeRequest}, gates on cadre
100
- * membership, then resumes the named strand if it is hibernating/idle and we
101
- * participate in it — replying with a {@link WakeAck}.
102
- */
103
- export class StrandWakeService {
104
- private readonly options: StrandWakeServiceOptions;
105
- private readonly readTimeoutMs: number;
106
- private readonly maxConcurrent: number;
107
- private node: Libp2p | null = null;
108
- /** In-flight inbound wake streams, used to enforce {@link maxConcurrent}. */
109
- private activeStreams = 0;
110
-
111
- constructor(options: StrandWakeServiceOptions) {
112
- this.options = options;
113
- this.readTimeoutMs = options.readTimeoutMs ?? DEFAULT_WAKE_READ_TIMEOUT_MS;
114
- this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_WAKES;
115
- }
116
-
117
- /** Number of in-flight inbound wake streams. */
118
- get activeCount(): number {
119
- return this.activeStreams;
120
- }
121
-
122
- /** Register the wake protocol handler on the control node. */
123
- initialize(node: Libp2p): void {
124
- this.node = node;
125
- // `runOnLimitedConnection: true` is REQUIRED for the relay path: a NAT'd
126
- // receiver is reached over a circuit-relay connection, which libp2p marks
127
- // "limited" (the relay caps its data/duration). Without this the receiver
128
- // would refuse the inbound wake stream on exactly the connection the
129
- // protocol is designed to use (see the relay note on `dialWake`).
130
- void node.handle(WAKE_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
131
- const remotePeerId = (rawConnection as Connection).remotePeer.toString();
132
- await this.handleStream(rawStream as ControlStream, remotePeerId);
133
- }, { runOnLimitedConnection: true });
134
- log('StrandWakeService registered handler: %s', WAKE_PROTOCOL);
135
- }
136
-
137
- /** Unregister the handler and release the node reference. */
138
- async shutdown(): Promise<void> {
139
- if (this.node) {
140
- await this.node.unhandle(WAKE_PROTOCOL);
141
- this.node = null;
142
- log('StrandWakeService shutdown');
143
- }
144
- }
145
-
146
- /**
147
- * Read the inbound request, decide + execute the wake, and write the ack.
148
- *
149
- * Three hardening layers, all reported as a non-accepting ack rather than a
150
- * dropped/hung stream: a concurrency cap (over {@link maxConcurrent}, reply
151
- * without touching the wake path), a read timeout (a peer that never
152
- * half-closes is aborted inside {@link readFrame}/`readStreamToEnd`), and the
153
- * existing malformed/oversized-frame guard.
154
- */
155
- private async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
156
- log('Incoming wake request from: %s', remotePeerId);
157
-
158
- if (this.activeStreams >= this.maxConcurrent) {
159
- log('Rejecting wake from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrent);
160
- const ack: WakeAck = { accepted: false, reason: 'Too many concurrent wake requests' };
161
- try {
162
- writeFrame(stream, ack);
163
- } catch {
164
- // Ignore send errors on the reject path.
165
- }
166
- try {
167
- await stream.close();
168
- } catch {
169
- // Ignore close errors.
170
- }
171
- return;
172
- }
173
-
174
- this.activeStreams++;
175
- try {
176
- const request = await readFrame<WakeRequest>(stream, this.readTimeoutMs);
177
- const ack = await this.processWakeRequest(request, remotePeerId);
178
- writeFrame(stream, ack);
179
- } catch (err) {
180
- log('Error handling wake request from %s: %o', remotePeerId, err);
181
- const ack: WakeAck = {
182
- accepted: false,
183
- reason: err instanceof Error ? err.message : 'Unknown error',
184
- };
185
- try {
186
- writeFrame(stream, ack);
187
- } catch {
188
- // Ignore send errors on the error path.
189
- }
190
- } finally {
191
- this.activeStreams--;
192
- try {
193
- await stream.close();
194
- } catch {
195
- // Ignore close errors.
196
- }
197
- }
198
- }
199
-
200
- /**
201
- * Decide and execute the wake for a decoded request. Exposed (not private) so
202
- * the decision matrix can be unit-tested directly.
203
- *
204
- * - Non-member sender → rejected (`accepted: false`).
205
- * - Unknown / not-participated strand → rejected.
206
- * - Hibernating or idle strand → resumed via the wake path, then `accepted`.
207
- * - Already-live strand → no-op, `accepted` with current status.
208
- */
209
- async processWakeRequest(request: WakeRequest, remotePeerId: string): Promise<WakeAck> {
210
- // The injected membership predicate is the whole v1 authorization (CadreNode
211
- // supplies the voucher-anchored one); only a peer it admits may ask us to wake.
212
- if (!(await this.options.isMember(remotePeerId))) {
213
- log('Rejecting wake from non-member %s', remotePeerId);
214
- return { accepted: false, reason: 'Sender is not a cadre member' };
215
- }
216
-
217
- const instance = this.options.getStrand(request.strandId);
218
- if (!instance) {
219
- log('Rejecting wake for unknown/unparticipated strand %s', request.strandId);
220
- return { accepted: false, reason: 'Strand not found or not participated in' };
221
- }
222
-
223
- if (instance.status === 'hibernating' || instance.status === 'idle') {
224
- log('Waking strand %s on push from %s (reason=%s)', request.strandId, remotePeerId, request.reason ?? 'unspecified');
225
- await this.options.wake(request.strandId);
226
- } else {
227
- log('Strand %s already %s; push-wake is a no-op', request.strandId, instance.status);
228
- }
229
-
230
- // The wake path mutates the shared instance, so re-read its current status.
231
- return { accepted: true, status: instance.status };
232
- }
233
- }
234
-
235
- /** Options for {@link dialWake}. */
236
- export interface DialWakeOptions {
237
- /** Per-dial timeout in ms (default {@link DEFAULT_WAKE_TIMEOUT_MS}). */
238
- timeoutMs?: number;
239
- /** Override the protocol id (defaults to {@link WAKE_PROTOCOL}). */
240
- protocolId?: string;
241
- }
242
-
243
- /**
244
- * Sender side: dial a target's control-network address(es), send a
245
- * {@link WakeRequest} over `WAKE_PROTOCOL`, and return the peer's {@link WakeAck}.
246
- *
247
- * Tries each candidate address in order (signaling/relay first, as produced by
248
- * `CadreNode.resolvePeerAddrs`) until one dials, so a NAT'd peer is reachable via
249
- * its circuit-relay address. Throws if no address is dialable.
250
- */
251
- export async function dialWake(
252
- node: Libp2p,
253
- addrs: Multiaddr[],
254
- request: WakeRequest,
255
- options: DialWakeOptions = {}
256
- ): Promise<WakeAck> {
257
- if (addrs.length === 0) {
258
- throw new Error('No dialable address for wake target');
259
- }
260
- const protocolId = options.protocolId ?? WAKE_PROTOCOL;
261
- const timeoutMs = options.timeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS;
262
-
263
- let lastError: Error | null = null;
264
- for (const addr of addrs) {
265
- try {
266
- // One deadline per attempt: its signal aborts the in-flight dialProtocol and
267
- // resets the live stream, so neither the connect nor the ack-read leaks.
268
- return await withDeadline(
269
- timeoutMs,
270
- `Wake dial ${addr.toString()}`,
271
- (signal) => sendWake(node, addr, protocolId, request, timeoutMs, signal),
272
- );
273
- } catch (err) {
274
- lastError = err instanceof Error ? err : new Error(String(err));
275
- log('Wake dial to %s failed: %o', addr.toString(), err);
276
- }
277
- }
278
- throw lastError ?? new Error('Wake dial failed');
279
- }
280
-
281
- /**
282
- * Open one stream, send the request, half-close, and read the ack.
283
- *
284
- * `signal` is the per-attempt deadline from {@link dialWake}: it goes to
285
- * `dialProtocol` so a timeout during connect aborts the dial, and into
286
- * {@link exchangeFrame} so a timeout after the stream is open resets it —
287
- * releasing the otherwise unbounded ack-read.
288
- */
289
- async function sendWake(
290
- node: Libp2p,
291
- addr: Multiaddr,
292
- protocolId: string,
293
- request: WakeRequest,
294
- timeoutMs: number,
295
- signal: AbortSignal,
296
- ): Promise<WakeAck> {
297
- // `runOnLimitedConnection: true`: the target may be reachable only over a
298
- // circuit-relay connection (the signaling-first addr), which libp2p marks
299
- // "limited". The wake exchange is a single tiny request→ack well within the
300
- // relay's data/duration cap, so opening it on the limited connection is safe
301
- // and is the whole point of dialing the relay address.
302
- const rawStream = await node.dialProtocol(addr, protocolId, { runOnLimitedConnection: true, signal });
303
-
304
- const ack = await exchangeFrame(
305
- rawStream as unknown as ControlStream,
306
- signal,
307
- request,
308
- (stream) => readFrame<WakeAck>(stream, timeoutMs),
309
- 'Wake dial aborted by timeout',
310
- );
311
-
312
- log('Wake ack from %s: accepted=%s status=%s', addr.toString(), ack.accepted, ack.status ?? '-');
313
- return ack;
314
- }
1
+ /**
2
+ * Control-network push-wake transport.
3
+ *
4
+ * Lets a same-cadre peer — typically an always-on server that participates in a
5
+ * strand and sees new activity — signal a hibernating cadre peer to bring that
6
+ * strand online, pull pending activity, and re-hibernate. Push-wake travels the
7
+ * **control network** (the per-party network connecting this party's own cadre
8
+ * nodes), which is the only network a hibernating peer keeps connected.
9
+ *
10
+ * Modeled directly on `seed-bootstrap.ts`: a dedicated libp2p protocol id,
11
+ * 4-byte big-endian length-prefixed JSON frames, `node.handle` for the receiver,
12
+ * `node.dialProtocol` for the sender, and the shared `ControlStream` primitives
13
+ * from `control-stream.ts`. The exchange is a single request → single ack on one
14
+ * stream (like seed delivery), so each side reads to EOF (under a read timeout)
15
+ * and decodes one frame via the shared {@link decodeLengthPrefixedFrame} guard.
16
+ *
17
+ * **Authorization (v1):** a wake is low-risk — it only causes the receiver to
18
+ * spend resources coming online for a strand it already participates in — so the
19
+ * receiver carries no per-request signature and instead defers entirely to the
20
+ * injected `isMember` predicate. `CadreNode` injects its AUTHORIZED-membership
21
+ * predicate there (`isAuthorizedMember`: the sender's `CadrePeer` row must carry
22
+ * a voucher that verifies against an owner key in the receiver's node-local
23
+ * trusted-owner anchor), so a peer that merely published rows into the replicated
24
+ * control DB is refused. This module stays agnostic: it enforces whatever
25
+ * predicate it is given.
26
+ */
27
+
28
+ import debug from 'debug';
29
+ import type { Libp2p, Connection } from '@libp2p/interface';
30
+ import type { Multiaddr } from '@multiformats/multiaddr';
31
+ import type { StrandInstance, WakeRequest, WakeAck } from './types.js';
32
+ import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
33
+ import { type ControlStream, writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
34
+
35
+ const log = debug('sereus:cadre:strand-wake');
36
+
37
+ /** Protocol id for control-network push-wake (parallel to `/sereus/seed/1.0.0`). */
38
+ export const WAKE_PROTOCOL = '/sereus/strand-wake/1.0.0';
39
+
40
+ /**
41
+ * Maximum wake frame size. Wake messages are tiny (a strand id + short reason),
42
+ * so this is a defensive cap — far below the 1MB seed ceiling — that bounds the
43
+ * bytes a peer can make the receiver buffer per stream.
44
+ */
45
+ const MAX_WAKE_SIZE = 64 * 1024;
46
+
47
+ /** Default time to wait for the ack before abandoning ONE wake dial attempt (ms). */
48
+ const DEFAULT_WAKE_TIMEOUT_MS = 10_000;
49
+
50
+ /**
51
+ * Default budget for a WHOLE {@link dialWake} call, in ms — every candidate
52
+ * address together, not each one.
53
+ *
54
+ * Without it the cost of a wake is (candidate count × {@link
55
+ * DEFAULT_WAKE_TIMEOUT_MS}), a number nothing chooses or bounds. That is not
56
+ * hypothetical: an address behind a dropped NAT mapping, or any host that
57
+ * blackholes rather than sending a RST, burns its full attempt timeout instead
58
+ * of failing in milliseconds, so a two-address peer costs 20 s and a
59
+ * five-address one costs 50 s.
60
+ *
61
+ * This makes the TARGET PEER the unit rather than the address — the same
62
+ * decision, for the same reason, as
63
+ * `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` in `control-cohort.ts`, and the same
64
+ * chosen number. 20 s is deliberately wider than one attempt timeout so a
65
+ * reachable peer whose signaling address is stale still gets a genuine try at
66
+ * its direct one; a peer needing longer than that is not "asleep and reachable",
67
+ * which is the only case a wake is for. The last attempt inside the budget gets
68
+ * whatever remains of it, so the call returns at the budget, not past it.
69
+ *
70
+ * Override per call with {@link DialWakeOptions.budgetMs} — tests that drive
71
+ * dead addresses on purpose set it low so a dial's duration is a chosen number
72
+ * rather than a transitive libp2p default stretched by machine load.
73
+ */
74
+ export const DEFAULT_WAKE_DIAL_BUDGET_MS = 20_000;
75
+
76
+ /** Default time the receiver waits for an inbound wake frame before aborting (ms). */
77
+ const DEFAULT_WAKE_READ_TIMEOUT_MS = 10_000;
78
+
79
+ /** Default cap on concurrent inbound wake streams a single peer can pin open. */
80
+ const DEFAULT_MAX_CONCURRENT_WAKES = 100;
81
+
82
+ /**
83
+ * Read a libp2p stream to EOF and decode the single length-prefixed JSON frame
84
+ * it carries. Bounded by `timeoutMs` (a never-half-closing peer is aborted, not
85
+ * awaited forever) and capped at {@link MAX_WAKE_SIZE}, reusing the shared
86
+ * {@link decodeLengthPrefixedFrame} guard for the prefix/length checks.
87
+ */
88
+ async function readFrame<T>(stream: ControlStream, timeoutMs: number): Promise<T> {
89
+ const data = await readStreamToEnd(stream, { maxBytes: MAX_WAKE_SIZE, timeoutMs, label: 'Wake' });
90
+ const body = decodeLengthPrefixedFrame(data, MAX_WAKE_SIZE);
91
+ return JSON.parse(new TextDecoder().decode(body)) as T;
92
+ }
93
+
94
+ /**
95
+ * Dependencies the {@link StrandWakeService} receiver needs from its host
96
+ * (`CadreNode`), injected so the service is testable without a full node.
97
+ */
98
+ export interface StrandWakeServiceOptions {
99
+ /** Membership gate: is the remote peer a `CadrePeer` member of this cadre? */
100
+ isMember(remotePeerId: string): Promise<boolean>;
101
+ /** Look up a local strand instance by id (undefined if not participated in). */
102
+ getStrand(strandId: string): StrandInstance | undefined;
103
+ /**
104
+ * Trigger the local wake path for a hibernating/idle strand. Wired to
105
+ * `CadreNode.wakeStrand` (→ `HibernationManager` → `resumeStrand`), whose
106
+ * resume coalescing prevents a push-wake racing a concurrent check-in.
107
+ */
108
+ wake(strandId: string): Promise<void>;
109
+ /**
110
+ * Time to wait for the inbound wake frame before aborting the read (ms).
111
+ * Defaults to {@link DEFAULT_WAKE_READ_TIMEOUT_MS}. Bounds a buggy/compromised
112
+ * own-cadre node that opens a stream and never half-closes its write end.
113
+ */
114
+ readTimeoutMs?: number;
115
+ /**
116
+ * Cap on concurrent inbound wake streams (defaults to
117
+ * {@link DEFAULT_MAX_CONCURRENT_WAKES}). Over the cap, a non-accepting ack is
118
+ * returned without invoking the wake path.
119
+ */
120
+ maxConcurrent?: number;
121
+ }
122
+
123
+ /**
124
+ * Receiver side of the push-wake protocol. Registers a `WAKE_PROTOCOL` handler
125
+ * on the control node and, for each inbound {@link WakeRequest}, gates on cadre
126
+ * membership, then resumes the named strand if it is hibernating/idle and we
127
+ * participate in it — replying with a {@link WakeAck}.
128
+ */
129
+ export class StrandWakeService {
130
+ private readonly options: StrandWakeServiceOptions;
131
+ private readonly readTimeoutMs: number;
132
+ private readonly maxConcurrent: number;
133
+ private node: Libp2p | null = null;
134
+ /** In-flight inbound wake streams, used to enforce {@link maxConcurrent}. */
135
+ private activeStreams = 0;
136
+
137
+ constructor(options: StrandWakeServiceOptions) {
138
+ this.options = options;
139
+ this.readTimeoutMs = options.readTimeoutMs ?? DEFAULT_WAKE_READ_TIMEOUT_MS;
140
+ this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_WAKES;
141
+ }
142
+
143
+ /** Number of in-flight inbound wake streams. */
144
+ get activeCount(): number {
145
+ return this.activeStreams;
146
+ }
147
+
148
+ /** Register the wake protocol handler on the control node. */
149
+ initialize(node: Libp2p): void {
150
+ this.node = node;
151
+ // `runOnLimitedConnection: true` is REQUIRED for the relay path: a NAT'd
152
+ // receiver is reached over a circuit-relay connection, which libp2p marks
153
+ // "limited" (the relay caps its data/duration). Without this the receiver
154
+ // would refuse the inbound wake stream on exactly the connection the
155
+ // protocol is designed to use (see the relay note on `dialWake`).
156
+ void node.handle(WAKE_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
157
+ const remotePeerId = (rawConnection as Connection).remotePeer.toString();
158
+ await this.handleStream(rawStream as ControlStream, remotePeerId);
159
+ }, { runOnLimitedConnection: true });
160
+ log('StrandWakeService registered handler: %s', WAKE_PROTOCOL);
161
+ }
162
+
163
+ /** Unregister the handler and release the node reference. */
164
+ async shutdown(): Promise<void> {
165
+ if (this.node) {
166
+ await this.node.unhandle(WAKE_PROTOCOL);
167
+ this.node = null;
168
+ log('StrandWakeService shutdown');
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Read the inbound request, decide + execute the wake, and write the ack.
174
+ *
175
+ * Three hardening layers, all reported as a non-accepting ack rather than a
176
+ * dropped/hung stream: a concurrency cap (over {@link maxConcurrent}, reply
177
+ * without touching the wake path), a read timeout (a peer that never
178
+ * half-closes is aborted inside {@link readFrame}/`readStreamToEnd`), and the
179
+ * existing malformed/oversized-frame guard.
180
+ */
181
+ private async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
182
+ log('Incoming wake request from: %s', remotePeerId);
183
+
184
+ if (this.activeStreams >= this.maxConcurrent) {
185
+ log('Rejecting wake from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrent);
186
+ const ack: WakeAck = { accepted: false, reason: 'Too many concurrent wake requests' };
187
+ try {
188
+ writeFrame(stream, ack);
189
+ } catch {
190
+ // Ignore send errors on the reject path.
191
+ }
192
+ try {
193
+ await stream.close();
194
+ } catch {
195
+ // Ignore close errors.
196
+ }
197
+ return;
198
+ }
199
+
200
+ this.activeStreams++;
201
+ try {
202
+ const request = await readFrame<WakeRequest>(stream, this.readTimeoutMs);
203
+ const ack = await this.processWakeRequest(request, remotePeerId);
204
+ writeFrame(stream, ack);
205
+ } catch (err) {
206
+ log('Error handling wake request from %s: %o', remotePeerId, err);
207
+ const ack: WakeAck = {
208
+ accepted: false,
209
+ reason: err instanceof Error ? err.message : 'Unknown error',
210
+ };
211
+ try {
212
+ writeFrame(stream, ack);
213
+ } catch {
214
+ // Ignore send errors on the error path.
215
+ }
216
+ } finally {
217
+ this.activeStreams--;
218
+ try {
219
+ await stream.close();
220
+ } catch {
221
+ // Ignore close errors.
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Decide and execute the wake for a decoded request. Exposed (not private) so
228
+ * the decision matrix can be unit-tested directly.
229
+ *
230
+ * - Non-member sender → rejected (`accepted: false`).
231
+ * - Unknown / not-participated strand → rejected.
232
+ * - Hibernating or idle strand → resumed via the wake path, then `accepted`.
233
+ * - Already-live strand → no-op, `accepted` with current status.
234
+ */
235
+ async processWakeRequest(request: WakeRequest, remotePeerId: string): Promise<WakeAck> {
236
+ // The injected membership predicate is the whole v1 authorization (CadreNode
237
+ // supplies the voucher-anchored one); only a peer it admits may ask us to wake.
238
+ if (!(await this.options.isMember(remotePeerId))) {
239
+ log('Rejecting wake from non-member %s', remotePeerId);
240
+ return { accepted: false, reason: 'Sender is not a cadre member' };
241
+ }
242
+
243
+ const instance = this.options.getStrand(request.strandId);
244
+ if (!instance) {
245
+ log('Rejecting wake for unknown/unparticipated strand %s', request.strandId);
246
+ return { accepted: false, reason: 'Strand not found or not participated in' };
247
+ }
248
+
249
+ if (instance.status === 'hibernating' || instance.status === 'idle') {
250
+ log('Waking strand %s on push from %s (reason=%s)', request.strandId, remotePeerId, request.reason ?? 'unspecified');
251
+ await this.options.wake(request.strandId);
252
+ } else {
253
+ log('Strand %s already %s; push-wake is a no-op', request.strandId, instance.status);
254
+ }
255
+
256
+ // The wake path mutates the shared instance, so re-read its current status.
257
+ return { accepted: true, status: instance.status };
258
+ }
259
+ }
260
+
261
+ /** Options for {@link dialWake}. */
262
+ export interface DialWakeOptions {
263
+ /** Per-ATTEMPT timeout in ms (default {@link DEFAULT_WAKE_TIMEOUT_MS}). */
264
+ timeoutMs?: number;
265
+ /**
266
+ * Budget for the whole call — every candidate together (default
267
+ * {@link DEFAULT_WAKE_DIAL_BUDGET_MS}).
268
+ */
269
+ budgetMs?: number;
270
+ /** Override the protocol id (defaults to {@link WAKE_PROTOCOL}). */
271
+ protocolId?: string;
272
+ }
273
+
274
+ /** One candidate's outcome, kept so the thrown error can name every attempt. */
275
+ interface WakeDialFailure {
276
+ addr: Multiaddr;
277
+ error: Error;
278
+ }
279
+
280
+ /**
281
+ * Sender side: dial a target's control-network address(es), send a
282
+ * {@link WakeRequest} over `WAKE_PROTOCOL`, and return the peer's {@link WakeAck}.
283
+ *
284
+ * Tries each candidate address in order (signaling/relay first, as produced by
285
+ * `CadreNode.resolvePeerAddrs`) until one dials, so a NAT'd peer is reachable via
286
+ * its circuit-relay address. Each attempt is bounded by `timeoutMs` and the call
287
+ * as a whole by `budgetMs` (see {@link DEFAULT_WAKE_DIAL_BUDGET_MS}); a candidate
288
+ * the budget leaves no room for is reported as untried rather than silently
289
+ * dropped.
290
+ *
291
+ * Throws if no address is dialable — with an error naming EVERY candidate and
292
+ * why it failed, not merely the last one. That distinction is not cosmetic:
293
+ * with only the last candidate's message the failure that mattered (usually the
294
+ * signaling address, tried first) is invisible outside a debug log, and the
295
+ * surfaced message points at whichever address happened to be tried last.
296
+ *
297
+ * The candidate loop is deliberately explicit rather than one
298
+ * `dialProtocol(addrs)` call. libp2p sorts any multi-address dial with
299
+ * `defaultAddressSorter`, whose `circuitRelayAddressesLast` pass would demote
300
+ * exactly the signaling address this ordering puts first — silently inverting
301
+ * it, with no per-dial sorter override to opt out of.
302
+ */
303
+ export async function dialWake(
304
+ node: Libp2p,
305
+ addrs: Multiaddr[],
306
+ request: WakeRequest,
307
+ options: DialWakeOptions = {}
308
+ ): Promise<WakeAck> {
309
+ if (addrs.length === 0) {
310
+ throw new Error('No dialable address for wake target');
311
+ }
312
+ const protocolId = options.protocolId ?? WAKE_PROTOCOL;
313
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS;
314
+ const budgetMs = options.budgetMs ?? DEFAULT_WAKE_DIAL_BUDGET_MS;
315
+ const deadline = Date.now() + budgetMs;
316
+
317
+ const failures: WakeDialFailure[] = [];
318
+ for (const addr of addrs) {
319
+ // Whatever is left of the whole-call budget caps this attempt, so the last
320
+ // candidate inside the budget still gets a real (if shortened) try.
321
+ const remaining = deadline - Date.now();
322
+ if (remaining <= 0) {
323
+ failures.push({ addr, error: new Error(`not tried — the ${budgetMs}ms wake dial budget was spent on earlier addresses`) });
324
+ continue;
325
+ }
326
+ const attemptMs = Math.min(timeoutMs, remaining);
327
+ try {
328
+ // One deadline per attempt: its signal aborts the in-flight dialProtocol and
329
+ // resets the live stream, so neither the connect nor the ack-read leaks.
330
+ return await withDeadline(
331
+ attemptMs,
332
+ `Wake dial ${addr.toString()}`,
333
+ (signal) => sendWake(node, addr, protocolId, request, attemptMs, signal),
334
+ );
335
+ } catch (err) {
336
+ failures.push({ addr, error: err instanceof Error ? err : new Error(String(err)) });
337
+ log('Wake dial to %s failed: %o', addr.toString(), err);
338
+ }
339
+ }
340
+ throw wakeDialError(failures);
341
+ }
342
+
343
+ /**
344
+ * Fold every candidate's failure into the one error {@link dialWake} throws.
345
+ *
346
+ * A single candidate throws its own error unchanged, so a one-address wake reads
347
+ * exactly as it always has (and `rejects.toThrow(/timed out/)` still means what
348
+ * it says). Several candidates produce one message naming each address and its
349
+ * cause, with the FIRST failure as `cause` — the first candidate is the
350
+ * signaling address, which is the one that matters when a relayed peer goes
351
+ * unreachable.
352
+ */
353
+ function wakeDialError(failures: WakeDialFailure[]): Error {
354
+ if (failures.length === 0) {
355
+ return new Error('Wake dial failed');
356
+ }
357
+ if (failures.length === 1) {
358
+ return failures[0].error;
359
+ }
360
+ const detail = failures.map((f) => `${f.addr.toString()} — ${f.error.message}`).join('; ');
361
+ return new Error(
362
+ `Wake dial failed for all ${failures.length} candidate addresses: ${detail}`,
363
+ { cause: failures[0].error },
364
+ );
365
+ }
366
+
367
+ /**
368
+ * Open one stream, send the request, half-close, and read the ack.
369
+ *
370
+ * `signal` is the per-attempt deadline from {@link dialWake}: it goes to
371
+ * `dialProtocol` so a timeout during connect aborts the dial, and into
372
+ * {@link exchangeFrame} so a timeout after the stream is open resets it —
373
+ * releasing the otherwise unbounded ack-read.
374
+ */
375
+ async function sendWake(
376
+ node: Libp2p,
377
+ addr: Multiaddr,
378
+ protocolId: string,
379
+ request: WakeRequest,
380
+ timeoutMs: number,
381
+ signal: AbortSignal,
382
+ ): Promise<WakeAck> {
383
+ // `runOnLimitedConnection: true`: the target may be reachable only over a
384
+ // circuit-relay connection (the signaling-first addr), which libp2p marks
385
+ // "limited". The wake exchange is a single tiny request→ack well within the
386
+ // relay's data/duration cap, so opening it on the limited connection is safe
387
+ // and is the whole point of dialing the relay address.
388
+ const rawStream = await node.dialProtocol(addr, protocolId, { runOnLimitedConnection: true, signal });
389
+
390
+ const ack = await exchangeFrame(
391
+ rawStream as unknown as ControlStream,
392
+ signal,
393
+ request,
394
+ (stream) => readFrame<WakeAck>(stream, timeoutMs),
395
+ 'Wake dial aborted by timeout',
396
+ );
397
+
398
+ log('Wake ack from %s: accepted=%s status=%s', addr.toString(), ack.accepted, ack.status ?? '-');
399
+ return ack;
400
+ }