@optimystic/db-p2p 0.16.3 → 0.17.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 (70) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +4 -1
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +11 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +6 -2
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +28 -4
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/quorum-restore.d.ts +42 -14
  10. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  11. package/dist/src/cluster/quorum-restore.js +0 -0
  12. package/dist/src/cluster/quorum-restore.js.map +1 -1
  13. package/dist/src/cluster/reconcile-block.d.ts +52 -0
  14. package/dist/src/cluster/reconcile-block.d.ts.map +1 -0
  15. package/dist/src/cluster/reconcile-block.js +113 -0
  16. package/dist/src/cluster/reconcile-block.js.map +1 -0
  17. package/dist/src/cluster/service.d.ts +4 -1
  18. package/dist/src/cluster/service.d.ts.map +1 -1
  19. package/dist/src/cluster/service.js +9 -1
  20. package/dist/src/cluster/service.js.map +1 -1
  21. package/dist/src/inbound-authorization.d.ts +111 -0
  22. package/dist/src/inbound-authorization.d.ts.map +1 -0
  23. package/dist/src/inbound-authorization.js +143 -0
  24. package/dist/src/inbound-authorization.js.map +1 -0
  25. package/dist/src/index.d.ts +1 -0
  26. package/dist/src/index.d.ts.map +1 -1
  27. package/dist/src/index.js +1 -0
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/libp2p-node-base.d.ts +42 -0
  30. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  31. package/dist/src/libp2p-node-base.js +43 -68
  32. package/dist/src/libp2p-node-base.js.map +1 -1
  33. package/dist/src/repo/coordinator-repo.d.ts +83 -10
  34. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  35. package/dist/src/repo/coordinator-repo.js +176 -24
  36. package/dist/src/repo/coordinator-repo.js.map +1 -1
  37. package/dist/src/repo/service.d.ts +4 -1
  38. package/dist/src/repo/service.d.ts.map +1 -1
  39. package/dist/src/repo/service.js +9 -1
  40. package/dist/src/repo/service.js.map +1 -1
  41. package/dist/src/storage/block-storage.d.ts.map +1 -1
  42. package/dist/src/storage/block-storage.js +11 -0
  43. package/dist/src/storage/block-storage.js.map +1 -1
  44. package/dist/src/storage/storage-repo.d.ts +47 -0
  45. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  46. package/dist/src/storage/storage-repo.js +99 -9
  47. package/dist/src/storage/storage-repo.js.map +1 -1
  48. package/dist/src/sync/service.d.ts +4 -7
  49. package/dist/src/sync/service.d.ts.map +1 -1
  50. package/dist/src/sync/service.js +13 -10
  51. package/dist/src/sync/service.js.map +1 -1
  52. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  53. package/dist/src/testing/mesh-harness.js +41 -48
  54. package/dist/src/testing/mesh-harness.js.map +1 -1
  55. package/package.json +2 -2
  56. package/{README.md → readme.md} +17 -0
  57. package/src/cluster/block-transfer-service.ts +12 -5
  58. package/src/cluster/cluster-repo.ts +28 -4
  59. package/src/cluster/quorum-restore.ts +0 -0
  60. package/src/cluster/reconcile-block.ts +158 -0
  61. package/src/cluster/service.ts +10 -3
  62. package/src/inbound-authorization.ts +184 -0
  63. package/src/index.ts +1 -0
  64. package/src/libp2p-node-base.ts +90 -72
  65. package/src/repo/coordinator-repo.ts +224 -25
  66. package/src/repo/service.ts +10 -3
  67. package/src/storage/block-storage.ts +11 -0
  68. package/src/storage/storage-repo.ts +114 -9
  69. package/src/sync/service.ts +14 -12
  70. package/src/testing/mesh-harness.ts +40 -48
@@ -20,12 +20,12 @@ import { MemoryRawStorage } from './storage/memory-storage.js';
20
20
  import type { IRawStorage } from './storage/i-raw-storage.js';
21
21
  import { seedOwnedBlocksFromStorage } from './owned-block-seed.js';
22
22
  import { clusterMember, type ReconcileBlockCallback, type CommitCertificateSink, type DeriveExpectedClusterCallback } from './cluster/cluster-repo.js';
23
- import { selectQuorumRev, selectQuorumBlock, canonicalBlockHash, type RevClaim, type BlockHashCandidate } from './cluster/quorum-restore.js';
23
+ import { createReconcileBlock } from './cluster/reconcile-block.js';
24
24
  import { createCommitCertStore, makeClusterCommitCertExtractor, type CommitCertStore } from './cluster/commit-cert.js';
25
25
  import { coordinatorRepo } from './repo/coordinator-repo.js';
26
26
  import { Libp2pKeyPeerNetwork, type NetworkMode, type NetworkStatePersistence } from './libp2p-key-network.js';
27
27
  import { ClusterClient } from './cluster/client.js';
28
- import type { IRepo, ICluster, ITransactionValidator, BlockId, IBlock, IBlockChangeNotifier } from '@optimystic/db-core';
28
+ import type { IRepo, ICluster, ITransactionValidator, BlockId, IBlockChangeNotifier } from '@optimystic/db-core';
29
29
  import type { ITransactionStateStore } from './cluster/i-transaction-state-store.js';
30
30
  import { networkManagerService, type NetworkManagerService } from './network/network-manager-service.js';
31
31
  import type { SpreadOnChurnConfig, SpreadOnChurnMonitor } from './cluster/spread-on-churn.js';
@@ -85,7 +85,7 @@ import { assertSuperMajorityCoupling } from './cluster/supermajority-coupling.js
85
85
  import { createLogger } from './logger.js';
86
86
  import { PeerReputationService } from './reputation/peer-reputation.js';
87
87
  import type { IPeerReputation } from './reputation/types.js';
88
- import { PenaltyReason } from './reputation/types.js';
88
+ import type { AuthorizeInboundStream, InboundStreamAuthorizationInit } from './inbound-authorization.js';
89
89
  import { DisputeService } from './dispute/dispute-service.js';
90
90
  import { DisputeClient } from './dispute/client.js';
91
91
  import { sampleArbitrators } from './dispute/arbitrator-selection.js';
@@ -174,11 +174,25 @@ export type NodeOptions = {
174
174
  relayServerInit?: CircuitRelayServerInit;
175
175
  /** Storage provider - either an IRawStorage instance or a factory function. Defaults to MemoryRawStorage if not provided. */
176
176
  storage?: RawStorageProvider;
177
- clusterSize?: number; // desired cluster size per key
177
+ /**
178
+ * Desired cluster size per key (default 10). Beyond sizing the cohort, this is the
179
+ * node's declaration of how many peers *should* exist to corroborate a claim: the
180
+ * read-repair corroboration floor is measured against it, so a genuine two-node
181
+ * deployment must set `clusterSize: 2` for its members to be able to repair each
182
+ * other (see `CoordinatorRepo.corroboratorCapacity`).
183
+ */
184
+ clusterSize?: number;
178
185
  clusterPolicy?: {
179
186
  allowDownsize?: boolean;
180
187
  sizeTolerance?: number; // acceptable relative difference (e.g. 0.5 = +/-50%)
181
188
  superMajorityThreshold?: number; // fraction of peers needed for super-majority (default: DEFAULT_SUPER_MAJORITY_THRESHOLD = 0.75)
189
+ /**
190
+ * Opt in to transacting below the safe cluster-size floor when FRET has no confident
191
+ * network-size estimate — the membership-admission and coordinator small-cluster gates
192
+ * both fail closed without it. Default false. Turn on only for single-node / local dev
193
+ * meshes that knowingly run undersized.
194
+ */
195
+ allowUnvalidatedSmallCluster?: boolean;
182
196
  };
183
197
 
184
198
  /** Override libp2p listen multiaddrs. */
@@ -275,6 +289,35 @@ export type NodeOptions = {
275
289
  */
276
290
  privateKey?: PrivateKey;
277
291
 
292
+ /**
293
+ * Optional predicate deciding whether a remote peer may open one of the four Optimystic
294
+ * database protocols on this node (`repo`, `cluster`, `sync`, `block-transfer`). It is
295
+ * consulted once per inbound stream, before any frame is decoded or any operation executed.
296
+ *
297
+ * This is deliberately ONE node-level option threaded to all four services rather than four
298
+ * per-service options: "is this peer allowed to talk to my database?" is a property of the
299
+ * node, not of the protocol, and four independently-settable options make it easy to secure
300
+ * three surfaces and silently miss the fourth. (Each service still accepts the same option in
301
+ * its own init, so the services stay independently testable and usable outside this factory.)
302
+ *
303
+ * Absent → no check at all, and today's behavior exactly. Supplied → fail-closed: `false`, a
304
+ * throw, a rejection, or a timeout all deny and abort the stream. `remotePeerId` is the
305
+ * dialing peer's `PeerId.toString()`. See {@link AuthorizeInboundStream} and
306
+ * `docs/internals.md` § Inbound Stream Authorization.
307
+ *
308
+ * NOTE: this covers the four database protocols only. The reactivity, matchmaking,
309
+ * cohort-topic and libp2p built-in (identify/ping/…) protocols this node also registers are
310
+ * NOT gated by it.
311
+ */
312
+ authorizeInboundStream?: AuthorizeInboundStream;
313
+
314
+ /**
315
+ * Deadline for {@link NodeOptions.authorizeInboundStream}; expiry denies the stream (a hanging
316
+ * predicate would otherwise pin an inbound stream slot). Defaults to
317
+ * `DEFAULT_INBOUND_AUTHORIZATION_TIMEOUT_MS` (5s). Ignored when no predicate is supplied.
318
+ */
319
+ authorizeInboundStreamTimeoutMs?: number;
320
+
278
321
  /**
279
322
  * Optional libp2p connection gater. The libp2p browser default denies
280
323
  * dialing insecure WebSockets and private/loopback addresses; callers
@@ -383,6 +426,19 @@ export async function createLibp2pNodeBase(
383
426
  }
384
427
  };
385
428
 
429
+ // The ONE authorization slice, spread verbatim into all four database-protocol service inits
430
+ // below. Building it once (rather than repeating two option reads per service) is what makes
431
+ // "secured three surfaces, missed the fourth" impossible: adding a fifth protocol service is a
432
+ // spread of this object, and dropping it from one is visible at the call site.
433
+ // Absent `authorizeInboundStream` → every service constructs its gate as `undefined` and the
434
+ // inbound path is byte-for-byte what it was before this option existed.
435
+ const inboundAuthorization: InboundStreamAuthorizationInit = {
436
+ ...(options.authorizeInboundStream ? { authorizeInboundStream: options.authorizeInboundStream } : {}),
437
+ ...(options.authorizeInboundStreamTimeoutMs !== undefined
438
+ ? { authorizeInboundStreamTimeoutMs: options.authorizeInboundStreamTimeoutMs }
439
+ : {})
440
+ };
441
+
386
442
  const nodePrivateKey = options.privateKey ?? await generateKeyPair('Ed25519');
387
443
 
388
444
  const listenAddrs = options.listenAddrs ?? defaults.listenAddrs;
@@ -479,7 +535,8 @@ export async function createLibp2pNodeBase(
479
535
  cluster: (components: any) => {
480
536
  const serviceFactory = clusterService({
481
537
  protocolPrefix: `/optimystic/${options.networkName}`,
482
- responsibilityK: options.responsibilityK ?? 1
538
+ responsibilityK: options.responsibilityK ?? 1,
539
+ ...inboundAuthorization
483
540
  });
484
541
  return serviceFactory({
485
542
  logger: components.logger,
@@ -505,7 +562,8 @@ export async function createLibp2pNodeBase(
505
562
  repo: (components: any) => {
506
563
  const serviceFactory = repoService({
507
564
  protocolPrefix: `/optimystic/${options.networkName}`,
508
- responsibilityK: options.responsibilityK ?? 1
565
+ responsibilityK: options.responsibilityK ?? 1,
566
+ ...inboundAuthorization
509
567
  });
510
568
  // RepoService.checkRedirect needs the running node (network manager for the
511
569
  // responsible-set computation, self id for the membership check, connection
@@ -525,7 +583,8 @@ export async function createLibp2pNodeBase(
525
583
 
526
584
  sync: (components: any) => {
527
585
  const serviceFactory = syncService({
528
- protocolPrefix: `/optimystic/${options.networkName}`
586
+ protocolPrefix: `/optimystic/${options.networkName}`,
587
+ ...inboundAuthorization
529
588
  });
530
589
  return serviceFactory({
531
590
  logger: components.logger,
@@ -539,7 +598,8 @@ export async function createLibp2pNodeBase(
539
598
  // node's own storage, not be re-routed through the cluster-coordinated repo.
540
599
  blockTransfer: (components: any) => {
541
600
  const serviceFactory = blockTransferService({
542
- protocolPrefix: `/optimystic/${options.networkName}`
601
+ protocolPrefix: `/optimystic/${options.networkName}`,
602
+ ...inboundAuthorization
543
603
  });
544
604
  return serviceFactory({
545
605
  registrar: components.registrar,
@@ -642,6 +702,9 @@ export async function createLibp2pNodeBase(
642
702
  minAbsoluteClusterSize: 2,
643
703
  allowClusterDownsize: options.clusterPolicy?.allowDownsize ?? true,
644
704
  clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5,
705
+ // Fail closed by default (an undersized cluster with no confident network-size estimate is
706
+ // rejected); embedders running knowingly-small meshes opt in through clusterPolicy.
707
+ allowUnvalidatedSmallCluster: options.clusterPolicy?.allowUnvalidatedSmallCluster ?? false,
645
708
  partitionDetectionWindow: 60000,
646
709
  // Configured full cluster size — the member's own reference for "full size" in the membership
647
710
  // admission gate (a below-full-size declared set under low FRET confidence is refused as a possible
@@ -674,69 +737,18 @@ export async function createLibp2pNodeBase(
674
737
  }
675
738
  };
676
739
 
677
- // Active reconciliation for a block this member committed without the matching pend
678
- // (cohort drift). Queries the commit cohort (self already excluded) for the block,
679
- // picks the highest revision that is at least the committed rev, and persists it via
680
- // the churn-replication funnel so the block is no longer under-replicated.
681
- const reconcileBlock: ReconcileBlockCallback = async (blockId, committed, cohortPeerIds) => {
682
- const targets = cohortPeerIds.filter(id => id !== node.peerId.toString());
683
- if (targets.length === 0) return;
684
-
685
- const fetched = await Promise.all(
686
- targets.map(async peerIdStr => ({ peerIdStr, archive: await fetchArchiveFromPeer(peerIdStr, blockId) }))
687
- );
688
-
689
- // Each cohort archive contributes one (rev, actionId) claim from its max
690
- // revision (>= the rev we committed). Pick the target rev by quorum
691
- // corroboration rather than raw Math.max — a lone peer inflating its rev
692
- // cannot steer reconciliation. Keep the serving peer + block per candidate
693
- // so we can then verify content agreement.
694
- // NOTE: this quorum is corroboration-of-a-claim, NOT Sybil-resistant cohort
695
- // membership — deferred to backlog `debt-read-repair-commit-cert-verification`.
696
- const candidates: { peerIdStr: string; rev: number; actionId: string; block?: IBlock }[] = [];
697
- for (const { peerIdStr, archive } of fetched) {
698
- if (!archive) continue;
699
- const revs = Object.keys(archive.revisions).map(Number);
700
- if (revs.length === 0) continue;
701
- const maxRev = Math.max(...revs);
702
- if (maxRev < committed.rev) continue;
703
- const data = archive.revisions[maxRev];
704
- if (!data?.action) continue;
705
- candidates.push({ peerIdStr, rev: maxRev, actionId: data.action.actionId, block: data.block });
706
- }
707
-
708
- const revClaims: RevClaim[] = candidates.map(c => ({ peerId: c.peerIdStr, rev: c.rev, actionId: c.actionId }));
709
- const selected = selectQuorumRev(revClaims, consensusConfig.simpleMajorityThreshold);
710
- if (!selected) return; // no rev corroborated by a quorum → leave block, churn/rebalance retries later
711
-
712
- // Content agreement: among archives corroborating the chosen (rev, actionId)
713
- // and actually carrying the block, the content must be byte-identical across
714
- // a quorum. A cohort member serving content that hashes differently is rejected.
715
- // NOTE: selectQuorumBlock recomputes its quorum over only the block-CARRYING
716
- // corroborators, not the full rev-responder set. If most peers corroborate the
717
- // rev but few carry block bytes (e.g. mid-prune), the content quorum can shrink
718
- // to 2. Harmless with honest peers; if a colluding pair ever becomes the only
719
- // block-servers for an agreed rev, that is the Sybil regime already deferred to
720
- // backlog `debt-read-repair-commit-cert-verification`.
721
- const corroborating = candidates.filter(c => c.rev === selected.rev && c.actionId === selected.actionId && c.block);
722
- const hashCandidates: BlockHashCandidate[] = await Promise.all(
723
- corroborating.map(async c => ({ peerId: c.peerIdStr, hash: await canonicalBlockHash(c.block!), block: c.block! }))
724
- );
725
- const agreed = selectQuorumBlock(hashCandidates, consensusConfig.simpleMajorityThreshold);
726
- if (!agreed) return; // no content quorum → skip persist
727
-
728
- // Best-effort: penalize cohort members that served content contradicting the
729
- // agreed hash for the same committed (rev, actionId). Never let this throw.
730
- try {
731
- for (const c of hashCandidates) {
732
- if (c.hash !== agreed.hash) {
733
- reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
734
- }
735
- }
736
- } catch { /* reputation write must never block restoration */ }
737
-
738
- await storageRepo.saveReplicatedBlock(blockId, agreed.block, { actionId: selected.actionId, rev: selected.rev });
739
- };
740
+ // Active reconciliation for a block this member committed without a materializable base
741
+ // (cohort drift, or a refused `missing-base-revision` commit). See `reconcile-block.ts` for
742
+ // the corroboration rules in particular why both quorums are capped by how many peers
743
+ // could answer at all, which is what lets a genuinely two-node cohort heal.
744
+ const reconcileBlock: ReconcileBlockCallback = createReconcileBlock({
745
+ selfPeerId: node.peerId.toString(),
746
+ fetchArchive: fetchArchiveFromPeer,
747
+ saveReplicatedBlock: (blockId, block, source) => storageRepo.saveReplicatedBlock(blockId, block, source),
748
+ simpleMajorityThreshold: consensusConfig.simpleMajorityThreshold,
749
+ clusterSize: consensusConfig.clusterSize,
750
+ reputation
751
+ });
740
752
 
741
753
  // Member-side membership derivation for the admission gate: independently re-derive this block's
742
754
  // responsible cluster from the SAME source the coordinator uses (IKeyNetwork.findCluster), plus FRET's
@@ -828,7 +840,13 @@ export async function createLibp2pNodeBase(
828
840
  storageRepo,
829
841
  localCluster: clusterImpl,
830
842
  localPeerId: node.peerId,
831
- clusterLatestCallback
843
+ clusterLatestCallback,
844
+ // Read-driven acquisition shares the commit path's reconcile callback verbatim: same bounded
845
+ // archive fetch, same (rev, actionId) and content quorums, same monotonic saveReplicatedBlock
846
+ // funnel. `clusterLatestCallback` alone can only tell the reader WHICH revision the cohort
847
+ // holds; this is what moves the bytes. Only reached once a corroborated revision exists, so a
848
+ // genuinely absent block still costs no archive fetch.
849
+ acquireBlockFromCohort: reconcileBlock
832
850
  });
833
851
 
834
852
  // Fail-fast coupling: the cluster member (what accepts a super-majority as sufficient) and the
@@ -9,10 +9,57 @@ import { createLogger } from '../logger.js';
9
9
  import type { IPeerReputation } from "../reputation/types.js";
10
10
  import { PenaltyReason } from "../reputation/types.js";
11
11
  import type { ITransactionStateStore } from "../cluster/i-transaction-state-store.js";
12
- import { selectQuorumRev, type RevClaim, type QuorumRev } from "../cluster/quorum-restore.js";
12
+ import { quorumSize, selectQuorumRev, type RevClaim, type QuorumRev } from "../cluster/quorum-restore.js";
13
+ import { RECONCILE_TIMEOUT_MS } from "../cluster/reconcile-block.js";
14
+ import type { ReconcileBlockCallback } from "../cluster/cluster-repo.js";
13
15
 
14
16
  const log = createLogger('coordinator-repo');
15
17
 
18
+ /**
19
+ * Acquire a block's content for a cohort-corroborated revision, from the cohort, and persist it.
20
+ *
21
+ * Deliberately the SAME shape as the commit path's {@link ReconcileBlockCallback}, and in the live
22
+ * node the very same instance (`libp2p-node-base` passes its `reconcileBlock` to both): read-driven
23
+ * acquisition needs exactly what reconcile already provides — a per-peer-bounded archive fetch, a
24
+ * quorum vote on the target `(rev, actionId)`, a quorum vote on the *content* at that revision, and a
25
+ * persist through the monotonic, commit-latched `StorageRepo.saveReplicatedBlock` funnel. Reusing it
26
+ * is what keeps read-repair from being a weaker trust path than reconcile.
27
+ */
28
+ export type AcquireBlockCallback = ReconcileBlockCallback;
29
+
30
+ /** True when a freshly-read local revision is strictly ahead of the baseline the repair started from. */
31
+ function isAdvanceOver(rev: number | undefined, baseline: ActionRev | undefined): boolean {
32
+ return typeof rev === 'number' && (baseline === undefined || rev > baseline.rev);
33
+ }
34
+
35
+ /**
36
+ * Reject if `promise` has not settled within `ms`. The timer is cleared on either outcome, so no
37
+ * handle outlives the race (hence no `unref`, which does not exist off Node).
38
+ */
39
+ function withDeadline<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
40
+ let timer: ReturnType<typeof setTimeout> | undefined;
41
+ const deadline = new Promise<never>((_, reject) => {
42
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
43
+ });
44
+ return Promise.race([promise, deadline]).finally(() => {
45
+ if (timer !== undefined) clearTimeout(timer);
46
+ });
47
+ }
48
+
49
+ /**
50
+ * What one round of polling the cohort learned about a block: the revision the OTHER
51
+ * cohort members corroborated, and what this node itself already holds. The two are kept
52
+ * apart on purpose — the local revision is the baseline being repaired, never evidence
53
+ * about the cluster (see {@link CoordinatorRepo.queryClusterForLatest}) — but the caller
54
+ * still needs it to tell whether the corroborated revision is actually an advance.
55
+ */
56
+ interface ClusterLatestQuery {
57
+ /** Highest `(rev, actionId)` corroborated by peers other than this node, if any. */
58
+ corroborated?: ActionRev;
59
+ /** This node's own latest for the block, as answered by the callback's self short-circuit. */
60
+ local?: ActionRev;
61
+ }
62
+
16
63
  /**
17
64
  * Extended cluster interface that includes the ability to check if a transaction was executed.
18
65
  * This is used by CoordinatorRepo to avoid duplicate execution.
@@ -36,6 +83,13 @@ interface CoordinatorRepoComponents {
36
83
  * Used for read-path cluster verification to discover unknown revisions.
37
84
  */
38
85
  clusterLatestCallback?: ClusterLatestCallback;
86
+ /**
87
+ * Optional callback that actually moves a block's bytes from the cohort into local storage once
88
+ * {@link clusterLatestCallback} has established a corroborated revision this node lacks. Absent →
89
+ * the read path can still *select* the right revision but converges only when the node already
90
+ * holds the corroborated action as a promotable pending. See {@link AcquireBlockCallback}.
91
+ */
92
+ acquireBlockFromCohort?: AcquireBlockCallback;
39
93
  }
40
94
 
41
95
  export function coordinatorRepo(
@@ -56,7 +110,8 @@ export function coordinatorRepo(
56
110
  fretService,
57
111
  components.clusterLatestCallback,
58
112
  reputation,
59
- stateStore
113
+ stateStore,
114
+ components.acquireBlockFromCohort
60
115
  );
61
116
  }
62
117
 
@@ -73,6 +128,8 @@ export class CoordinatorRepo implements IRepo {
73
128
  private readonly readRepairSampleRate: number;
74
129
  /** Simple-majority threshold from the consensus policy; drives the read-repair corroboration quorum. */
75
130
  private readonly simpleMajorityThreshold: number;
131
+ /** Configured full cluster size; the operator's declaration of how many corroborators should exist. */
132
+ private readonly clusterSize: number;
76
133
  /** Resolved super-majority threshold the coordinator commits on (mirrors the value handed to ClusterCoordinator). */
77
134
  private readonly superMajorityThreshold: number;
78
135
  private readonly reputation?: IPeerReputation;
@@ -91,7 +148,8 @@ export class CoordinatorRepo implements IRepo {
91
148
  fretService?: FretService,
92
149
  private readonly clusterLatestCallback?: ClusterLatestCallback,
93
150
  reputation?: IPeerReputation,
94
- stateStore?: ITransactionStateStore
151
+ stateStore?: ITransactionStateStore,
152
+ private readonly acquireBlockFromCohort?: AcquireBlockCallback
95
153
  ) {
96
154
  this.localPeerId = localPeerId;
97
155
  const policy: ClusterConsensusConfig & { clusterSize: number } = {
@@ -120,6 +178,7 @@ export class CoordinatorRepo implements IRepo {
120
178
  this.readRepairSampleRate = policy.readRepairSampleRate!;
121
179
  this.simpleMajorityThreshold = policy.simpleMajorityThreshold;
122
180
  this.superMajorityThreshold = policy.superMajorityThreshold;
181
+ this.clusterSize = policy.clusterSize;
123
182
  this.reputation = reputation;
124
183
  const localClusterRef = localCluster && localPeerId ? {
125
184
  update: localCluster.update.bind(localCluster),
@@ -312,33 +371,163 @@ export class CoordinatorRepo implements IRepo {
312
371
  return;
313
372
  }
314
373
 
315
- const clusterLatest = await this.queryClusterForLatest(peerIds, blockId, context);
316
- if (clusterLatest) {
317
- // Found on cluster - trigger restoration to sync the block
318
- await this.storageRepo.get({ blockIds: [blockId], context: { committed: [clusterLatest], rev: clusterLatest.rev } });
319
- log('cluster-fetch:synced', { blockId, rev: clusterLatest.rev });
374
+ const { corroborated, local } = await this.queryClusterForLatest(peerIds, blockId, context);
375
+ // Nothing corroborated: keep local data AND stay eligible for repair — marking the
376
+ // block seen here would suppress the next attempt for the whole read-repair window.
377
+ if (!corroborated) return;
378
+
379
+ // Never restore backwards. With this node's own claim excluded from the quorum, a
380
+ // cohort that lags behind the reader corroborates an OLDER revision; adopting it
381
+ // would be a regression, and logging it as a sync would be a lie. The cohort did
382
+ // answer, so the block is verified fresh — mark it seen.
383
+ if (local && corroborated.rev <= local.rev) {
384
+ log('cluster-fetch:local-current', { blockId, localRev: local.rev, clusterRev: corroborated.rev });
320
385
  this.markBlocksSeen([blockId]);
386
+ return;
321
387
  }
388
+
389
+ // Corroborated revision is ahead of ours — converge onto it.
390
+ const rev = await this.restoreCorroborated(blockId, corroborated, local, peerIds);
391
+
392
+ // Log the OUTCOME, not the attempt. Logging `synced` unconditionally reported hundreds of
393
+ // phantom convergences per run and made a real replication defect invisible for two debugging
394
+ // sessions.
395
+ if (rev !== undefined) {
396
+ log('cluster-fetch:synced', { blockId, rev });
397
+ } else {
398
+ log('cluster-fetch:not-restored', { blockId, localRev: local?.rev, clusterRev: corroborated.rev });
399
+ }
400
+ // The block is marked seen either way — the cohort DID answer, so its freshness was checked,
401
+ // which is what the read-repair window tracks. A failed convergence therefore waits out the
402
+ // window before retrying.
403
+ // NOTE: that damping covers only a block this node holds at an OLDER revision. A block entirely
404
+ // missing locally never consults the window (`get` triggers on `isMissing` before
405
+ // `shouldReadRepair`), so a persistently failing acquisition — e.g. a two-node deployment left
406
+ // at the default `clusterSize: 10`, where the content quorum can never be met — re-fetches an
407
+ // archive on every read of that block. Correct, and self-limiting once the cohort can agree; if
408
+ // it ever shows as read amplification, gate the acquisition step (not the latest-query) on the
409
+ // same window rather than widening `isMissing`.
410
+ this.markBlocksSeen([blockId]);
322
411
  }
323
412
 
324
413
  /**
325
- * Query cluster peers for their latest revision and return the highest
326
- * revision corroborated by a quorum of distinct peers.
414
+ * Bring this node up to the cohort-corroborated `corroborated`, returning the revision it holds
415
+ * afterwards when that is an advance over `local`, else `undefined`.
416
+ *
417
+ * Two mechanisms, cheapest first:
418
+ * 1. **Promote a local pending** — free, no network, and the only mechanism that existed before
419
+ * block acquisition. Covers the node that saw the pend and missed the commit broadcast.
420
+ * 2. **Acquire the bytes from the cohort** ({@link AcquireBlockCallback}) — covers everything else,
421
+ * including a block this node has never seen at all.
422
+ *
423
+ * **Why acquisition is gated here and not on a plain local miss.** `BlockStorage.getBlock` returns
424
+ * `undefined` for a block with no local metadata *without* consulting its restore callback, so that
425
+ * an insert probing a fresh random block id for a collision does not cost a network fetch. That
426
+ * remains true: this method runs only after {@link queryClusterForLatest} produced a quorum-
427
+ * corroborated `(rev, actionId)`, which a genuinely non-existent block can never produce (no peer
428
+ * claims it, so `selectQuorumRev` declines and `fetchBlockFromCluster` returns before reaching
429
+ * here). The cost of a genuine absence is unchanged — the latest-query round trip that already
430
+ * happened — while a block the cohort demonstrably holds is no longer thrown away.
431
+ *
432
+ * Cohort peer ids are passed straight through: the callback filters self out and caps its own
433
+ * corroboration quorum by how many peers could answer at all.
434
+ */
435
+ private async restoreCorroborated(
436
+ blockId: BlockId,
437
+ corroborated: ActionRev,
438
+ local: ActionRev | undefined,
439
+ cohortPeerIds: string[]
440
+ ): Promise<number | undefined> {
441
+ const promoted = await this.promoteCorroborated(blockId, corroborated);
442
+ if (isAdvanceOver(promoted, local)) {
443
+ return promoted;
444
+ }
445
+
446
+ if (!this.acquireBlockFromCohort) {
447
+ return undefined;
448
+ }
449
+ try {
450
+ // Bounded: a stalled cohort peer must not hold up the caller's read. Persisting happens
451
+ // inside the callback via `saveReplicatedBlock`, which takes the per-block commit latch —
452
+ // safe to call from here because the read path holds no latch of its own (`StorageRepo.get`
453
+ // acquires and releases it around the promotion above, and nothing wraps this method).
454
+ await withDeadline(
455
+ this.acquireBlockFromCohort(blockId, corroborated, cohortPeerIds),
456
+ RECONCILE_TIMEOUT_MS,
457
+ `block acquisition for ${blockId}`
458
+ );
459
+ } catch (err) {
460
+ // Declines are cheap and retryable — nothing was persisted. Report and leave the block behind.
461
+ log('cluster-fetch:acquire-error', { blockId, rev: corroborated.rev, error: (err as Error).message });
462
+ return undefined;
463
+ }
464
+ const acquired = await this.readLocalRev(blockId);
465
+ return isAdvanceOver(acquired, local) ? acquired : undefined;
466
+ }
467
+
468
+ /**
469
+ * Promote a corroborated action this node already holds as a local pending — the no-network half of
470
+ * the repair. Returns the local revision afterwards.
471
+ *
472
+ * A pending-only block (metadata seeded by `savePendingTransaction`, no committed revision) asked
473
+ * for a forward revision throws out of `BlockStorage.ensureRevision` when no restore can supply it.
474
+ * On THIS path that is an absence, not a read failure — acquisition is precisely the mechanism that
475
+ * can supply it — so the throw is logged and swallowed rather than short-circuiting the caller.
476
+ */
477
+ private async promoteCorroborated(blockId: BlockId, corroborated: ActionRev): Promise<number | undefined> {
478
+ try {
479
+ return await this.readLocalRev(blockId, { committed: [corroborated], rev: corroborated.rev });
480
+ } catch (err) {
481
+ log('cluster-fetch:promote-unavailable', { blockId, rev: corroborated.rev, error: (err as Error).message });
482
+ return undefined;
483
+ }
484
+ }
485
+
486
+ /** This node's own `latest.rev` for a block, optionally driving a promotion context through the read. */
487
+ private async readLocalRev(blockId: BlockId, context?: ActionContext): Promise<number | undefined> {
488
+ const result = await this.storageRepo.get({ blockIds: [blockId], context });
489
+ return result[blockId]?.state?.latest?.rev;
490
+ }
491
+
492
+ /**
493
+ * How many peers other than this node could corroborate a claim about a block, given a
494
+ * cohort view of `peerIds`. Deliberately the MAX of what we observe and what the
495
+ * configured cluster size implies: the corroboration floor may only be relaxed for a
496
+ * cohort that is genuinely small, never for one that merely *looks* small. `findCluster`
497
+ * results are unauthenticated, so a partition — or an attacker with routing influence —
498
+ * can shrink this node's view to itself plus one peer; measuring against the configured
499
+ * size keeps that shrunken view from talking the requirement down to a single voter.
500
+ * The escape hatch for a real two-node deployment is therefore to configure
501
+ * `clusterSize: 2`, an explicit operator declaration, mirroring how
502
+ * `allowUnvalidatedSmallCluster` gates the membership admission floor.
503
+ */
504
+ private corroboratorCapacity(peerIds: string[]): number {
505
+ const selfId = this.localPeerId?.toString();
506
+ const observed = peerIds.filter(id => id !== selfId).length;
507
+ return Math.max(observed, this.clusterSize - 1);
508
+ }
509
+
510
+ /**
511
+ * Query cluster peers for their latest revision and return the highest revision
512
+ * corroborated by a quorum of distinct peers, alongside this node's own latest.
327
513
  *
328
514
  * Replaces the old "max rev any single peer reports" — which let one lying
329
515
  * peer over-reporting its revision steer restoration — with quorum
330
- * corroboration on the exact `(rev, actionId)` pair (see
331
- * {@link selectQuorumRev}). The local node's own latest is included as a
332
- * corroborating vote because `clusterLatestCallback` self-short-circuits to
333
- * local storage. Returns `undefined` (keep local, do not restore) when no
334
- * revision is corroborated.
516
+ * corroboration on the exact `(rev, actionId)` pair (see {@link selectQuorumRev}).
517
+ *
518
+ * This node's own answer is split out of the claim set rather than counted in it:
519
+ * `clusterLatestCallback` short-circuits self to local storage, so including it let a
520
+ * reader whose only peer timed out "corroborate" the very revision it was trying to
521
+ * repair. It is returned separately so the caller can compare, not vote.
335
522
  *
336
523
  * NOTE: the quorum is corroboration-of-a-claim, NOT Sybil-resistant cohort
337
- * membership — a peer minting fresh keypairs still casts a vote. Commit-cert
338
- * + membership anchoring is deferred to backlog
524
+ * membership — a peer minting fresh keypairs still casts a vote, and the claims
525
+ * themselves are bare assertions (a `BlockArchive` carries no commit certificate, so
526
+ * there is nothing here to verify a `(rev, actionId)` against). Commit-cert +
527
+ * membership anchoring is deferred to backlog
339
528
  * `debt-read-repair-commit-cert-verification`.
340
529
  */
341
- private async queryClusterForLatest(peerIds: string[], blockId: BlockId, context?: ActionContext): Promise<ActionRev | undefined> {
530
+ private async queryClusterForLatest(peerIds: string[], blockId: BlockId, context?: ActionContext): Promise<ClusterLatestQuery> {
342
531
  // Add timeout wrapper to prevent hanging on unresponsive peers
343
532
  const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> =>
344
533
  Promise.race([
@@ -356,18 +545,28 @@ export class CoordinatorRepo implements IRepo {
356
545
  })
357
546
  );
358
547
 
548
+ const selfId = this.localPeerId?.toString();
549
+ let local: ActionRev | undefined;
359
550
  const claims: RevClaim[] = [];
360
551
  for (const result of latestResults) {
361
- if (result.status === 'fulfilled' && result.value.value) {
362
- const { peerIdStr, value } = result.value;
363
- claims.push({ peerId: peerIdStr, rev: value.rev, actionId: value.actionId });
552
+ if (result.status !== 'fulfilled' || !result.value.value) continue;
553
+ const { peerIdStr, value } = result.value;
554
+ if (peerIdStr === selfId) {
555
+ local = value;
556
+ continue;
364
557
  }
558
+ claims.push({ peerId: peerIdStr, rev: value.rev, actionId: value.actionId });
365
559
  }
366
560
 
367
- const selected = selectQuorumRev(claims, this.simpleMajorityThreshold);
561
+ const capacity = this.corroboratorCapacity(peerIds);
562
+ const selected = selectQuorumRev(claims, this.simpleMajorityThreshold, capacity);
368
563
  if (!selected) {
369
- log('cluster-fetch:no-quorum', { blockId, responders: claims.length });
370
- return undefined;
564
+ log('cluster-fetch:no-quorum', {
565
+ blockId,
566
+ responders: claims.length,
567
+ required: quorumSize(claims.length, this.simpleMajorityThreshold, capacity)
568
+ });
569
+ return { local };
371
570
  }
372
571
 
373
572
  // Best-effort: penalize peers whose claim contradicts the corroborated pair
@@ -375,7 +574,7 @@ export class CoordinatorRepo implements IRepo {
375
574
  // rev). A lower rev is just lag, never penalized. Never let this throw.
376
575
  this.penalizeContradictingRevClaims(claims, selected, blockId);
377
576
 
378
- return { actionId: selected.actionId, rev: selected.rev };
577
+ return { corroborated: { actionId: selected.actionId, rev: selected.rev }, local };
379
578
  }
380
579
 
381
580
  /**
@@ -8,6 +8,7 @@ import { encodePeers, type RedirectPayload } from './redirect.js'
8
8
  import { MAX_BLOCK_MESSAGE_BYTES } from '../protocol-limits.js'
9
9
  import type { Uint8ArrayList } from 'uint8arraylist'
10
10
  import { createLogger } from '../logger.js'
11
+ import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js'
11
12
 
12
13
  const debugLog = createLogger('repo-service')
13
14
 
@@ -38,7 +39,7 @@ export type RepoServiceComponents = BaseComponents & {
38
39
  libp2p?: Libp2p
39
40
  }
40
41
 
41
- export type RepoServiceInit = {
42
+ export type RepoServiceInit = InboundStreamAuthorizationInit & {
42
43
  protocol?: string,
43
44
  protocolPrefix?: string,
44
45
  maxInboundStreams?: number,
@@ -81,6 +82,8 @@ export class RepoService implements Startable {
81
82
  * self identity, and connection addrs through this explicitly-set reference.
82
83
  */
83
84
  private libp2pRef: Libp2p | undefined
85
+ /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
86
+ private readonly authorization: InboundStreamAuthorization | undefined
84
87
 
85
88
  constructor(components: RepoServiceComponents, init: RepoServiceInit = {}) {
86
89
  this.components = components
@@ -92,6 +95,7 @@ export class RepoService implements Startable {
92
95
  this.repo = components.repo
93
96
  this.running = false
94
97
  this.responsibilityK = init.responsibilityK ?? 1
98
+ this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args))
95
99
  }
96
100
 
97
101
  readonly [Symbol.toStringTag] = '@libp2p/repo-service'
@@ -236,8 +240,8 @@ export class RepoService implements Startable {
236
240
  /**
237
241
  * Handle incoming streams on the repo protocol
238
242
  */
239
- private handleIncomingStream(stream: Stream, connection: Connection): void {
240
- const peerId = connection.remotePeer
243
+ private handleIncomingStream(stream: Stream, connection?: Connection): void {
244
+ const peerId = connection?.remotePeer
241
245
 
242
246
  const processStream = async function* (this: RepoService, source: AsyncIterable<Uint8ArrayList>) {
243
247
  for await (const msg of source) {
@@ -278,6 +282,9 @@ export class RepoService implements Startable {
278
282
 
279
283
  void (async () => {
280
284
  try {
285
+ // Authorization runs before ANY decoding or execution. Guarded on the field so a
286
+ // node without a predicate keeps the original path untouched.
287
+ if (this.authorization && await this.authorization.deny(stream, peerId?.toString())) return
281
288
  const responses = pipe(
282
289
  stream,
283
290
  (source) => lpDecode(source, { maxDataLength: MAX_BLOCK_MESSAGE_BYTES }),
@@ -36,6 +36,17 @@ export class BlockStorage implements IBlockStorage {
36
36
  async getBlock(rev?: number): Promise<{ block: IBlock, actionRev: ActionRev } | undefined> {
37
37
  const meta = await this.storage.getMetadata(this.blockId);
38
38
  if (!meta) {
39
+ // No metadata at all ⇒ this node has never seen the block, and reads report it absent
40
+ // WITHOUT consulting `restoreCallback`. That is deliberate, not an oversight: `restoreCallback`
41
+ // is reachable only from ensureRevision below, so a never-seen block is never fetched HERE.
42
+ // Attempting a fetch at this layer would turn every read of a genuinely non-existent block —
43
+ // the common case for an insert probing for a collision — into a network round trip, because
44
+ // storage cannot tell "nobody has this" from "I don't have this".
45
+ //
46
+ // The layer that CAN tell them apart makes that call instead: `CoordinatorRepo` acquires the
47
+ // block only once the cohort has corroborated a `(rev, actionId)` for it
48
+ // (`restoreCorroborated` → `acquireBlockFromCohort`), so an id no peer claims still costs
49
+ // nothing beyond the latest-query it already performed. Keep this early return as-is.
39
50
  return undefined;
40
51
  }
41
52