@optimystic/db-p2p 0.24.0 → 0.24.2

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 (60) hide show
  1. package/dist/src/cluster/service.d.ts +8 -0
  2. package/dist/src/cluster/service.d.ts.map +1 -1
  3. package/dist/src/cluster/service.js +16 -4
  4. package/dist/src/cluster/service.js.map +1 -1
  5. package/dist/src/cohort-topic/host.js +34 -11
  6. package/dist/src/cohort-topic/host.js.map +1 -1
  7. package/dist/src/cohort-topic/stream-util.d.ts +25 -11
  8. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  9. package/dist/src/cohort-topic/stream-util.js +31 -19
  10. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  11. package/dist/src/libp2p-key-network.d.ts +68 -0
  12. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  13. package/dist/src/libp2p-key-network.js +123 -14
  14. package/dist/src/libp2p-key-network.js.map +1 -1
  15. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  16. package/dist/src/libp2p-node-base.js +8 -5
  17. package/dist/src/libp2p-node-base.js.map +1 -1
  18. package/dist/src/logger.d.ts +2 -2
  19. package/dist/src/logger.js +2 -2
  20. package/dist/src/matchmaking/query-transport.js +3 -3
  21. package/dist/src/matchmaking/query-transport.js.map +1 -1
  22. package/dist/src/peer-address-book.d.ts +69 -0
  23. package/dist/src/peer-address-book.d.ts.map +1 -1
  24. package/dist/src/peer-address-book.js +110 -15
  25. package/dist/src/peer-address-book.js.map +1 -1
  26. package/dist/src/reactivity/notify-transport.d.ts +4 -4
  27. package/dist/src/reactivity/notify-transport.js +6 -6
  28. package/dist/src/reactivity/notify-transport.js.map +1 -1
  29. package/dist/src/reactivity/push-state-gossip.js +2 -2
  30. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  31. package/dist/src/reactivity/recover-transport.d.ts +6 -2
  32. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  33. package/dist/src/reactivity/recover-transport.js +7 -3
  34. package/dist/src/reactivity/recover-transport.js.map +1 -1
  35. package/dist/src/repo/service.d.ts +6 -0
  36. package/dist/src/repo/service.d.ts.map +1 -1
  37. package/dist/src/repo/service.js +12 -2
  38. package/dist/src/repo/service.js.map +1 -1
  39. package/dist/src/routing/libp2p-known-peers.d.ts.map +1 -1
  40. package/dist/src/routing/libp2p-known-peers.js +5 -0
  41. package/dist/src/routing/libp2p-known-peers.js.map +1 -1
  42. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
  43. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  44. package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
  45. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/cluster/service.ts +305 -293
  48. package/src/cohort-topic/host.ts +2932 -2901
  49. package/src/cohort-topic/stream-util.ts +147 -135
  50. package/src/libp2p-key-network.ts +1235 -1120
  51. package/src/libp2p-node-base.ts +1678 -1675
  52. package/src/logger.ts +27 -27
  53. package/src/matchmaking/query-transport.ts +492 -492
  54. package/src/peer-address-book.ts +266 -149
  55. package/src/reactivity/notify-transport.ts +144 -144
  56. package/src/reactivity/push-state-gossip.ts +291 -291
  57. package/src/reactivity/recover-transport.ts +412 -408
  58. package/src/repo/service.ts +323 -313
  59. package/src/routing/libp2p-known-peers.ts +31 -26
  60. package/src/testing/cohort-topic-mesh-harness.ts +673 -663
@@ -1,408 +1,412 @@
1
- /**
2
- * Reactivity recover RPC transport — the live libp2p backfill/resume recovery protocol
3
- * (`docs/reactivity.md` §Backfill RPC, §Resume).
4
- *
5
- * This is the **pull** companion to the one-way notify transport: a subscriber that detected a gap (or
6
- * woke from sleep) asks a serving cohort member "what did I miss?" and is brought current. The protocol is
7
- * **request-reply** — one {@link RecoverRequestV1} frame out, one {@link RecoverReplyV1} frame back over a
8
- * single stream — so it rides the cohort-topic {@link requestResponse} / {@link handleRequestResponse}
9
- * helpers (NOT the one-way notify helpers, which would desync the dialer).
10
- *
11
- * Two pieces live here, both decoupled from the live node assembly so they unit-test in isolation:
12
- *
13
- * - {@link Libp2pReactivityRecoverTransport} — the **outbound** side. Supplies the two db-core seams
14
- * (`BackfillTransport` / `ResumeTransport`) against one node: pick a target (sticky cohort-hint primary
15
- * first for the one-RT happy path, else a cohort-walk member), frame the request, exchange it over the
16
- * recover protocol, and return the inner reply. The wire exchange is injected as a {@link RecoverDialer}
17
- * so the target-selection + framing logic tests without a real socket; {@link createLibp2pRecoverDialer}
18
- * is the production libp2p-backed implementation.
19
- * - {@link createRecoverRequestHandler} / {@link registerRecoverHandler} — the **inbound** serve handler.
20
- * Decode (bounded) → verify the request's peer-key signature against the dialing peer → reject a
21
- * replay/stale request → resolve the live `PushState` → `serveBackfill` / `serveResume` → reply. Any
22
- * failure produces **no reply** (the stream aborts) rather than throwing out of the handler.
23
- *
24
- * ## Dial-target encoding bridge (load-bearing)
25
- *
26
- * The sticky {@link import("@optimystic/db-core").ReactivityCohortHint}`.primary` is **base64url of
27
- * cohort member-id bytes** (`base64url(utf8(peerIdString))`), per its db-core JSDoc, whereas
28
- * {@link requestResponse} needs a `peerIdFromString`-parseable peer-id **string**. Feeding the raw
29
- * base64url straight to `peerIdFromString` throws → the dial is swallowed → recovery silently never
30
- * reaches the cohort. {@link decodeCohortHintTarget} pins the conversion
31
- * (`bytesToPeerIdString(b64urlToBytes(primary))`); the `resolveCohort` walk, by contrast, already returns
32
- * peer-id strings (the cohort-topic `CohortPeerResolver` space) and is used as-is.
33
- */
34
-
35
- import type { Libp2p } from "libp2p";
36
- import type { PeerId, PrivateKey } from "@libp2p/interface";
37
- import { peerIdFromString } from "@libp2p/peer-id";
38
- import {
39
- b64urlToBytes,
40
- bytesToB64url,
41
- reactivityTopicId,
42
- serveBackfill,
43
- serveResume,
44
- backfillSigningPayload,
45
- resumeSigningPayload,
46
- encodeRecoverRequestV1,
47
- decodeRecoverRequestV1,
48
- encodeRecoverReplyV1,
49
- decodeRecoverReplyV1,
50
- type BackfillV1,
51
- type BackfillReplyV1,
52
- type BackfillSignable,
53
- type ResumeV1,
54
- type ResumeSignable,
55
- type RecoverKind,
56
- type RecoverReplyV1,
57
- type BackfillTransport,
58
- type PushState,
59
- type StickyCohortHintCache,
60
- type CorrelationReplayGuard,
61
- type RotationRedirectV1,
62
- } from "@optimystic/db-core";
63
- import { requestResponse, handleRequestResponse, DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
64
- import { verifyPeerSig, signPeerSig } from "../cohort-topic/peer-sig.js";
65
- import { peerIdToBytes, bytesToPeerIdString } from "../cohort-topic/peer-codec.js";
66
- import { PROTOCOL_REACTIVITY_RECOVER } from "./protocols.js";
67
- import type { ResumeTransport } from "./subscription-manager.js";
68
- import { createLogger } from "../logger.js";
69
-
70
- const log = createLogger("reactivity-recover");
71
-
72
- /**
73
- * Decode a sticky-cohort-hint `primary` (base64url of member-id bytes = `base64url(utf8(peerIdString))`)
74
- * back to the dialable peer-id string. The inverse trap of feeding the raw value to `peerIdFromString`
75
- * (which expects a peer-id string, not base64url-of-its-utf8) — pinned by the dial-target encoding test.
76
- */
77
- export function decodeCohortHintTarget(primary: string): string {
78
- return bytesToPeerIdString(b64urlToBytes(primary));
79
- }
80
-
81
- // --- subscriber request signing (the manager's synchronous signer seam) ---
82
-
83
- /**
84
- * The synchronous request-signing seam the production subscribe factory feeds a
85
- * {@link import("./subscription-manager.js").ReactivitySubscriptionManager}: it builds a
86
- * {@link BackfillV1} / {@link ResumeV1} signature (base64url) over the unsigned image.
87
- */
88
- export interface RecoverRequestSigners {
89
- /** Sign a {@link BackfillV1} over {@link backfillSigningPayload} of its unsigned image. */
90
- readonly signBackfill: (unsigned: BackfillSignable) => string;
91
- /** Sign a {@link ResumeV1} over {@link resumeSigningPayload} of its unsigned image. */
92
- readonly signResume: (unsigned: ResumeSignable) => string;
93
- }
94
-
95
- /**
96
- * Build the subscriber's recover request signers from the node's libp2p Ed25519 private key.
97
- *
98
- * This resolves the recover wiring's one design point: the subscription-manager `signBackfill` /
99
- * `signResume` ports are **synchronous** (`(unsigned) => string`) — the db-core driver builds the
100
- * unsigned image internally, so a pre-signed value is impossible — but libp2p `PrivateKey.sign` is
101
- * **async**. Rather than make the seam (and `createBackfillRequester`'s `sign`, a db-core change) async,
102
- * sign with the synchronous {@link signPeerSig} (noble, over the raw Ed25519 seed) over db-core's
103
- * canonical signing payloads. The produced signature verifies on the serving side under
104
- * {@link verifyPeerSig} over the same bytes (both noble, RFC8032), so the round trip is symmetric.
105
- */
106
- export function createRecoverRequestSigners(privateKey: PrivateKey): RecoverRequestSigners {
107
- return {
108
- signBackfill: (unsigned: BackfillSignable): string => bytesToB64url(signPeerSig(privateKey, backfillSigningPayload(unsigned))),
109
- signResume: (unsigned: ResumeSignable): string => bytesToB64url(signPeerSig(privateKey, resumeSigningPayload(unsigned))),
110
- };
111
- }
112
-
113
- // --- outbound transport ---
114
-
115
- /**
116
- * Thrown out of {@link Libp2pReactivityRecoverTransport.backfillTransport} / `resumeTransport` when the
117
- * dialed cohort answered with a `kind: "rotated"` recover reply: the outgoing tail this request reached has
118
- * rotated and is draining, so it bounced the request to the new tree (`docs/reactivity.md` §Tail rotation).
119
- * It carries the {@link RotationRedirectV1} so the subscription manager can move itself to the successor.
120
- *
121
- * A `kind: "rotated"` reply is **terminal** for the cohort-walk — the dialed member spoke authoritatively for
122
- * the cohort, so the transport stops rather than falling through to the next target (contrast a *dial
123
- * failure*, which still falls through). The subscriber honors it like a notification-driven rotation.
124
- */
125
- export class RotationRedirectError extends Error {
126
- /** The drain-window redirect the serving cohort returned (`newTailId` / `newTopicId` / `effectiveAtRevision`). */
127
- readonly redirect: RotationRedirectV1;
128
- constructor(redirect: RotationRedirectV1) {
129
- super(`reactivity recover: cohort rotated to tail ${redirect.newTailId} (effectiveAtRevision ${redirect.effectiveAtRevision})`);
130
- this.name = "RotationRedirectError";
131
- this.redirect = redirect;
132
- }
133
- }
134
-
135
- /** The wire exchange seam: open the recover protocol to `target`, send `frame`, return the bounded reply. */
136
- export interface RecoverDialer {
137
- /** Exchange one request frame for one reply frame with `target` (peer-id string). Rejects on a dial failure. */
138
- exchange(target: string, frame: Uint8Array): Promise<Uint8Array>;
139
- }
140
-
141
- /** Build the production libp2p-backed {@link RecoverDialer} over {@link requestResponse}. */
142
- export function createLibp2pRecoverDialer(node: Libp2p, recoverProtocol: string = PROTOCOL_REACTIVITY_RECOVER, maxBytes: number = DEFAULT_STREAM_MAX_BYTES): RecoverDialer {
143
- return {
144
- exchange: (target, frame) => requestResponse(node, peerIdFromString(target), recoverProtocol, frame, maxBytes),
145
- };
146
- }
147
-
148
- /** Construction inputs for a {@link Libp2pReactivityRecoverTransport}. */
149
- export interface Libp2pReactivityRecoverTransportOptions {
150
- /** Wire exchange (e.g. {@link createLibp2pRecoverDialer} over the live node). */
151
- readonly dialer: RecoverDialer;
152
- /** This node's peer-id string (`node.peerId.toString()`); never dialed — a co-located serve is the node wiring's. */
153
- readonly selfPeerId: string;
154
- /** Sticky cohort-hint cache, shared with the subscription manager (the one-RT primary for the happy path). */
155
- readonly cohortHintCache: StickyCohortHintCache;
156
- /** FRET cohort-walk fallback: a topic id → dialable cohort member peer-id strings. */
157
- readonly resolveCohort: (topicId: Uint8Array) => string[];
158
- /** Per-frame ceiling for encode/decode; default {@link DEFAULT_STREAM_MAX_BYTES}. */
159
- readonly maxBytes?: number;
160
- }
161
-
162
- /**
163
- * The outbound recover transport: one instance per node, exposing the two db-core function seams against it.
164
- * Each returned transport dials the **sticky primary first** (one round trip after a brief flap), falling
165
- * back to a **cohort-walk** member on a dial failure (any member that holds the topic's gossiped `PushState`
166
- * can answer). A kind mismatch or all-targets-failed surfaces as a rejection so the caller's retry/escalation
167
- * policy (the subscription manager's backfill escalation, or `manager.resume()`'s caller) takes over.
168
- */
169
- export class Libp2pReactivityRecoverTransport {
170
- private readonly dialer: RecoverDialer;
171
- private readonly selfPeerId: string;
172
- private readonly cohortHintCache: StickyCohortHintCache;
173
- private readonly resolveCohort: (topicId: Uint8Array) => string[];
174
- private readonly maxBytes: number;
175
-
176
- constructor(options: Libp2pReactivityRecoverTransportOptions) {
177
- this.dialer = options.dialer;
178
- this.selfPeerId = options.selfPeerId;
179
- this.cohortHintCache = options.cohortHintCache;
180
- this.resolveCohort = options.resolveCohort;
181
- this.maxBytes = options.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
182
- }
183
-
184
- /** The db-core {@link BackfillTransport} for `(topicId, collectionId)` — frames + dials a signed {@link BackfillV1}. */
185
- backfillTransport(topicId: Uint8Array, collectionId: string): BackfillTransport {
186
- return async (req: BackfillV1): Promise<BackfillReplyV1> => {
187
- const frame = encodeRecoverRequestV1({ v: 1, kind: "backfill", backfill: req }, this.maxBytes);
188
- const reply = await this.exchange("backfill", frame, topicId, collectionId);
189
- if (reply.backfillReply === undefined) {
190
- throw new Error("reactivity recover: backfill reply missing its body");
191
- }
192
- return reply.backfillReply;
193
- };
194
- }
195
-
196
- /** The {@link ResumeTransport} for `(topicId, collectionId)` — frames + dials a signed {@link ResumeV1}. */
197
- resumeTransport(topicId: Uint8Array, collectionId: string): ResumeTransport {
198
- return async (req: ResumeV1) => {
199
- const frame = encodeRecoverRequestV1({ v: 1, kind: "resume", resume: req }, this.maxBytes);
200
- const reply = await this.exchange("resume", frame, topicId, collectionId);
201
- if (reply.resumeReply === undefined) {
202
- throw new Error("reactivity recover: resume reply missing its body");
203
- }
204
- return reply.resumeReply;
205
- };
206
- }
207
-
208
- /**
209
- * Exchange one recover frame with the first reachable target: sticky primary, then each cohort-walk
210
- * member. A **dial failure** falls through to the next candidate; a successful dial is **terminal** (the
211
- * member answered for the cohort) — a `kind: "rotated"` redirect throws {@link RotationRedirectError}, and
212
- * a reply decoding to the wrong `kind` is a protocol error. Throws when no candidate succeeds.
213
- */
214
- private async exchange(kind: RecoverKind, frame: Uint8Array, topicId: Uint8Array, collectionId: string): Promise<RecoverReplyV1> {
215
- const targets = this.selectTargets(topicId, collectionId);
216
- if (targets.length === 0) {
217
- throw new Error("reactivity recover: no serving cohort target resolved");
218
- }
219
- let lastErr: unknown;
220
- for (const target of targets) {
221
- let replyFrame: Uint8Array;
222
- try {
223
- replyFrame = await this.dialer.exchange(target, frame);
224
- } catch (err) {
225
- lastErr = err; // dial failure → fall back to the next candidate (sticky → walk)
226
- log("recover dial to %s failed, trying next target: %o", target, err);
227
- continue;
228
- }
229
- const reply = decodeRecoverReplyV1(replyFrame, this.maxBytes); // a decode failure here is terminal
230
- // A rotated cohort answered authoritatively: surface the redirect and stop the walk (terminal, never
231
- // a fallthrough — the dialed member spoke for the cohort).
232
- if (reply.kind === "rotated") {
233
- throw new RotationRedirectError(reply.rotated!);
234
- }
235
- if (reply.kind !== kind) {
236
- throw new Error(`reactivity recover: reply kind "${reply.kind}" does not match request kind "${kind}"`);
237
- }
238
- return reply;
239
- }
240
- throw lastErr ?? new Error("reactivity recover: all targets failed");
241
- }
242
-
243
- /**
244
- * Ordered dial candidates for a recover: the sticky primary (decoded from its base64url-of-bytes form)
245
- * first, then the cohort-walk members. Self is never dialed (a co-located serve is the node wiring's
246
- * concern), and duplicates collapse.
247
- */
248
- private selectTargets(topicId: Uint8Array, collectionId: string): string[] {
249
- const seen = new Set<string>();
250
- const targets: string[] = [];
251
- const add = (target: string | undefined): void => {
252
- if (target === undefined || target === this.selfPeerId || seen.has(target)) {
253
- return;
254
- }
255
- seen.add(target);
256
- targets.push(target);
257
- };
258
- const hint = this.cohortHintCache.get(collectionId);
259
- if (hint?.primary !== undefined) {
260
- try {
261
- add(decodeCohortHintTarget(hint.primary));
262
- } catch (err) {
263
- log("recover: malformed sticky primary for %s, skipping to cohort-walk: %o", collectionId, err);
264
- }
265
- }
266
- for (const member of this.resolveCohort(topicId)) {
267
- add(member);
268
- }
269
- return targets;
270
- }
271
- }
272
-
273
- // --- inbound serve handler ---
274
-
275
- /** Live `PushState` resolvers + replay guard the recover serve handler dispatches against. */
276
- export interface RecoverServeDeps {
277
- /** Resolve the served `PushState` for an exact reactivity topic id (resume's stale-tail lookup). */
278
- readonly pushStateFor: (topicId: Uint8Array) => PushState | undefined;
279
- /** Resolve the served `PushState` for a collection id — the current tail (backfill, and rotated resume). */
280
- readonly pushStateForCollection: (collectionId: string) => PushState | undefined;
281
- /** Node-level freshness + anti-replay gate keyed on the request signature bytes. */
282
- readonly replayGuard: CorrelationReplayGuard;
283
- /**
284
- * The drain-window redirect for a request that reached an **outgoing (rotated)** tail still in its drain
285
- * window, or `undefined` if the resolved topic never rotated / has drained. The node wiring binds this to
286
- * `ReactivityForwarderHost.rotationRedirectFor`, resolving the old topic from the request: for **resume**
287
- * the request carries `topicId = reactivityTopicId(latestKnownTailId)`; for **backfill** (no `topicId`)
288
- * the binding resolves the collection's current served topic. When it returns a redirect the serve replies
289
- * `kind: "rotated"` instead of serving data, moving the subscriber to the new tree. Absent ⇒ never redirect.
290
- */
291
- readonly rotationFor?: (req: { topicId?: Uint8Array; collectionId: string }, now: number) => RotationRedirectV1 | undefined;
292
- /** Unix-ms clock for the replay-guard window. Default `Date.now`. */
293
- readonly clock?: () => number;
294
- /** Per-frame decode ceiling; default {@link DEFAULT_STREAM_MAX_BYTES}. */
295
- readonly maxBytes?: number;
296
- }
297
-
298
- /**
299
- * Verify the request's peer-key signature against the dialing peer, then admit it through the freshness +
300
- * anti-replay guard. The dialing peer's id **is** the signer (no signer-id field on the wire); the signature
301
- * bytes are the anti-replay key (globally unique + authenticated). Returns `false` (reject, no reply) on a
302
- * bad signature, a stale/future timestamp, or a replay.
303
- */
304
- function verifyAndAdmit(deps: RecoverServeDeps, signerId: string, payload: Uint8Array, signatureB64: string, timestamp: number, now: number): boolean {
305
- const signature = b64urlToBytes(signatureB64);
306
- if (!verifyPeerSig(signerId, payload, signature)) {
307
- log("recover serve: signature verification failed for %s (no reply)", signerId);
308
- return false;
309
- }
310
- if (!deps.replayGuard.accept(signature, peerIdToBytes(signerId), timestamp, now)) {
311
- log("recover serve: replay/stale request from %s (no reply)", signerId);
312
- return false;
313
- }
314
- return true;
315
- }
316
-
317
- /**
318
- * Serve a backfill from the collection's current served tail, or `undefined` if this node serves none. When
319
- * the node serves **only** the outgoing (draining) tail, `rotationFor` returns the drain redirect and the
320
- * reply is `kind: "rotated"` instead — a best-effort secondary path (the primary mechanism for an active
321
- * subscriber is notify-driven detection). Once both tails coexist `pushStateForCollection` resolves the new
322
- * tail, so the redirect is emitted only while the old tail is the sole served state (see §Tail rotation).
323
- */
324
- function serveBackfillReply(deps: RecoverServeDeps, req: BackfillV1, now: number): Uint8Array | undefined {
325
- const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
326
- const redirect = deps.rotationFor?.({ collectionId: req.collectionId }, now);
327
- if (redirect !== undefined) {
328
- return encodeRecoverReplyV1({ v: 1, kind: "rotated", rotated: redirect }, maxBytes);
329
- }
330
- const ps = deps.pushStateForCollection(req.collectionId);
331
- if (ps === undefined) {
332
- return undefined; // not a serving member for this collection → no reply (subscriber walks/chain-reads)
333
- }
334
- const backfillReply = serveBackfill(ps.replayBuffer, req, ps.collectionId);
335
- return encodeRecoverReplyV1({ v: 1, kind: "backfill", backfillReply }, maxBytes);
336
- }
337
-
338
- /**
339
- * Serve a resume against the live `PushState`. A resume reaching the **outgoing (draining)** tail — its
340
- * `latestKnownTailId` anchors a topic this node has marked rotated — is answered with the drain redirect
341
- * (`kind: "rotated"`), moving the subscriber to the new tree. Otherwise prefer the exact topic the request's
342
- * `latestKnownTailId` anchors (so a non-rotated subscriber classifies into backfill/checkpoint/out_of_window);
343
- * if this node no longer serves that tail's topic, fall back to the collection's current tail so the cohort
344
- * can still answer `tail_rotated` (its `currentTailId` differs from the request's stale tail) or, for a span
345
- * that crosses a rotation, serve from the new tail's `inheritedCheckpoint`. `undefined` ⇒ no served state.
346
- */
347
- function serveResumeReply(deps: RecoverServeDeps, req: ResumeV1, now: number): Uint8Array | undefined {
348
- const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
349
- const staleTopic = reactivityTopicId(b64urlToBytes(req.latestKnownTailId));
350
- const redirect = deps.rotationFor?.({ topicId: staleTopic, collectionId: req.collectionId }, now);
351
- if (redirect !== undefined) {
352
- return encodeRecoverReplyV1({ v: 1, kind: "rotated", rotated: redirect }, maxBytes);
353
- }
354
- const ps = deps.pushStateFor(staleTopic) ?? deps.pushStateForCollection(req.collectionId);
355
- if (ps === undefined) {
356
- return undefined;
357
- }
358
- const resumeReply = serveResume(req, {
359
- buffer: ps.replayBuffer,
360
- checkpoint: ps.checkpoint,
361
- inheritedCheckpoint: ps.inheritedCheckpoint,
362
- currentTailId: ps.tailIdAtJoin,
363
- currentRevision: ps.lastRevision,
364
- rotationRevision: ps.lastRevision,
365
- expectedCollectionId: ps.collectionId,
366
- });
367
- return encodeRecoverReplyV1({ v: 1, kind: "resume", resumeReply }, maxBytes);
368
- }
369
-
370
- /**
371
- * Build the recover serve callback for {@link handleRequestResponse}: it returns the reply frame, or
372
- * `undefined` for **no reply** (a decode/verify/replay/resolve failure aborts the stream). It never throws
373
- * out of the handler.
374
- */
375
- export function createRecoverRequestHandler(deps: RecoverServeDeps): (frame: Uint8Array, fromPeer: PeerId) => Promise<Uint8Array | undefined> {
376
- const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
377
- const clock = deps.clock ?? ((): number => Date.now());
378
- return (frame: Uint8Array, fromPeer: PeerId): Promise<Uint8Array | undefined> => {
379
- try {
380
- const req = decodeRecoverRequestV1(frame, maxBytes);
381
- const signerId = fromPeer.toString();
382
- const now = clock();
383
- if (req.kind === "backfill") {
384
- const b = req.backfill;
385
- if (b === undefined || !verifyAndAdmit(deps, signerId, backfillSigningPayload(b), b.signature, b.timestamp, now)) {
386
- return Promise.resolve(undefined);
387
- }
388
- return Promise.resolve(serveBackfillReply(deps, b, now));
389
- }
390
- const r = req.resume;
391
- if (r === undefined || !verifyAndAdmit(deps, signerId, resumeSigningPayload(r), r.signature, r.timestamp, now)) {
392
- return Promise.resolve(undefined);
393
- }
394
- return Promise.resolve(serveResumeReply(deps, r, now));
395
- } catch (err) {
396
- // A malformed/foreign request (decode failure, foreign collectionId from serve*) must never throw
397
- // out of the stream handler: log + no reply (the stream aborts, the subscriber falls back).
398
- log("recover serve: dropping request (no reply): %o", err);
399
- return Promise.resolve(undefined);
400
- }
401
- };
402
- }
403
-
404
- /** Register the inbound recover protocol handler on `node` (request-reply over the recover protocol). */
405
- export function registerRecoverHandler(node: Libp2p, protocol: string, deps: RecoverServeDeps): void {
406
- const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
407
- handleRequestResponse(node, protocol, createRecoverRequestHandler(deps), maxBytes);
408
- }
1
+ /**
2
+ * Reactivity recover RPC transport — the live libp2p backfill/resume recovery protocol
3
+ * (`docs/reactivity.md` §Backfill RPC, §Resume).
4
+ *
5
+ * This is the **pull** companion to the one-way notify transport: a subscriber that detected a gap (or
6
+ * woke from sleep) asks a serving cohort member "what did I miss?" and is brought current. The protocol is
7
+ * **request-reply** — one {@link RecoverRequestV1} frame out, one {@link RecoverReplyV1} frame back over a
8
+ * single stream — so it rides the cohort-topic {@link requestResponse} / {@link handleRequestResponse}
9
+ * helpers (NOT the one-way notify helpers, which would desync the dialer).
10
+ *
11
+ * Two pieces live here, both decoupled from the live node assembly so they unit-test in isolation:
12
+ *
13
+ * - {@link Libp2pReactivityRecoverTransport} — the **outbound** side. Supplies the two db-core seams
14
+ * (`BackfillTransport` / `ResumeTransport`) against one node: pick a target (sticky cohort-hint primary
15
+ * first for the one-RT happy path, else a cohort-walk member), frame the request, exchange it over the
16
+ * recover protocol, and return the inner reply. The wire exchange is injected as a {@link RecoverDialer}
17
+ * so the target-selection + framing logic tests without a real socket; {@link createLibp2pRecoverDialer}
18
+ * is the production libp2p-backed implementation.
19
+ * - {@link createRecoverRequestHandler} / {@link registerRecoverHandler} — the **inbound** serve handler.
20
+ * Decode (bounded) → verify the request's peer-key signature against the dialing peer → reject a
21
+ * replay/stale request → resolve the live `PushState` → `serveBackfill` / `serveResume` → reply. Any
22
+ * failure produces **no reply** (the stream aborts) rather than throwing out of the handler.
23
+ *
24
+ * ## Dial-target encoding bridge (load-bearing)
25
+ *
26
+ * The sticky {@link import("@optimystic/db-core").ReactivityCohortHint}`.primary` is **base64url of
27
+ * cohort member-id bytes** (`base64url(utf8(peerIdString))`), per its db-core JSDoc, whereas
28
+ * {@link requestResponse} needs a `peerIdFromString`-parseable peer-id **string**. Feeding the raw
29
+ * base64url straight to `peerIdFromString` throws → the dial is swallowed → recovery silently never
30
+ * reaches the cohort. {@link decodeCohortHintTarget} pins the conversion
31
+ * (`bytesToPeerIdString(b64urlToBytes(primary))`); the `resolveCohort` walk, by contrast, already returns
32
+ * peer-id strings (the cohort-topic `CohortPeerResolver` space) and is used as-is.
33
+ */
34
+
35
+ import type { Libp2p } from "libp2p";
36
+ import type { PeerId, PrivateKey } from "@libp2p/interface";
37
+ import { peerIdFromString } from "@libp2p/peer-id";
38
+ import {
39
+ b64urlToBytes,
40
+ bytesToB64url,
41
+ reactivityTopicId,
42
+ serveBackfill,
43
+ serveResume,
44
+ backfillSigningPayload,
45
+ resumeSigningPayload,
46
+ encodeRecoverRequestV1,
47
+ decodeRecoverRequestV1,
48
+ encodeRecoverReplyV1,
49
+ decodeRecoverReplyV1,
50
+ type BackfillV1,
51
+ type BackfillReplyV1,
52
+ type BackfillSignable,
53
+ type ResumeV1,
54
+ type ResumeSignable,
55
+ type RecoverKind,
56
+ type RecoverReplyV1,
57
+ type BackfillTransport,
58
+ type PushState,
59
+ type StickyCohortHintCache,
60
+ type CorrelationReplayGuard,
61
+ type RotationRedirectV1,
62
+ } from "@optimystic/db-core";
63
+ import { requestResponse, handleRequestResponse, DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
64
+ import { verifyPeerSig, signPeerSig } from "../cohort-topic/peer-sig.js";
65
+ import { peerIdToBytes, bytesToPeerIdString } from "../cohort-topic/peer-codec.js";
66
+ import { PROTOCOL_REACTIVITY_RECOVER } from "./protocols.js";
67
+ import type { ResumeTransport } from "./subscription-manager.js";
68
+ import { createLogger } from "../logger.js";
69
+
70
+ const log = createLogger("reactivity-recover");
71
+
72
+ /**
73
+ * Decode a sticky-cohort-hint `primary` (base64url of member-id bytes = `base64url(utf8(peerIdString))`)
74
+ * back to the dialable peer-id string. The inverse trap of feeding the raw value to `peerIdFromString`
75
+ * (which expects a peer-id string, not base64url-of-its-utf8) — pinned by the dial-target encoding test.
76
+ */
77
+ export function decodeCohortHintTarget(primary: string): string {
78
+ return bytesToPeerIdString(b64urlToBytes(primary));
79
+ }
80
+
81
+ // --- subscriber request signing (the manager's synchronous signer seam) ---
82
+
83
+ /**
84
+ * The synchronous request-signing seam the production subscribe factory feeds a
85
+ * {@link import("./subscription-manager.js").ReactivitySubscriptionManager}: it builds a
86
+ * {@link BackfillV1} / {@link ResumeV1} signature (base64url) over the unsigned image.
87
+ */
88
+ export interface RecoverRequestSigners {
89
+ /** Sign a {@link BackfillV1} over {@link backfillSigningPayload} of its unsigned image. */
90
+ readonly signBackfill: (unsigned: BackfillSignable) => string;
91
+ /** Sign a {@link ResumeV1} over {@link resumeSigningPayload} of its unsigned image. */
92
+ readonly signResume: (unsigned: ResumeSignable) => string;
93
+ }
94
+
95
+ /**
96
+ * Build the subscriber's recover request signers from the node's libp2p Ed25519 private key.
97
+ *
98
+ * This resolves the recover wiring's one design point: the subscription-manager `signBackfill` /
99
+ * `signResume` ports are **synchronous** (`(unsigned) => string`) — the db-core driver builds the
100
+ * unsigned image internally, so a pre-signed value is impossible — but libp2p `PrivateKey.sign` is
101
+ * **async**. Rather than make the seam (and `createBackfillRequester`'s `sign`, a db-core change) async,
102
+ * sign with the synchronous {@link signPeerSig} (noble, over the raw Ed25519 seed) over db-core's
103
+ * canonical signing payloads. The produced signature verifies on the serving side under
104
+ * {@link verifyPeerSig} over the same bytes (both noble, RFC8032), so the round trip is symmetric.
105
+ */
106
+ export function createRecoverRequestSigners(privateKey: PrivateKey): RecoverRequestSigners {
107
+ return {
108
+ signBackfill: (unsigned: BackfillSignable): string => bytesToB64url(signPeerSig(privateKey, backfillSigningPayload(unsigned))),
109
+ signResume: (unsigned: ResumeSignable): string => bytesToB64url(signPeerSig(privateKey, resumeSigningPayload(unsigned))),
110
+ };
111
+ }
112
+
113
+ // --- outbound transport ---
114
+
115
+ /**
116
+ * Thrown out of {@link Libp2pReactivityRecoverTransport.backfillTransport} / `resumeTransport` when the
117
+ * dialed cohort answered with a `kind: "rotated"` recover reply: the outgoing tail this request reached has
118
+ * rotated and is draining, so it bounced the request to the new tree (`docs/reactivity.md` §Tail rotation).
119
+ * It carries the {@link RotationRedirectV1} so the subscription manager can move itself to the successor.
120
+ *
121
+ * A `kind: "rotated"` reply is **terminal** for the cohort-walk — the dialed member spoke authoritatively for
122
+ * the cohort, so the transport stops rather than falling through to the next target (contrast a *dial
123
+ * failure*, which still falls through). The subscriber honors it like a notification-driven rotation.
124
+ */
125
+ export class RotationRedirectError extends Error {
126
+ /** The drain-window redirect the serving cohort returned (`newTailId` / `newTopicId` / `effectiveAtRevision`). */
127
+ readonly redirect: RotationRedirectV1;
128
+ constructor(redirect: RotationRedirectV1) {
129
+ super(`reactivity recover: cohort rotated to tail ${redirect.newTailId} (effectiveAtRevision ${redirect.effectiveAtRevision})`);
130
+ this.name = "RotationRedirectError";
131
+ this.redirect = redirect;
132
+ }
133
+ }
134
+
135
+ /** The wire exchange seam: open the recover protocol to `target`, send `frame`, return the bounded reply. */
136
+ export interface RecoverDialer {
137
+ /** Exchange one request frame for one reply frame with `target` (peer-id string). Rejects on a dial failure. */
138
+ exchange(target: string, frame: Uint8Array): Promise<Uint8Array>;
139
+ }
140
+
141
+ /** Build the production libp2p-backed {@link RecoverDialer} over {@link requestResponse}. */
142
+ export function createLibp2pRecoverDialer(node: Libp2p, recoverProtocol: string = PROTOCOL_REACTIVITY_RECOVER, maxBytes: number = DEFAULT_STREAM_MAX_BYTES): RecoverDialer {
143
+ return {
144
+ exchange: (target, frame) => requestResponse(node, peerIdFromString(target), recoverProtocol, frame, maxBytes),
145
+ };
146
+ }
147
+
148
+ /** Construction inputs for a {@link Libp2pReactivityRecoverTransport}. */
149
+ export interface Libp2pReactivityRecoverTransportOptions {
150
+ /** Wire exchange (e.g. {@link createLibp2pRecoverDialer} over the live node). */
151
+ readonly dialer: RecoverDialer;
152
+ /** This node's peer-id string (`node.peerId.toString()`); never dialed — a co-located serve is the node wiring's. */
153
+ readonly selfPeerId: string;
154
+ /** Sticky cohort-hint cache, shared with the subscription manager (the one-RT primary for the happy path). */
155
+ readonly cohortHintCache: StickyCohortHintCache;
156
+ /** FRET cohort-walk fallback: a topic id → dialable cohort member peer-id strings. */
157
+ readonly resolveCohort: (topicId: Uint8Array) => string[];
158
+ /** Per-frame ceiling for encode/decode; default {@link DEFAULT_STREAM_MAX_BYTES}. */
159
+ readonly maxBytes?: number;
160
+ }
161
+
162
+ /**
163
+ * The outbound recover transport: one instance per node, exposing the two db-core function seams against it.
164
+ * Each returned transport dials the **sticky primary first** (one round trip after a brief flap), falling
165
+ * back to a **cohort-walk** member on a dial failure (any member that holds the topic's gossiped `PushState`
166
+ * can answer). A kind mismatch or all-targets-failed surfaces as a rejection so the caller's retry/escalation
167
+ * policy (the subscription manager's backfill escalation, or `manager.resume()`'s caller) takes over.
168
+ */
169
+ export class Libp2pReactivityRecoverTransport {
170
+ private readonly dialer: RecoverDialer;
171
+ private readonly selfPeerId: string;
172
+ private readonly cohortHintCache: StickyCohortHintCache;
173
+ private readonly resolveCohort: (topicId: Uint8Array) => string[];
174
+ private readonly maxBytes: number;
175
+
176
+ constructor(options: Libp2pReactivityRecoverTransportOptions) {
177
+ this.dialer = options.dialer;
178
+ this.selfPeerId = options.selfPeerId;
179
+ this.cohortHintCache = options.cohortHintCache;
180
+ this.resolveCohort = options.resolveCohort;
181
+ this.maxBytes = options.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
182
+ }
183
+
184
+ /** The db-core {@link BackfillTransport} for `(topicId, collectionId)` — frames + dials a signed {@link BackfillV1}. */
185
+ backfillTransport(topicId: Uint8Array, collectionId: string): BackfillTransport {
186
+ return async (req: BackfillV1): Promise<BackfillReplyV1> => {
187
+ const frame = encodeRecoverRequestV1({ v: 1, kind: "backfill", backfill: req }, this.maxBytes);
188
+ const reply = await this.exchange("backfill", frame, topicId, collectionId);
189
+ if (reply.backfillReply === undefined) {
190
+ throw new Error("reactivity recover: backfill reply missing its body");
191
+ }
192
+ return reply.backfillReply;
193
+ };
194
+ }
195
+
196
+ /** The {@link ResumeTransport} for `(topicId, collectionId)` — frames + dials a signed {@link ResumeV1}. */
197
+ resumeTransport(topicId: Uint8Array, collectionId: string): ResumeTransport {
198
+ return async (req: ResumeV1) => {
199
+ const frame = encodeRecoverRequestV1({ v: 1, kind: "resume", resume: req }, this.maxBytes);
200
+ const reply = await this.exchange("resume", frame, topicId, collectionId);
201
+ if (reply.resumeReply === undefined) {
202
+ throw new Error("reactivity recover: resume reply missing its body");
203
+ }
204
+ return reply.resumeReply;
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Exchange one recover frame with the first reachable target: sticky primary, then each cohort-walk
210
+ * member. A **dial failure** falls through to the next candidate; a successful dial is **terminal** (the
211
+ * member answered for the cohort) — a `kind: "rotated"` redirect throws {@link RotationRedirectError}, and
212
+ * a reply decoding to the wrong `kind` is a protocol error. Throws when no candidate succeeds.
213
+ */
214
+ private async exchange(kind: RecoverKind, frame: Uint8Array, topicId: Uint8Array, collectionId: string): Promise<RecoverReplyV1> {
215
+ const targets = this.selectTargets(topicId, collectionId);
216
+ if (targets.length === 0) {
217
+ throw new Error("reactivity recover: no serving cohort target resolved");
218
+ }
219
+ let lastErr: unknown;
220
+ for (const target of targets) {
221
+ let replyFrame: Uint8Array;
222
+ try {
223
+ replyFrame = await this.dialer.exchange(target, frame);
224
+ } catch (err) {
225
+ lastErr = err; // dial failure → fall back to the next candidate (sticky → walk)
226
+ log("recover dial to %s failed, trying next target: %o", target, err);
227
+ continue;
228
+ }
229
+ const reply = decodeRecoverReplyV1(replyFrame, this.maxBytes); // a decode failure here is terminal
230
+ // A rotated cohort answered authoritatively: surface the redirect and stop the walk (terminal, never
231
+ // a fallthrough — the dialed member spoke for the cohort).
232
+ if (reply.kind === "rotated") {
233
+ throw new RotationRedirectError(reply.rotated!);
234
+ }
235
+ if (reply.kind !== kind) {
236
+ throw new Error(`reactivity recover: reply kind "${reply.kind}" does not match request kind "${kind}"`);
237
+ }
238
+ return reply;
239
+ }
240
+ throw lastErr ?? new Error("reactivity recover: all targets failed");
241
+ }
242
+
243
+ /**
244
+ * Ordered dial candidates for a recover: the sticky primary (decoded from its base64url-of-bytes form)
245
+ * first, then the cohort-walk members. Self is never dialed (a co-located serve is the node wiring's
246
+ * concern), and duplicates collapse.
247
+ */
248
+ private selectTargets(topicId: Uint8Array, collectionId: string): string[] {
249
+ const seen = new Set<string>();
250
+ const targets: string[] = [];
251
+ const add = (target: string | undefined): void => {
252
+ if (target === undefined || target === this.selfPeerId || seen.has(target)) {
253
+ return;
254
+ }
255
+ seen.add(target);
256
+ targets.push(target);
257
+ };
258
+ const hint = this.cohortHintCache.get(collectionId);
259
+ if (hint?.primary !== undefined) {
260
+ try {
261
+ add(decodeCohortHintTarget(hint.primary));
262
+ } catch (err) {
263
+ log("recover: malformed sticky primary for %s, skipping to cohort-walk: %o", collectionId, err);
264
+ }
265
+ }
266
+ for (const member of this.resolveCohort(topicId)) {
267
+ add(member);
268
+ }
269
+ return targets;
270
+ }
271
+ }
272
+
273
+ // --- inbound serve handler ---
274
+
275
+ /** Live `PushState` resolvers + replay guard the recover serve handler dispatches against. */
276
+ export interface RecoverServeDeps {
277
+ /** Resolve the served `PushState` for an exact reactivity topic id (resume's stale-tail lookup). */
278
+ readonly pushStateFor: (topicId: Uint8Array) => PushState | undefined;
279
+ /** Resolve the served `PushState` for a collection id — the current tail (backfill, and rotated resume). */
280
+ readonly pushStateForCollection: (collectionId: string) => PushState | undefined;
281
+ /** Node-level freshness + anti-replay gate keyed on the request signature bytes. */
282
+ readonly replayGuard: CorrelationReplayGuard;
283
+ /**
284
+ * The drain-window redirect for a request that reached an **outgoing (rotated)** tail still in its drain
285
+ * window, or `undefined` if the resolved topic never rotated / has drained. The node wiring binds this to
286
+ * `ReactivityForwarderHost.rotationRedirectFor`, resolving the old topic from the request: for **resume**
287
+ * the request carries `topicId = reactivityTopicId(latestKnownTailId)`; for **backfill** (no `topicId`)
288
+ * the binding resolves the collection's current served topic. When it returns a redirect the serve replies
289
+ * `kind: "rotated"` instead of serving data, moving the subscriber to the new tree. Absent ⇒ never redirect.
290
+ */
291
+ readonly rotationFor?: (req: { topicId?: Uint8Array; collectionId: string }, now: number) => RotationRedirectV1 | undefined;
292
+ /** Unix-ms clock for the replay-guard window. Default `Date.now`. */
293
+ readonly clock?: () => number;
294
+ /** Per-frame decode ceiling; default {@link DEFAULT_STREAM_MAX_BYTES}. */
295
+ readonly maxBytes?: number;
296
+ }
297
+
298
+ /**
299
+ * Verify the request's peer-key signature against the dialing peer, then admit it through the freshness +
300
+ * anti-replay guard. The dialing peer's id **is** the signer (no signer-id field on the wire); the signature
301
+ * bytes are the anti-replay key (globally unique + authenticated). Returns `false` (reject, no reply) on a
302
+ * bad signature, a stale/future timestamp, or a replay.
303
+ */
304
+ function verifyAndAdmit(deps: RecoverServeDeps, signerId: string, payload: Uint8Array, signatureB64: string, timestamp: number, now: number): boolean {
305
+ const signature = b64urlToBytes(signatureB64);
306
+ if (!verifyPeerSig(signerId, payload, signature)) {
307
+ log("recover serve: signature verification failed for %s (no reply)", signerId);
308
+ return false;
309
+ }
310
+ if (!deps.replayGuard.accept(signature, peerIdToBytes(signerId), timestamp, now)) {
311
+ log("recover serve: replay/stale request from %s (no reply)", signerId);
312
+ return false;
313
+ }
314
+ return true;
315
+ }
316
+
317
+ /**
318
+ * Serve a backfill from the collection's current served tail, or `undefined` if this node serves none. When
319
+ * the node serves **only** the outgoing (draining) tail, `rotationFor` returns the drain redirect and the
320
+ * reply is `kind: "rotated"` instead — a best-effort secondary path (the primary mechanism for an active
321
+ * subscriber is notify-driven detection). Once both tails coexist `pushStateForCollection` resolves the new
322
+ * tail, so the redirect is emitted only while the old tail is the sole served state (see §Tail rotation).
323
+ */
324
+ function serveBackfillReply(deps: RecoverServeDeps, req: BackfillV1, now: number): Uint8Array | undefined {
325
+ const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
326
+ const redirect = deps.rotationFor?.({ collectionId: req.collectionId }, now);
327
+ if (redirect !== undefined) {
328
+ return encodeRecoverReplyV1({ v: 1, kind: "rotated", rotated: redirect }, maxBytes);
329
+ }
330
+ const ps = deps.pushStateForCollection(req.collectionId);
331
+ if (ps === undefined) {
332
+ return undefined; // not a serving member for this collection → no reply (subscriber walks/chain-reads)
333
+ }
334
+ const backfillReply = serveBackfill(ps.replayBuffer, req, ps.collectionId);
335
+ return encodeRecoverReplyV1({ v: 1, kind: "backfill", backfillReply }, maxBytes);
336
+ }
337
+
338
+ /**
339
+ * Serve a resume against the live `PushState`. A resume reaching the **outgoing (draining)** tail — its
340
+ * `latestKnownTailId` anchors a topic this node has marked rotated — is answered with the drain redirect
341
+ * (`kind: "rotated"`), moving the subscriber to the new tree. Otherwise prefer the exact topic the request's
342
+ * `latestKnownTailId` anchors (so a non-rotated subscriber classifies into backfill/checkpoint/out_of_window);
343
+ * if this node no longer serves that tail's topic, fall back to the collection's current tail so the cohort
344
+ * can still answer `tail_rotated` (its `currentTailId` differs from the request's stale tail) or, for a span
345
+ * that crosses a rotation, serve from the new tail's `inheritedCheckpoint`. `undefined` ⇒ no served state.
346
+ */
347
+ function serveResumeReply(deps: RecoverServeDeps, req: ResumeV1, now: number): Uint8Array | undefined {
348
+ const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
349
+ const staleTopic = reactivityTopicId(b64urlToBytes(req.latestKnownTailId));
350
+ const redirect = deps.rotationFor?.({ topicId: staleTopic, collectionId: req.collectionId }, now);
351
+ if (redirect !== undefined) {
352
+ return encodeRecoverReplyV1({ v: 1, kind: "rotated", rotated: redirect }, maxBytes);
353
+ }
354
+ const ps = deps.pushStateFor(staleTopic) ?? deps.pushStateForCollection(req.collectionId);
355
+ if (ps === undefined) {
356
+ return undefined;
357
+ }
358
+ const resumeReply = serveResume(req, {
359
+ buffer: ps.replayBuffer,
360
+ checkpoint: ps.checkpoint,
361
+ inheritedCheckpoint: ps.inheritedCheckpoint,
362
+ currentTailId: ps.tailIdAtJoin,
363
+ currentRevision: ps.lastRevision,
364
+ rotationRevision: ps.lastRevision,
365
+ expectedCollectionId: ps.collectionId,
366
+ });
367
+ return encodeRecoverReplyV1({ v: 1, kind: "resume", resumeReply }, maxBytes);
368
+ }
369
+
370
+ /**
371
+ * Build the recover serve callback for {@link handleRequestResponse}: it returns the reply frame, or
372
+ * `undefined` on a decode/verify/replay/resolve failure. It never throws out of the handler.
373
+ *
374
+ * NOTE: `undefined` reaches the dialer as an explicit zero-length frame, and
375
+ * {@link Libp2pReactivityRecoverTransport.exchange} decodes that as a *terminal* protocol error rather
376
+ * than falling through to the next cohort candidate — one member declining ends the whole recover walk.
377
+ * Tracked as `debt-stream-reply-no-result-untyped`.
378
+ */
379
+ export function createRecoverRequestHandler(deps: RecoverServeDeps): (frame: Uint8Array, fromPeer: PeerId) => Promise<Uint8Array | undefined> {
380
+ const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
381
+ const clock = deps.clock ?? ((): number => Date.now());
382
+ return (frame: Uint8Array, fromPeer: PeerId): Promise<Uint8Array | undefined> => {
383
+ try {
384
+ const req = decodeRecoverRequestV1(frame, maxBytes);
385
+ const signerId = fromPeer.toString();
386
+ const now = clock();
387
+ if (req.kind === "backfill") {
388
+ const b = req.backfill;
389
+ if (b === undefined || !verifyAndAdmit(deps, signerId, backfillSigningPayload(b), b.signature, b.timestamp, now)) {
390
+ return Promise.resolve(undefined);
391
+ }
392
+ return Promise.resolve(serveBackfillReply(deps, b, now));
393
+ }
394
+ const r = req.resume;
395
+ if (r === undefined || !verifyAndAdmit(deps, signerId, resumeSigningPayload(r), r.signature, r.timestamp, now)) {
396
+ return Promise.resolve(undefined);
397
+ }
398
+ return Promise.resolve(serveResumeReply(deps, r, now));
399
+ } catch (err) {
400
+ // A malformed/foreign request (decode failure, foreign collectionId from serve*) must never throw
401
+ // out of the stream handler: log + no reply (a zero-length reply frame; see the note above).
402
+ log("recover serve: dropping request (no reply): %o", err);
403
+ return Promise.resolve(undefined);
404
+ }
405
+ };
406
+ }
407
+
408
+ /** Register the inbound recover protocol handler on `node` (request-reply over the recover protocol). */
409
+ export function registerRecoverHandler(node: Libp2p, protocol: string, deps: RecoverServeDeps): void {
410
+ const maxBytes = deps.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
411
+ handleRequestResponse(node, protocol, createRecoverRequestHandler(deps), maxBytes);
412
+ }