@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optimystic/db-p2p",
3
- "version": "0.16.3",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "P2P database functionality for Optimystic",
6
6
  "main": "dist/src/index.js",
@@ -81,7 +81,7 @@
81
81
  "@libp2p/tcp": "^11.0.10",
82
82
  "@libp2p/websockets": "^10.1.3",
83
83
  "@multiformats/multiaddr": "^13.0.1",
84
- "@optimystic/db-core": "^0.16.3",
84
+ "@optimystic/db-core": "^0.17.0",
85
85
  "async-mutex": "^0.5.0",
86
86
  "debug": "^4.4.3",
87
87
  "it-all": "^3.0.9",
@@ -336,6 +336,23 @@ const keyNetwork = new Libp2pKeyPeerNetwork(node);
336
336
  - **Peer Discovery**: Integrates with libp2p DHT for peer discovery
337
337
  - **Security**: Leverages libp2p's cryptographic peer identity
338
338
 
339
+ **Inbound stream authorization (optional).** The `repo`, `cluster`, `sync` and `block-transfer`
340
+ protocols are open to any peer that can connect. An embedder whose database is private supplies one
341
+ node-level, fail-closed predicate, consulted once per inbound stream before any decoding or execution:
342
+
343
+ ```typescript
344
+ const node = await createLibp2pNode({
345
+ networkName: 'my-net',
346
+ bootstrapNodes: [],
347
+ // remotePeerId is the dialing peer's PeerId.toString(); only a literal `true` allows.
348
+ // false / throw / rejection / timeout all deny and abort the stream.
349
+ authorizeInboundStream: (remotePeerId, protocol) => memberSet.has(remotePeerId)
350
+ });
351
+ ```
352
+
353
+ Omitting it is the default and leaves behavior unchanged. See
354
+ [internals](../../docs/internals.md) § Inbound Stream Authorization for the full contract.
355
+
339
356
  ## Usage Examples
340
357
 
341
358
  ### Setting Up a Coordinator Node
@@ -1,4 +1,4 @@
1
- import type { Startable, Stream } from '@libp2p/interface';
1
+ import type { Connection, Startable, Stream } from '@libp2p/interface';
2
2
  import type { IRepo, PeerId, IPeerNetwork, ActionId, ActionRev, IBlock, BlockId } from '@optimystic/db-core';
3
3
  import { pipe } from 'it-pipe';
4
4
  import * as lp from 'it-length-prefixed';
@@ -7,6 +7,7 @@ import { toString as u8ToString } from 'uint8arrays/to-string';
7
7
  import { ProtocolClient } from '../protocol-client.js';
8
8
  import { MAX_BLOCK_MESSAGE_BYTES } from '../protocol-limits.js';
9
9
  import { createLogger } from '../logger.js';
10
+ import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js';
10
11
 
11
12
  const log = createLogger('block-transfer-service');
12
13
 
@@ -61,7 +62,7 @@ export interface IBlockReplicaStore extends IRepo {
61
62
  saveReplicatedBlock(blockId: BlockId, block: IBlock, source?: ActionRev): Promise<void>;
62
63
  }
63
64
 
64
- export interface BlockTransferServiceInit {
65
+ export interface BlockTransferServiceInit extends InboundStreamAuthorizationInit {
65
66
  protocolPrefix?: string;
66
67
  }
67
68
 
@@ -81,6 +82,8 @@ export class BlockTransferService implements Startable {
81
82
  private readonly protocol: string;
82
83
  private readonly repo: IBlockReplicaStore;
83
84
  private readonly registrar: BlockTransferServiceComponents['registrar'];
85
+ /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
86
+ private readonly authorization: InboundStreamAuthorization | undefined;
84
87
 
85
88
  constructor(
86
89
  components: BlockTransferServiceComponents,
@@ -89,11 +92,12 @@ export class BlockTransferService implements Startable {
89
92
  this.protocol = buildBlockTransferProtocol(init.protocolPrefix ?? '');
90
93
  this.repo = components.repo;
91
94
  this.registrar = components.registrar;
95
+ this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => log(msg, ...args));
92
96
  }
93
97
 
94
98
  async start(): Promise<void> {
95
99
  if (this.running) return;
96
- await this.registrar.handle(this.protocol, async (data: any) => {
100
+ await this.registrar.handle(this.protocol, async (data: any, connection?: Connection) => {
97
101
  // libp2p invokes the stream handler with the Stream as the FIRST positional argument
98
102
  // (see cluster/repo/dispute services, which all use `(stream, connection)`). The block-
99
103
  // transfer handler previously read `data.stream`, which is `undefined` for the positional
@@ -101,7 +105,7 @@ export class BlockTransferService implements Startable {
101
105
  // never replied, and every push/pull dialled this service hung with no response. Unwrap
102
106
  // defensively (older shape passed `{ stream }`), mirroring sync/service.ts.
103
107
  const stream = data?.stream ?? data;
104
- await this.handleRequest(stream);
108
+ await this.handleRequest(stream, connection);
105
109
  });
106
110
  this.running = true;
107
111
  log('started on %s', this.protocol);
@@ -114,9 +118,12 @@ export class BlockTransferService implements Startable {
114
118
  log('stopped');
115
119
  }
116
120
 
117
- private async handleRequest(stream: Stream): Promise<void> {
121
+ private async handleRequest(stream: Stream, connection?: Connection): Promise<void> {
118
122
  const self = this;
119
123
  try {
124
+ // Authorization runs before ANY decoding or execution. Guarded on the field so a
125
+ // node without a predicate keeps the original path untouched.
126
+ if (this.authorization && await this.authorization.deny(stream, connection?.remotePeer?.toString())) return;
120
127
  // Read the request, process it, and write the response on ONE continuous duplex
121
128
  // pipe (mirrors cluster/repo/dispute services). The earlier read-to-end-then-write
122
129
  // design deadlocked over a real stream: the client sends one length-prefixed request
@@ -17,6 +17,8 @@ import type { FretService } from "p2p-fret";
17
17
  import type { IPeerReputation } from "../reputation/types.js";
18
18
  import { PenaltyReason } from "../reputation/types.js";
19
19
  import type { ITransactionStateStore } from "./i-transaction-state-store.js";
20
+ import { isMissingBaseRevisionFailure } from "../storage/storage-repo.js";
21
+ import { RECONCILE_TIMEOUT_MS } from "./reconcile-block.js";
20
22
 
21
23
  const log = createLogger('cluster-member')
22
24
 
@@ -176,7 +178,8 @@ const ExecutedTransactionTtlMs = 10 * 60 * 1000;
176
178
  // Upper bound on an awaited active reconciliation of a divergent commit. Bounds the
177
179
  // consensus path so a slow/unreachable cohort peer can't stall the cluster stream;
178
180
  // a timeout is logged and tolerated (never thrown — that would reset the stream).
179
- const ReconcileTimeoutMs = 5000;
181
+ // Shared with the read path's acquisition (see RECONCILE_TIMEOUT_MS) — same operation, same bound.
182
+ const ReconcileTimeoutMs = RECONCILE_TIMEOUT_MS;
180
183
 
181
184
  /**
182
185
  * True when a thrown storage error reports a missing pending action — i.e. this
@@ -1124,9 +1127,13 @@ export class ClusterMember implements ICluster {
1124
1127
  * - **behind**: we missed the prior `pend` cluster-transaction (cohort drift
1125
1128
  * between the independent pend and commit phases, or transient unreachability),
1126
1129
  * so we lack the pending action — `StorageRepo.commit` *throws* "Pending
1127
- * action … not found".
1130
+ * action … not found";
1131
+ * - **behind (no base)**: we DID see the pend, but we never saw the revision that
1132
+ * created the block, so the transform has nothing to apply to —
1133
+ * `StorageRepo.commit` returns `success:false` with a `missing-base-revision`
1134
+ * reason rather than recording a revision it could not materialize.
1128
1135
  *
1129
- * For the **behind** case we hold no revision of the committed blocks at all, so we
1136
+ * For both **behind** cases we hold no usable revision of the committed blocks, so we
1130
1137
  * actively reconcile: pull the committed revision from a cohort peer that holds it
1131
1138
  * (`reconcileBlock`) and restore it locally. Lazy read-repair on a later read cannot
1132
1139
  * recover it on its own when cohort drift has left the block under-replicated (no
@@ -1215,7 +1222,8 @@ export class ClusterMember implements ICluster {
1215
1222
  }
1216
1223
  if (!result.success) {
1217
1224
  // success:false is a StaleFailure. `missing` ⇒ ahead/stale divergence
1218
- // (we already hold ≥ this rev): tolerate, do NOT reconcile downward. A bare
1225
+ // (we already hold ≥ this rev): tolerate, do NOT reconcile downward. A
1226
+ // missing-base reason ⇒ behind divergence, reconcile (below). Any other bare
1219
1227
  // `reason` with no `missing` ⇒ a genuine internalCommit fault: propagate so
1220
1228
  // handleConsensus rolls back the executed marker and rethrows.
1221
1229
  if (result.missing?.length) {
@@ -1228,6 +1236,22 @@ export class ClusterMember implements ICluster {
1228
1236
  });
1229
1237
  return;
1230
1238
  }
1239
+ // This member holds no materializable base for one of the blocks, so
1240
+ // `StorageRepo.commit` REFUSED rather than record a revision it could never serve.
1241
+ // Same "behind" divergence as a missing pend — and the same cure: pull the committed
1242
+ // revision from a cohort peer. Reconciling here (after commit released its per-block
1243
+ // latches) is what makes refusing safe; fetching inside the commit path would deadlock
1244
+ // against the latch `saveReplicatedBlock` needs to persist what it fetched.
1245
+ if (isMissingBaseRevisionFailure(result)) {
1246
+ log('cluster-member:consensus-commit-diverged', {
1247
+ messageHash,
1248
+ actionId: commit.actionId,
1249
+ divergence: 'behind',
1250
+ reason: result.reason
1251
+ });
1252
+ await this.reconcileDivergentCommit(record, commit);
1253
+ return;
1254
+ }
1231
1255
  throw new Error(`Consensus commit for action ${commit.actionId} failed: ${result.reason ?? 'unknown reason'}`);
1232
1256
  }
1233
1257
  return;
Binary file
@@ -0,0 +1,158 @@
1
+ import type { ActionRev, BlockId, IBlock } from "@optimystic/db-core";
2
+ import type { BlockArchive } from "../storage/struct.js";
3
+ import type { ReconcileBlockCallback } from "./cluster-repo.js";
4
+ import type { IPeerReputation } from "../reputation/types.js";
5
+ import { PenaltyReason } from "../reputation/types.js";
6
+ import {
7
+ selectQuorumRev, selectQuorumBlock, canonicalBlockHash,
8
+ type RevClaim, type BlockHashCandidate, type QuorumRev
9
+ } from "./quorum-restore.js";
10
+ import { createLogger } from '../logger.js';
11
+
12
+ const log = createLogger('reconcile-block');
13
+
14
+ /**
15
+ * Wall-clock bound on one whole reconcile pass (all cohort peers, both quorums, the persist).
16
+ * Shared by both callers so a slow or unreachable cohort peer stalls neither the commit path
17
+ * (`ClusterMember.withReconcileTimeout` — a stall there holds up consensus execution) nor the read
18
+ * path (`CoordinatorRepo.restoreCorroborated` — a stall there holds up a caller's `get`).
19
+ */
20
+ export const RECONCILE_TIMEOUT_MS = 5000;
21
+
22
+ /** One cohort peer's answer for a block: its highest revision, and the block bytes if it carried them. */
23
+ interface ReconcileCandidate {
24
+ peerId: string;
25
+ rev: number;
26
+ actionId: string;
27
+ /** Present only when the serving archive carried a materialized block for `rev`. */
28
+ block?: IBlock;
29
+ }
30
+
31
+ /** Collaborators {@link createReconcileBlock} needs, injected so the logic stays transport-agnostic. */
32
+ export interface ReconcileBlockDeps {
33
+ /** This node's own peer id; excluded from the cohort targets. */
34
+ selfPeerId: string;
35
+ /** Fetch one cohort peer's archive for `blockId` — `undefined` when it is unreachable or holds nothing. */
36
+ fetchArchive: (peerId: string, blockId: BlockId) => Promise<BlockArchive | undefined>;
37
+ /** Persist the agreed content through the churn-replication funnel. */
38
+ saveReplicatedBlock: (blockId: BlockId, block: IBlock, source: ActionRev) => Promise<void>;
39
+ /** Proportional corroboration threshold; the cohort's `simpleMajorityThreshold`. */
40
+ simpleMajorityThreshold: number;
41
+ /** Configured full cluster size — the floor for {@link corroboratorCapacity}. */
42
+ clusterSize: number;
43
+ /** Best-effort misbehavior reporting; a throwing implementation is swallowed. */
44
+ reputation?: Pick<IPeerReputation, 'reportPeer'>;
45
+ }
46
+
47
+ /**
48
+ * How many peers other than this node could answer for the block at all. Mirrors
49
+ * `CoordinatorRepo.corroboratorCapacity` exactly, and for the same reason: the corroboration
50
+ * floor may be relaxed only for a cohort that is *genuinely* small, never for one that merely
51
+ * looks small. `cohortPeerIds` come from the (untrusted) coordinator-declared peer set, so a
52
+ * self-shrunk record could otherwise talk the requirement down to a single voter; taking the MAX
53
+ * against the configured `clusterSize` keeps that shrunken view out of the relaxed branch. The
54
+ * escape hatch for a real two-node deployment is to configure `clusterSize: 2`.
55
+ */
56
+ function corroboratorCapacity(targets: string[], clusterSize: number): number {
57
+ return Math.max(targets.length, clusterSize - 1);
58
+ }
59
+
60
+ /**
61
+ * The claim a peer's archive makes: its highest revision, provided that revision is at least the
62
+ * one we committed. `undefined` when the peer served nothing usable (unreachable, empty archive,
63
+ * or only revisions older than the commit we are healing).
64
+ */
65
+ function toCandidate(peerId: string, archive: BlockArchive | undefined, committedRev: number): ReconcileCandidate | undefined {
66
+ if (!archive) return undefined;
67
+ const revs = Object.keys(archive.revisions).map(Number);
68
+ if (revs.length === 0) return undefined;
69
+ const maxRev = Math.max(...revs);
70
+ if (maxRev < committedRev) return undefined;
71
+ const data = archive.revisions[maxRev];
72
+ if (!data?.action) return undefined;
73
+ return { peerId, rev: maxRev, actionId: data.action.actionId, block: data.block };
74
+ }
75
+
76
+ /** Hash the block bytes of every candidate that both corroborates `selected` and actually carried content. */
77
+ async function hashCarriers(candidates: ReconcileCandidate[], selected: QuorumRev): Promise<BlockHashCandidate[]> {
78
+ const carriers = candidates.filter(c => c.rev === selected.rev && c.actionId === selected.actionId && c.block);
79
+ return await Promise.all(
80
+ carriers.map(async c => ({ peerId: c.peerId, hash: await canonicalBlockHash(c.block!), block: c.block! }))
81
+ );
82
+ }
83
+
84
+ /** Report cohort members that served content contradicting the agreed hash. Best-effort; never throws. */
85
+ function penalizeContradictingContent(
86
+ reputation: Pick<IPeerReputation, 'reportPeer'> | undefined,
87
+ candidates: BlockHashCandidate[],
88
+ agreedHash: string,
89
+ blockId: BlockId
90
+ ): void {
91
+ if (!reputation) return;
92
+ try {
93
+ for (const c of candidates) {
94
+ if (c.hash !== agreedHash) {
95
+ reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
96
+ }
97
+ }
98
+ } catch (err) {
99
+ log('reconcile:penalize-error', { blockId, error: (err as Error).message });
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Active reconciliation for a block this member committed without a materializable base
105
+ * (cohort drift between the independent pend and commit cluster-transactions, or a refused
106
+ * `missing-base-revision` commit). Queries the commit cohort — self already excluded by
107
+ * `ClusterMember.reconcileDivergentCommit` — for the block, picks the target revision by quorum
108
+ * corroboration rather than raw `Math.max` (a lone peer inflating its rev cannot steer
109
+ * reconciliation), verifies the cohort agrees on the *content* at that revision, and persists it.
110
+ *
111
+ * Both quorums are capped by {@link corroboratorCapacity}: demanding two corroborators from a
112
+ * cohort that contains exactly one other peer is a permanent deadlock, not a safety property —
113
+ * the node can never heal and stays unreadable forever.
114
+ *
115
+ * **Exposure at capacity 1 (documented, not accidental).** Block ids are random 256-bit strings
116
+ * (`db-core` `structs.ts`), NOT content-addressed, so nothing on the receive path can re-derive
117
+ * the id from the bytes: `canonicalBlockHash` is a cross-peer *agreement* hash, never a check
118
+ * against `blockId`. A sole cohort peer's content is therefore believed on its word. That adds no
119
+ * trust the cohort had not already extended — the same peer's `(rev, actionId)` claim is likewise
120
+ * uncorroborable at that size (see `selectQuorumRev`'s capacity note), and a two-member cohort has
121
+ * no honest majority to appeal to in the first place. Closing it needs commit-cert verification,
122
+ * tracked by backlog `debt-read-repair-commit-cert-verification`.
123
+ *
124
+ * Declines are cheap and retryable: nothing is persisted, nothing is marked, and the next commit
125
+ * or churn/rebalance pass tries again.
126
+ */
127
+ export function createReconcileBlock(deps: ReconcileBlockDeps): ReconcileBlockCallback {
128
+ return async (blockId, committed, cohortPeerIds) => {
129
+ const targets = cohortPeerIds.filter(id => id !== deps.selfPeerId);
130
+ if (targets.length === 0) return;
131
+
132
+ const fetched = await Promise.all(
133
+ targets.map(async peerId => toCandidate(peerId, await deps.fetchArchive(peerId, blockId), committed.rev))
134
+ );
135
+ const candidates = fetched.filter((c): c is ReconcileCandidate => c !== undefined);
136
+ const capacity = corroboratorCapacity(targets, deps.clusterSize);
137
+
138
+ const revClaims: RevClaim[] = candidates.map(({ peerId, rev, actionId }) => ({ peerId, rev, actionId }));
139
+ const selected = selectQuorumRev(revClaims, deps.simpleMajorityThreshold, capacity);
140
+ if (!selected) {
141
+ // Leave the block behind; churn/rebalance and the next commit retry.
142
+ log('reconcile:no-rev-quorum', { blockId, rev: committed.rev, responders: revClaims.length, capacity });
143
+ return;
144
+ }
145
+
146
+ const hashCandidates = await hashCarriers(candidates, selected);
147
+ const agreed = selectQuorumBlock(hashCandidates, deps.simpleMajorityThreshold, capacity);
148
+ if (!agreed) {
149
+ log('reconcile:no-content-quorum', { blockId, rev: selected.rev, carriers: hashCandidates.length, capacity });
150
+ return;
151
+ }
152
+
153
+ penalizeContradictingContent(deps.reputation, hashCandidates, agreed.hash, blockId);
154
+
155
+ await deps.saveReplicatedBlock(blockId, agreed.block, { actionId: selected.actionId, rev: selected.rev });
156
+ log('reconcile:restored', { blockId, rev: selected.rev, actionId: selected.actionId });
157
+ };
158
+ }
@@ -7,6 +7,7 @@ import { encodePeers, type RedirectPayload } from '../repo/redirect.js';
7
7
  import { toClusterErrorEnvelope } from './cluster-error.js';
8
8
  import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
9
9
  import type { Uint8ArrayList } from 'uint8arraylist';
10
+ import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js';
10
11
 
11
12
  interface BaseComponents {
12
13
  logger: { forComponent: (name: string) => Logger },
@@ -31,7 +32,7 @@ export interface ClusterServiceComponents extends BaseComponents {
31
32
  getConnectionAddrs?: (peerId: PeerId) => string[]
32
33
  }
33
34
 
34
- export interface ClusterServiceInit {
35
+ export interface ClusterServiceInit extends InboundStreamAuthorizationInit {
35
36
  protocol?: string,
36
37
  protocolPrefix?: string,
37
38
  maxInboundStreams?: number,
@@ -65,6 +66,8 @@ export class ClusterService implements Startable {
65
66
  private running: boolean;
66
67
  /** Responsibility K - small-mesh bypass threshold for redirect decisions */
67
68
  private readonly responsibilityK: number;
69
+ /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
70
+ private readonly authorization: InboundStreamAuthorization | undefined;
68
71
 
69
72
  constructor(components: ClusterServiceComponents, init: ClusterServiceInit = {}) {
70
73
  this.components = components;
@@ -75,6 +78,7 @@ export class ClusterService implements Startable {
75
78
  this.cluster = components.cluster;
76
79
  this.running = false;
77
80
  this.responsibilityK = init.responsibilityK ?? 1;
81
+ this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args));
78
82
  }
79
83
 
80
84
  readonly [Symbol.toStringTag] = '@libp2p/cluster';
@@ -180,8 +184,8 @@ export class ClusterService implements Startable {
180
184
  throw new Error(`Unknown operation: ${message.operation}`);
181
185
  }
182
186
 
183
- private handleIncomingStream(stream: Stream, connection: Connection): void {
184
- const peerId = connection.remotePeer;
187
+ private handleIncomingStream(stream: Stream, connection?: Connection): void {
188
+ const peerId = connection?.remotePeer;
185
189
 
186
190
  const processStream = async function* (this: ClusterService, source: AsyncIterable<Uint8ArrayList>) {
187
191
  for await (const msg of source) {
@@ -215,6 +219,9 @@ export class ClusterService implements Startable {
215
219
 
216
220
  void (async () => {
217
221
  try {
222
+ // Authorization runs before ANY decoding or execution. Guarded on the field so a
223
+ // node without a predicate keeps the original path untouched.
224
+ if (this.authorization && await this.authorization.deny(stream, peerId?.toString())) return;
218
225
  const responses = pipe(
219
226
  stream,
220
227
  (source) => lpDecode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Optional, embedder-supplied authorization for inbound protocol streams.
3
+ *
4
+ * The `repo`, `cluster`, `sync` and `block-transfer` services otherwise go straight from
5
+ * "stream opened" to "decode and execute", so any peer that can open a connection can issue
6
+ * database operations. An application that owns a *private* database (only nodes it admitted
7
+ * may read or write) needs a seam ahead of decoding; this is that seam.
8
+ *
9
+ * Contract:
10
+ *
11
+ * - **Absent predicate → today's behavior exactly.** Every service holds
12
+ * `InboundStreamAuthorization | undefined`; when it is `undefined` the handler never
13
+ * awaits anything extra and no code on this path runs.
14
+ * - **Supplied predicate → fail closed.** Only a literal `true` allows the stream. `false`,
15
+ * a throw, a rejection, a timeout, or an unidentifiable remote peer all deny, and denial
16
+ * aborts the stream *before* any frame is decoded or any operation executed.
17
+ * - **Called once per inbound stream**, not once per operation — which is equivalent here
18
+ * because all four protocols are strictly one request per stream (each handler's generator
19
+ * `return`s after the first response, so a second queued frame is never read).
20
+ * - **Peer id encoding**: the predicate receives `connection.remotePeer.toString()` — the
21
+ * libp2p base58btc/CIDv1 peer-id string (e.g. `12D3KooW…`), the same form
22
+ * `PeerId.toString()` produces everywhere else in this codebase. Compare against that,
23
+ * never against a multiaddr, a public-key hash, or a base64 encoding.
24
+ * - **What a caller observes**: a stream reset. Denial is deliberately *not* reported on the
25
+ * wire — telling an unauthorized peer "you are not a member" confirms membership state to
26
+ * exactly the party the embedder decided not to trust, and the four protocols have four
27
+ * different response shapes with no common error frame. The denial is instead loud on the
28
+ * *denying* node: it is logged with the peer id, protocol and reason, and the stream is
29
+ * aborted with an {@link UnauthorizedInboundStreamError} carrying
30
+ * {@link INBOUND_STREAM_UNAUTHORIZED_CODE}, so local diagnostics can tell a denial apart
31
+ * from a transport fault.
32
+ * - **Cost**: the predicate sits in the hot path of every inbound stream, ahead of the work
33
+ * that stream would do. Embedders are expected to make it cheap — an in-memory set lookup —
34
+ * and to memoize anything that would otherwise hit storage or the network per stream.
35
+ */
36
+
37
+ /**
38
+ * Predicate deciding whether `remotePeerId` may open `protocol` on this node.
39
+ *
40
+ * @param remotePeerId the dialing peer's `PeerId.toString()` (base58btc / CIDv1, `12D3KooW…`)
41
+ * @param protocol the full protocol id the stream was opened on, e.g.
42
+ * `/optimystic/<network>/repo/1.0.0`
43
+ * @returns `true` to allow. Anything else — `false`, a throw, or a rejection — denies.
44
+ */
45
+ export type AuthorizeInboundStream = (remotePeerId: string, protocol: string) => Promise<boolean> | boolean;
46
+
47
+ /** Stable error code carried by {@link UnauthorizedInboundStreamError}. */
48
+ export const INBOUND_STREAM_UNAUTHORIZED_CODE = 'ERR_INBOUND_STREAM_UNAUTHORIZED';
49
+
50
+ /**
51
+ * The reason an inbound stream was aborted by the authorization gate. Distinct from a
52
+ * transport fault so the denying node's logs and any local abort-reason inspection can tell
53
+ * the two apart; the remote only ever sees the stream reset (see the module doc).
54
+ */
55
+ export class UnauthorizedInboundStreamError extends Error {
56
+ readonly code = INBOUND_STREAM_UNAUTHORIZED_CODE;
57
+ constructor(remotePeerId: string, protocol: string, reason: string) {
58
+ super(`inbound stream denied: peer=${remotePeerId} protocol=${protocol} reason=${reason}`);
59
+ this.name = 'UnauthorizedInboundStreamError';
60
+ }
61
+ }
62
+
63
+ /**
64
+ * How long the predicate may take before the stream is denied. A hanging predicate would
65
+ * otherwise pin an inbound stream slot indefinitely; timing out is the fail-closed reading of
66
+ * "we could not establish that this peer is allowed".
67
+ */
68
+ export const DEFAULT_INBOUND_AUTHORIZATION_TIMEOUT_MS = 5_000;
69
+
70
+ /** The slice of a service's init that configures this gate. Mixed into all four service inits. */
71
+ export interface InboundStreamAuthorizationInit {
72
+ /**
73
+ * Optional predicate consulted once per inbound stream, before any decoding or execution.
74
+ * Absent → no check at all. See {@link AuthorizeInboundStream}.
75
+ */
76
+ authorizeInboundStream?: AuthorizeInboundStream;
77
+ /**
78
+ * Deadline for {@link InboundStreamAuthorizationInit.authorizeInboundStream}; expiry denies
79
+ * the stream. Default {@link DEFAULT_INBOUND_AUTHORIZATION_TIMEOUT_MS}.
80
+ */
81
+ authorizeInboundStreamTimeoutMs?: number;
82
+ }
83
+
84
+ /** The part of a libp2p `Stream` this gate needs: the ability to tear it down. */
85
+ interface AbortableStream {
86
+ abort: (err: Error) => void;
87
+ }
88
+
89
+ /** Log sink shape shared by the component loggers and the `debug` loggers the services use. */
90
+ export type AuthorizationLog = (message: string, ...args: unknown[]) => void;
91
+
92
+ /** Marker resolved by the deadline race when the predicate has not settled in time. */
93
+ const TIMED_OUT = Symbol('inbound-authorization-timeout');
94
+
95
+ /**
96
+ * A configured authorization gate for one service's protocol. Constructed only when the
97
+ * embedder supplied a predicate, so a service holding `undefined` runs the original code path.
98
+ */
99
+ export class InboundStreamAuthorization {
100
+ constructor(
101
+ private readonly authorize: AuthorizeInboundStream,
102
+ private readonly protocol: string,
103
+ private readonly timeoutMs: number,
104
+ private readonly log: AuthorizationLog
105
+ ) { }
106
+
107
+ /**
108
+ * Consult the predicate for one inbound stream and abort the stream if it is not allowed.
109
+ *
110
+ * @returns `true` when the stream was denied — the caller must return immediately without
111
+ * decoding anything — and `false` when it may proceed.
112
+ */
113
+ async deny(stream: AbortableStream, remotePeerId: string | undefined): Promise<boolean> {
114
+ // No identifiable remote → the predicate cannot be asked, so we cannot establish
115
+ // authorization. Fail closed rather than fall through to execution.
116
+ if (remotePeerId === undefined) {
117
+ return this.abort(stream, '<unidentified>', 'no remote peer id on the inbound connection');
118
+ }
119
+ const verdict = await this.decide(remotePeerId);
120
+ return verdict.allowed ? false : this.abort(stream, remotePeerId, verdict.reason);
121
+ }
122
+
123
+ /** Run the predicate under its deadline, converting every failure mode into a denial. */
124
+ private async decide(remotePeerId: string): Promise<{ allowed: true } | { allowed: false, reason: string }> {
125
+ let timer: ReturnType<typeof setTimeout> | undefined;
126
+ try {
127
+ const verdict = this.authorize(remotePeerId, this.protocol);
128
+ // A synchronous predicate needs no timer at all.
129
+ if (typeof verdict === 'boolean') {
130
+ return verdict ? { allowed: true } : { allowed: false, reason: 'predicate returned false' };
131
+ }
132
+ const decision = await Promise.race([
133
+ verdict,
134
+ new Promise<typeof TIMED_OUT>(resolve => { timer = setTimeout(() => resolve(TIMED_OUT), this.timeoutMs); })
135
+ ]);
136
+ if (decision === TIMED_OUT) {
137
+ return { allowed: false, reason: `predicate did not settle within ${this.timeoutMs}ms` };
138
+ }
139
+ return decision === true ? { allowed: true } : { allowed: false, reason: 'predicate returned false' };
140
+ } catch (err) {
141
+ // A throwing predicate is a bug in the embedder, not permission to proceed: log it
142
+ // (never swallow) and deny.
143
+ this.log('authorization predicate threw for peer=%s protocol=%s - %o', remotePeerId, this.protocol, err);
144
+ return { allowed: false, reason: `predicate threw: ${err instanceof Error ? err.message : String(err)}` };
145
+ } finally {
146
+ if (timer !== undefined) clearTimeout(timer);
147
+ }
148
+ }
149
+
150
+ /** Log the denial and tear the stream down. Always returns `true` (denied). */
151
+ private abort(stream: AbortableStream, remotePeerId: string, reason: string): true {
152
+ const error = new UnauthorizedInboundStreamError(remotePeerId, this.protocol, reason);
153
+ this.log('inbound stream denied peer=%s protocol=%s reason=%s', remotePeerId, this.protocol, reason);
154
+ try {
155
+ stream.abort(error);
156
+ } catch (err) {
157
+ // The stream may already be torn down; the denial itself still stands.
158
+ this.log('aborting a denied stream failed peer=%s protocol=%s - %o', remotePeerId, this.protocol, err);
159
+ }
160
+ return true;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Build the gate for a service, or `undefined` when the embedder supplied no predicate.
166
+ *
167
+ * Returning `undefined` (rather than an always-allow gate) is what makes the absent-predicate
168
+ * case genuinely free: the call site guards on the field and never awaits.
169
+ */
170
+ export function createInboundStreamAuthorization(
171
+ init: InboundStreamAuthorizationInit,
172
+ protocol: string,
173
+ log: AuthorizationLog
174
+ ): InboundStreamAuthorization | undefined {
175
+ if (init.authorizeInboundStream === undefined) {
176
+ return undefined;
177
+ }
178
+ return new InboundStreamAuthorization(
179
+ init.authorizeInboundStream,
180
+ protocol,
181
+ init.authorizeInboundStreamTimeoutMs ?? DEFAULT_INBOUND_AUTHORIZATION_TIMEOUT_MS,
182
+ log
183
+ );
184
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from "./cluster/rebalance-monitor.js";
6
6
  export * from "./cluster/spread-on-churn.js";
7
7
  export * from "./cluster/block-transfer.js";
8
8
  export * from "./cluster/block-transfer-service.js";
9
+ export * from "./inbound-authorization.js";
9
10
  export * from "./protocol-client.js";
10
11
  export * from "./repo/client.js";
11
12
  export * from "./repo/cluster-coordinator.js";