@serfab/cadre-core 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/announce-addrs.d.ts +49 -0
  2. package/dist/announce-addrs.js +78 -0
  3. package/dist/announce-addrs.js.map +1 -0
  4. package/dist/cadre-node.d.ts +281 -30
  5. package/dist/cadre-node.js +513 -103
  6. package/dist/cadre-node.js.map +1 -1
  7. package/dist/control-database.d.ts +155 -23
  8. package/dist/control-database.js +272 -104
  9. package/dist/control-database.js.map +1 -1
  10. package/dist/control-read-retry.d.ts +103 -0
  11. package/dist/control-read-retry.js +145 -0
  12. package/dist/control-read-retry.js.map +1 -0
  13. package/dist/control-retry.d.ts +101 -0
  14. package/dist/control-retry.js +108 -0
  15. package/dist/control-retry.js.map +1 -0
  16. package/dist/control-schema.d.ts +1 -1
  17. package/dist/control-schema.js +20 -4
  18. package/dist/control-schema.js.map +1 -1
  19. package/dist/control-write-retry.d.ts +59 -35
  20. package/dist/control-write-retry.js +21 -92
  21. package/dist/control-write-retry.js.map +1 -1
  22. package/dist/delegate-admission.d.ts +17 -15
  23. package/dist/delegate-admission.js +17 -15
  24. package/dist/delegate-admission.js.map +1 -1
  25. package/dist/index.d.ts +6 -4
  26. package/dist/index.js +17 -8
  27. package/dist/index.js.map +1 -1
  28. package/dist/membership-connection-gater.d.ts +211 -25
  29. package/dist/membership-connection-gater.js +309 -29
  30. package/dist/membership-connection-gater.js.map +1 -1
  31. package/dist/peer-record.d.ts +26 -0
  32. package/dist/peer-record.js +32 -0
  33. package/dist/peer-record.js.map +1 -1
  34. package/dist/relay-addrs.d.ts +76 -26
  35. package/dist/relay-addrs.js +115 -27
  36. package/dist/relay-addrs.js.map +1 -1
  37. package/dist/relay-reservation.d.ts +20 -15
  38. package/dist/relay-reservation.js +18 -14
  39. package/dist/relay-reservation.js.map +1 -1
  40. package/dist/strand-addr-protocol.js +16 -0
  41. package/dist/strand-addr-protocol.js.map +1 -1
  42. package/dist/strand-backfill.js +13 -4
  43. package/dist/strand-backfill.js.map +1 -1
  44. package/dist/strand-formation-manager.d.ts +10 -9
  45. package/dist/strand-formation-manager.js +10 -9
  46. package/dist/strand-formation-manager.js.map +1 -1
  47. package/dist/strand-formation-protocol.d.ts +7 -7
  48. package/dist/strand-formation-protocol.js +8 -8
  49. package/dist/strand-formation-protocol.js.map +1 -1
  50. package/dist/strand-instance-manager.d.ts +4 -3
  51. package/dist/strand-instance-manager.js +31 -6
  52. package/dist/strand-instance-manager.js.map +1 -1
  53. package/dist/strand-membership-writer.d.ts +106 -14
  54. package/dist/strand-membership-writer.js +180 -14
  55. package/dist/strand-membership-writer.js.map +1 -1
  56. package/dist/strand-solicitation.d.ts +3 -3
  57. package/dist/strand-solicitation.js +4 -5
  58. package/dist/strand-solicitation.js.map +1 -1
  59. package/dist/strand-wake-protocol.d.ts +47 -2
  60. package/dist/strand-wake-protocol.js +76 -6
  61. package/dist/strand-wake-protocol.js.map +1 -1
  62. package/dist/types.d.ts +74 -29
  63. package/dist/types.js.map +1 -1
  64. package/package.json +8 -8
  65. package/src/announce-addrs.ts +93 -0
  66. package/src/cadre-node.ts +5603 -5158
  67. package/src/control-database.ts +2584 -2406
  68. package/src/control-read-retry.ts +167 -0
  69. package/src/control-retry.ts +190 -0
  70. package/src/control-schema.ts +20 -4
  71. package/src/control-write-retry.ts +30 -130
  72. package/src/delegate-admission.ts +17 -15
  73. package/src/index.ts +435 -407
  74. package/src/membership-connection-gater.ts +533 -204
  75. package/src/peer-record.ts +33 -0
  76. package/src/relay-addrs.ts +218 -111
  77. package/src/relay-reservation.ts +828 -823
  78. package/src/strand-addr-protocol.ts +17 -0
  79. package/src/strand-backfill.ts +389 -366
  80. package/src/strand-formation-manager.ts +492 -491
  81. package/src/strand-formation-protocol.ts +8 -8
  82. package/src/strand-instance-manager.ts +636 -611
  83. package/src/strand-membership-writer.ts +209 -14
  84. package/src/strand-solicitation.ts +6 -7
  85. package/src/strand-wake-protocol.ts +94 -8
  86. package/src/types.ts +74 -29
@@ -1,204 +1,533 @@
1
- /**
2
- * Control-network inbound connection gate (defense-in-depth).
3
- *
4
- * Layer 2 of the membership enforcement chain: the PRIMARY gates are per-stream
5
- * — every sensitive sereus control protocol rejects a peer that fails the
6
- * voucher-anchored membership check (wake and strand-addr via
7
- * `CadreNode.isAuthorizedMember`; the Optimystic control-DB protocols via the
8
- * materialized-snapshot gate `CadreNode.authorizeInboundControlStream`), and
9
- * the replicated rows an outsider *can* still write are disbelieved at read
10
- * time. This module adds the opportunistic connection-level layer on top: a
11
- * peer this node can positively determine is NOT authorized is refused at the
12
- * encrypted-connection checkpoint, before any protocol negotiation, so a
13
- * known-nothing outsider is never even in the conversation.
14
- *
15
- * ## The stranger allowlist (the ONE place it is defined)
16
- *
17
- * A libp2p connection gater decides per CONNECTION, before protocols are
18
- * negotiated, so "allow seed, deny repo" cannot be expressed here — instead the
19
- * policy admits a connection whenever a legitimate stranger interaction could be
20
- * riding it, and the per-stream gates take over. The complete set of protocols a
21
- * NOT-yet-authorized peer may legitimately speak on a control node is:
22
- *
23
- * - `/sereus/seed/1.0.0` ({@link SEED_PROTOCOL}) — enrollment seed delivery.
24
- * An owner dials a brand-new node to seed it (the new node has no members
25
- * yet, so its gate is inert), and an invited phone dials the owner before it
26
- * is authorized (admitted via the enrollment window `CadreNode.createInvite`
27
- * opens). The handler's own trust decision is the anchored seed-trust policy.
28
- * - `/sereus/formation/1.0.0` ({@link FORMATION_PROTOCOL}) — cross-party
29
- * strand formation via open invitations. Stranger-facing BY DESIGN: the
30
- * initiator is another party, and its token is only checkable inside the
31
- * protocol. The exemption is therefore keyed on EXPECTATION of a stranger,
32
- * not capability to serve one: stranger denial is suspended only while this
33
- * node has at least one UNEXPIRED, NOT-FULLY-CONSUMED open invitation
34
- * outstanding (`StrandSolicitationService.hasOutstandingInvitation` — the
35
- * tokens this process minted or published, plus any still-redeemable
36
- * `FormationInvite` row the usage recorder can see). Merely registering the
37
- * responder (`CadreNode.initializeStrandSolicitation`) does NOT suspend it,
38
- * so an app that registers eagerly at node bring-up keeps a live gate. The
39
- * handler's own trust decision remains the per-token check, which is
40
- * strictly finer than this one: a peer admitted here can still be rejected
41
- * in-protocol for a bogus or spent token.
42
- *
43
- * There is one further connection-level carve-out, which is NOT a protocol
44
- * exemption and needs no stranger window:
45
- *
46
- * - **Announced delegate peers** (`delegate-admission.ts`). A member's strand
47
- * node runs as a separate libp2p identity whose peerId no sibling can
48
- * recompute, so a relay-running control node would deny its circuit-relay
49
- * reservation. Before starting a strand node, a member's control node
50
- * announces that peerId over the already-authenticated strand-addr RPC, and
51
- * the receiver holds a short-lived grant for it
52
- * (`CadreNode.grantDelegateAdmission`). The grant admits the CONNECTION and
53
- * nothing else — a reservation needs no stream — and is deliberately
54
- * invisible to the per-stream gate below, so a delegate still gets refused
55
- * on every members-only protocol.
56
- *
57
- * Everything else a control node handles — the Optimystic control-DB protocols
58
- * (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`), wake
59
- * (`/sereus/strand-wake/1.0.0`), and strand-addr (`/sereus/strand-addr/1.0.0`)
60
- * — is members-only, and every one of them enforces that per-stream. Wake and
61
- * strand-addr check `isAuthorizedMember` inside their handlers; the four
62
- * control-DB protocols are gated by the fail-closed
63
- * `CadreNode.authorizeInboundControlStream` (wired as libp2p's
64
- * `authorizeInboundStream` upstream hook), which judges each inbound stream
65
- * against the MATERIALIZED authorized-peer snapshot — synchronous and
66
- * in-memory, because a live DB read from inside the gate would deadlock into
67
- * mutual denial. The two layers complement, not duplicate: this connection
68
- * gate is fail-open over a live DB read (deny only on positive proof of an
69
- * outsider), the stream gate is fail-closed over the snapshot and has NO
70
- * stranger carve-outs — an enrollment window admits a stranger's connection
71
- * for seed delivery, yet its repo streams are still refused.
72
- *
73
- * ## Fail-open, deliberately
74
- *
75
- * This layer only ever denies on a POSITIVE determination of "unauthorized
76
- * outsider while no stranger path is open". Any error, missing dependency, or
77
- * ambiguous state admits the connection and defers to the fail-closed stream
78
- * gates — a DB hiccup must not partition a legitimate cadre.
79
- */
80
-
81
- import debug from 'debug';
82
- import type { ConnectionGater, PeerId, MultiaddrConnection } from '@libp2p/interface';
83
- import { SEED_PROTOCOL } from './seed-bootstrap.js';
84
- import { FORMATION_PROTOCOL } from './strand-formation-protocol.js';
85
-
86
- const log = debug('sereus:cadre:connection-gater');
87
-
88
- /**
89
- * The protocols a not-yet-authorized peer may legitimately speak on a control
90
- * node (see the module doc above for why each is open and where its own trust
91
- * decision lives). Exported as the single reference point so the allowlist
92
- * cannot silently drift across modules.
93
- */
94
- export const STRANGER_OPEN_PROTOCOLS: readonly string[] = [SEED_PROTOCOL, FORMATION_PROTOCOL];
95
-
96
- /**
97
- * How long `CadreNode.createInvite` holds the inbound gate open for strangers
98
- * when the invite carries no explicit expiry: the invitee must dial in before
99
- * it is authorized, and an expiry-less invite gives the gate no bound of its
100
- * own. Redeeming after this window still works once the owner re-mints (or the
101
- * caller re-opens the window via `CadreNode.openEnrollmentWindow`).
102
- */
103
- export const DEFAULT_ENROLLMENT_WINDOW_MS = 30 * 60 * 1000;
104
-
105
- /**
106
- * Deadline for one admission decision, after which the connection is ADMITTED.
107
- *
108
- * libp2p awaits `denyInboundEncryptedConnection` inside the inbound upgrade
109
- * WITHOUT racing its inbound-upgrade timeout signal (unlike the pre-encryption
110
- * `denyInboundConnection` hook), so a decision that never settles wedges that
111
- * upgrade forever — the connection-manager's inbound-upgrade slot is taken by
112
- * `acceptIncomingConnection` and released in the `finally` that never runs. The
113
- * real policy reads the control DB (`listAuthorizedMembers`), which can pull
114
- * over the network, so "never settles" is reachable. Bounding it here keeps the
115
- * fail-open contract honest: a slow decision admits rather than silently
116
- * failing closed (or not at all).
117
- */
118
- export const ADMISSION_DECISION_TIMEOUT_MS = 2_000;
119
-
120
- /**
121
- * The admission decision the gate defers to — implemented by
122
- * `CadreNode.admitInboundControlConnection` (enrollment windows, anchor state,
123
- * bootstrap infra, the authorized-member set). Kept injectable so the gater's
124
- * composition/fail-open behavior is unit-testable without a full node.
125
- */
126
- export interface InboundAdmissionPolicy {
127
- /** Should an inbound encrypted connection from this peer be admitted? */
128
- admitInbound(remotePeerId: string): Promise<boolean> | boolean;
129
- }
130
-
131
- /**
132
- * Build the control node's connection gater: the caller-supplied gater (if any)
133
- * with membership admission composed onto `denyInboundEncryptedConnection` —
134
- * the earliest checkpoint where the remote's authenticated PeerId is known.
135
- *
136
- * Composition semantics: every hook of `base` is preserved as-is; on the
137
- * inbound-encrypted hook a deny from EITHER the base gater or the admission
138
- * policy denies. A policy error — or a decision slower than
139
- * `decisionTimeoutMs` — admits (fail-open, see module doc); the base gater's
140
- * verdict is still honored first.
141
- *
142
- * NOTE: `base` is spread, so a gater passed as a CLASS INSTANCE would lose its
143
- * prototype methods; every caller in this repo (and libp2p's own default)
144
- * supplies a plain object. If a class-based gater ever shows up, delegate
145
- * per-hook instead of spreading.
146
- *
147
- * Deny timing, as observed by the denied dialer: noise negotiates the muxer in
148
- * the security handshake's early data, so the DIALER's upgrade may complete
149
- * (its `dial()` resolves) before this receiver-side hook runs. The deny then
150
- * aborts the receiver's upgrade — the receiver never registers the connection
151
- * and never creates its muxer, so no protocol can ever be negotiated — and the
152
- * dialer sees its "open" connection close moments later.
153
- *
154
- * Control node only: strand cohort nodes legitimately connect cross-party
155
- * peers, so `CadreNode` threads the raw configured gater to them unchanged.
156
- */
157
- export function createMembershipConnectionGater(
158
- policy: InboundAdmissionPolicy,
159
- base?: ConnectionGater,
160
- decisionTimeoutMs: number = ADMISSION_DECISION_TIMEOUT_MS
161
- ): ConnectionGater {
162
- return {
163
- ...base,
164
- denyInboundEncryptedConnection: async (peerId: PeerId, maConn: MultiaddrConnection): Promise<boolean> => {
165
- if (await base?.denyInboundEncryptedConnection?.(peerId, maConn)) {
166
- return true;
167
- }
168
- const remotePeerId = peerId.toString();
169
- try {
170
- return !(await admitWithinDeadline(policy, remotePeerId, decisionTimeoutMs));
171
- } catch (error) {
172
- log('admitInbound threw for %s — admitting (fail-open; stream gates decide): %o', remotePeerId, error);
173
- return false;
174
- }
175
- }
176
- };
177
- }
178
-
179
- /**
180
- * Run the admission decision under {@link ADMISSION_DECISION_TIMEOUT_MS},
181
- * resolving to "admit" if it has not settled in time (see that constant for why
182
- * an unbounded await is not safe here). The timer is always cleared so a decided
183
- * connection never holds the event loop open.
184
- */
185
- async function admitWithinDeadline(
186
- policy: InboundAdmissionPolicy,
187
- remotePeerId: string,
188
- timeoutMs: number
189
- ): Promise<boolean> {
190
- let timer: ReturnType<typeof setTimeout> | undefined;
191
- try {
192
- return await Promise.race([
193
- Promise.resolve(policy.admitInbound(remotePeerId)),
194
- new Promise<boolean>((resolve) => {
195
- timer = setTimeout(() => {
196
- log('admitInbound exceeded %dms for %s — admitting (fail-open; stream gates decide)', timeoutMs, remotePeerId);
197
- resolve(true);
198
- }, timeoutMs);
199
- })
200
- ]);
201
- } finally {
202
- clearTimeout(timer);
203
- }
204
- }
1
+ /**
2
+ * Control-network inbound admission gates (defense-in-depth): the
3
+ * encrypted-connection checkpoint and the circuit-relay reservation checkpoint.
4
+ *
5
+ * Layer 2 of the membership enforcement chain: the PRIMARY gates are per-stream
6
+ * — every sensitive sereus control protocol rejects a peer that fails the
7
+ * voucher-anchored membership check (wake and strand-addr via
8
+ * `CadreNode.isAuthorizedMember`; the Optimystic control-DB protocols via the
9
+ * materialized-snapshot gate `CadreNode.authorizeInboundControlStream`), and
10
+ * the replicated rows an outsider *can* still write are disbelieved at read
11
+ * time. This module adds the opportunistic connection-level layer on top: a
12
+ * peer this node can positively determine is NOT authorized is refused at the
13
+ * encrypted-connection checkpoint, before any protocol negotiation, so a
14
+ * known-nothing outsider is never even in the conversation — EXCEPT on a node
15
+ * that runs the circuit-relay server, where the refusal moves to the
16
+ * relay-reservation checkpoint instead (see "The relay-reservation seam").
17
+ *
18
+ * ## The stranger allowlist (the ONE place it is defined)
19
+ *
20
+ * A libp2p connection gater decides per CONNECTION, before protocols are
21
+ * negotiated, so "allow seed, deny repo" cannot be expressed here — instead the
22
+ * policy admits a connection whenever a legitimate stranger interaction could be
23
+ * riding it, and the per-stream gates take over. The complete set of protocols a
24
+ * NOT-yet-authorized peer may legitimately speak on a control node is:
25
+ *
26
+ * - `/sereus/seed/1.0.0` ({@link SEED_PROTOCOL}) — enrollment seed delivery.
27
+ * An owner dials a brand-new node to seed it (the new node has no members
28
+ * yet, so its gate is inert), and an invited phone dials the owner before it
29
+ * is authorized (admitted via the enrollment window `CadreNode.createInvite`
30
+ * opens). The handler's own trust decision is the anchored seed-trust policy.
31
+ * - `/sereus/formation/1.0.0` ({@link FORMATION_PROTOCOL}) — cross-party
32
+ * strand formation via open invitations. Stranger-facing BY DESIGN: the
33
+ * initiator is another party, and its token is only checkable inside the
34
+ * protocol. The exemption is therefore keyed on EXPECTATION of a stranger,
35
+ * not capability to serve one: stranger denial is suspended only while this
36
+ * node has at least one UNEXPIRED, NOT-FULLY-CONSUMED open invitation
37
+ * outstanding (`StrandSolicitationService.hasOutstandingInvitation` — the
38
+ * tokens this process minted or published, plus any still-redeemable
39
+ * `FormationInvite` row the usage recorder can see). Merely registering the
40
+ * responder (`CadreNode.initializeStrandSolicitation`) does NOT suspend it,
41
+ * so an app that registers eagerly at node bring-up keeps a live gate. The
42
+ * handler's own trust decision remains the per-token check, which is
43
+ * strictly finer than this one: a peer admitted here can still be rejected
44
+ * in-protocol for a bogus or spent token.
45
+ *
46
+ * There is one further connection-level carve-out, which is NOT a protocol
47
+ * exemption and needs no stranger window:
48
+ *
49
+ * - **Announced delegate peers** (`delegate-admission.ts`). A member's strand
50
+ * node runs as a separate libp2p identity whose peerId no sibling can
51
+ * recompute. Before starting a strand node, a member's control node
52
+ * announces that peerId over the already-authenticated strand-addr RPC, and
53
+ * the receiver holds a short-lived grant for it
54
+ * (`CadreNode.grantDelegateAdmission`). The grant admits the CONNECTION,
55
+ * admits the peer's RESERVATION at the seam below (without spending the
56
+ * unauthorized budget), and nothing else — it is deliberately invisible to
57
+ * the per-stream gate, so a delegate still gets refused on every
58
+ * members-only protocol.
59
+ *
60
+ * ## The relay-reservation seam
61
+ *
62
+ * A circuit-relay reservation is established by the reserving peer DIALING the
63
+ * relay, so at the relay it is an inbound connection — and killing that
64
+ * connection kills the reservation. For a genuine member whose `CadrePeer` row
65
+ * has not yet replicated to this node, a connection-level deny here is NOT
66
+ * self-healing the way a data connection's is: an outbound reconcile dial
67
+ * re-establishes a data link, but no outbound dial can grant the REMOTE peer a
68
+ * reservation, and a relay-only peer has no address of its own to dial back —
69
+ * the reservation IS its address. Boot ordering makes the window ordinary (a
70
+ * node's circuit listener runs inside `libp2p.start()`, before its own row
71
+ * could have replicated anywhere), and a stalled replication makes it
72
+ * unbounded. So the relay would be answering a RELAY question ("may this peer
73
+ * use my forwarding capacity?") with a MEMBERSHIP answer, at a checkpoint where
74
+ * a wrong answer is unrecoverable.
75
+ *
76
+ * On a node whose relay server is enabled the two questions are separated:
77
+ *
78
+ * - The policy returns `'admit-for-relay'` instead of `'deny'`, and the
79
+ * connection is ADMITTED. The fail-closed per-stream gates still refuse such
80
+ * a peer every members-only protocol, so it gains identify/ping and the
81
+ * relay hop protocol, nothing else.
82
+ * - The reservation itself is decided at libp2p's
83
+ * `denyInboundRelayReservation` hook (the circuit-relay server consults it
84
+ * per RESERVE request): the policy admits members, delegates and configured
85
+ * infra outright, and admits peers it cannot place only within a bounded
86
+ * budget ({@link UnauthorizedReservationBudget}) — a member whose row is in
87
+ * flight always finds a slot under any sane cap, while outsiders cannot
88
+ * annex the party's relay capacity.
89
+ * - An `'admit-for-relay'` connection that is NOT reserving is dropped: it has
90
+ * {@link RELAY_ADMISSION_RESERVE_DEADLINE_MS} to get a reservation ADMITTED
91
+ * at that hook, after which the underlying connection is aborted. The
92
+ * guarantee above thus weakens on relay-enabled nodes from "never in the
93
+ * conversation" to "in it briefly, and can speak nothing".
94
+ *
95
+ * The deadline clears on reservation ADMISSION, not on the reservation's own
96
+ * success: this gate cannot observe the server-side `reserve()` outcome, so a
97
+ * peer whose admitted reservation is then refused for server capacity keeps its
98
+ * connection until either side closes it — bounded and mute, so harmless.
99
+ *
100
+ * Everything else a control node handles — the Optimystic control-DB protocols
101
+ * (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`), wake
102
+ * (`/sereus/strand-wake/1.0.0`), and strand-addr (`/sereus/strand-addr/1.0.0`)
103
+ * — is members-only, and every one of them enforces that per-stream. Wake and
104
+ * strand-addr check `isAuthorizedMember` inside their handlers; the four
105
+ * control-DB protocols are gated by the fail-closed
106
+ * `CadreNode.authorizeInboundControlStream` (wired as libp2p's
107
+ * `authorizeInboundStream` upstream hook), which judges each inbound stream
108
+ * against the MATERIALIZED authorized-peer snapshot — synchronous and
109
+ * in-memory, because a live DB read from inside the gate would deadlock into
110
+ * mutual denial. The two layers complement, not duplicate: this connection
111
+ * gate is fail-open over a live DB read (deny only on positive proof of an
112
+ * outsider), the stream gate is fail-closed over the snapshot and has NO
113
+ * stranger carve-outs — an enrollment window admits a stranger's connection
114
+ * for seed delivery, yet its repo streams are still refused.
115
+ *
116
+ * ## The bring-up quiet period
117
+ *
118
+ * One state is decided BEFORE any of the above, and is not about the remote peer
119
+ * at all: while this node's control-database bring-up is in flight
120
+ * (`InboundAdmissionPolicy.bringUpInFlight`), the gate denies BOTH directions —
121
+ * `denyDialPeer` and `denyInboundEncryptedConnection` — and then opens.
122
+ *
123
+ * The invariant it protects is "`ControlDatabase.initialize()` runs while this
124
+ * node holds ZERO control connections". Building that database is a long chain
125
+ * of cohort-consulting block probes, and every connected same-party sibling is in
126
+ * the cohort those probes consult. A sibling that has not yet replicated this
127
+ * node's `CadrePeer` row correctly refuses them at its own fail-closed per-stream
128
+ * gate, so ONE connection in this window turns bring-up into
129
+ * `BlockUnavailableError` and `start()` rejects. Retrying cannot converge: the
130
+ * condition that would clear the refusal is this node's own row reaching the
131
+ * sibling, and writing that row needs the database the retry is building. So the
132
+ * only fix is to not be in the conversation yet — which is what this window is.
133
+ *
134
+ * The ordering is arranged so nothing SHOULD open a connection here anyway:
135
+ * `network.relayAddrs` resolves to a listener that dials nothing and reserves
136
+ * after bring-up (`relay-addrs.ts`), and the control-cohort reconcile pass is
137
+ * scheduled post-start. This window is what makes that a property rather than an
138
+ * accident of ordering — the live case it actually catches is
139
+ * `controlNetwork.bootstrapNodes`, where `@libp2p/bootstrap` emits its discovery
140
+ * events ~1 s after `libp2p.start()` and the connection manager auto-dials from
141
+ * there, which is a race bring-up wins only while raw-storage latency stays low.
142
+ *
143
+ * A denial here costs a retry, not a partition: libp2p's connection manager
144
+ * re-dials on its auto-dial cadence, the reservation supervisor re-drives, and a
145
+ * denied inbound peer reconnects. The window opens on bring-up FAILURE too (via
146
+ * `CadreNode.cleanup`), so teardown is never gated.
147
+ *
148
+ * ## Fail-open, deliberately
149
+ *
150
+ * Outside that window this layer only ever denies on a POSITIVE determination of
151
+ * "unauthorized outsider while no stranger path is open". Any error, missing
152
+ * dependency, or ambiguous state admits the connection (and the reservation) and
153
+ * defers to the fail-closed stream gates — a DB hiccup must not partition a
154
+ * legitimate cadre.
155
+ */
156
+
157
+ import debug from 'debug';
158
+ import type { ConnectionGater, PeerId, MultiaddrConnection } from '@libp2p/interface';
159
+ import { SEED_PROTOCOL } from './seed-bootstrap.js';
160
+ import { FORMATION_PROTOCOL } from './strand-formation-protocol.js';
161
+
162
+ const log = debug('sereus:cadre:connection-gater');
163
+
164
+ /**
165
+ * The protocols a not-yet-authorized peer may legitimately speak on a control
166
+ * node (see the module doc above for why each is open and where its own trust
167
+ * decision lives). Exported as the single reference point so the allowlist
168
+ * cannot silently drift across modules.
169
+ */
170
+ export const STRANGER_OPEN_PROTOCOLS: readonly string[] = [SEED_PROTOCOL, FORMATION_PROTOCOL];
171
+
172
+ /**
173
+ * How long `CadreNode.createInvite` holds the inbound gate open for strangers
174
+ * when the invite carries no explicit expiry: the invitee must dial in before
175
+ * it is authorized, and an expiry-less invite gives the gate no bound of its
176
+ * own. Redeeming after this window still works once the owner re-mints (or the
177
+ * caller re-opens the window via `CadreNode.openEnrollmentWindow`).
178
+ */
179
+ export const DEFAULT_ENROLLMENT_WINDOW_MS = 30 * 60 * 1000;
180
+
181
+ /**
182
+ * Deadline for one admission decision, after which the fail-open outcome is
183
+ * used (connection admitted / reservation admitted).
184
+ *
185
+ * libp2p awaits `denyInboundEncryptedConnection` inside the inbound upgrade
186
+ * WITHOUT racing its inbound-upgrade timeout signal (unlike the pre-encryption
187
+ * `denyInboundConnection` hook), so a decision that never settles wedges that
188
+ * upgrade forever — the connection-manager's inbound-upgrade slot is taken by
189
+ * `acceptIncomingConnection` and released in the `finally` that never runs. The
190
+ * real policy reads the control DB (`listAuthorizedMembers`), which can pull
191
+ * over the network, so "never settles" is reachable. Bounding it here keeps the
192
+ * fail-open contract honest: a slow decision admits rather than silently
193
+ * failing closed (or not at all).
194
+ */
195
+ export const ADMISSION_DECISION_TIMEOUT_MS = 2_000;
196
+
197
+ /**
198
+ * How long an `'admit-for-relay'` connection may exist without a relay
199
+ * reservation being ADMITTED at the `denyInboundRelayReservation` hook, after
200
+ * which the gate aborts the underlying connection. A reserving client asks for
201
+ * its slot immediately after the connection upgrades (`relay-reservation.ts`
202
+ * dials and requests in one drive; a strand node's configured circuit listener
203
+ * does the same from inside its own `listen()`), so a connection idle past this
204
+ * deadline is not reserving.
205
+ */
206
+ export const RELAY_ADMISSION_RESERVE_DEADLINE_MS = 5_000;
207
+
208
+ /**
209
+ * Default cap on concurrent relay reservations held by peers the membership
210
+ * check could not place (see {@link UnauthorizedReservationBudget}). Small on
211
+ * purpose: it exists for the handful of genuine members whose rows are still
212
+ * in flight, not as public relay capacity. Overridable per node via
213
+ * `network.unauthorizedRelayReservationCap` (0 refuses every unauthorized
214
+ * reservation — the strict pre-seam posture).
215
+ *
216
+ * NOTE: this cap shares the relay server's own reservation store, whose default
217
+ * size is 15 (`@libp2p/circuit-relay-v2`'s `DEFAULT_MAX_RESERVATION_STORE_SIZE`);
218
+ * unplaced peers may therefore occupy up to this many of those slots, and the
219
+ * rest are what members and delegates compete for. This module cannot read the
220
+ * server's size, so the two are kept apart by hand — if either is raised, keep
221
+ * this one well under the store's, or a fleet of unplaceable peers can crowd
222
+ * genuine members out of the server's own store (which the gate cannot override).
223
+ */
224
+ export const MAX_UNAUTHORIZED_RELAY_RESERVATIONS = 8;
225
+
226
+ /**
227
+ * How long one {@link UnauthorizedReservationBudget} entry occupies a slot
228
+ * without a refresh. Mirrors the relay server's own default reservation TTL
229
+ * (`@libp2p/circuit-relay-v2`'s `DEFAULT_MAX_RESERVATION_TTL`, 2 h): the server
230
+ * holds an unrefreshed reservation exactly that long, and a live reserver
231
+ * re-requests (re-hitting the admission hook, refreshing its entry) well before
232
+ * expiry — so the live entry count tracks the server's own occupancy without
233
+ * this module reaching into the server's reservation store.
234
+ *
235
+ * NOTE: a mirror, not a live coupling — if the relay server is ever configured
236
+ * with a non-default `reservationTtl` (db-p2p's `relayServerInit`), update this
237
+ * constant alongside it or the budget's occupancy drifts from the server's.
238
+ */
239
+ export const UNAUTHORIZED_RESERVATION_TTL_MS = 2 * 60 * 60 * 1000;
240
+
241
+ /**
242
+ * The connection-level outcome of the admission policy:
243
+ * - `'admit'` — a peer with a legitimate claim on the connection (member,
244
+ * delegate, infra, open stranger window, or any fail-open state).
245
+ * - `'deny'` — positively an outsider, and this node runs no relay server, so
246
+ * the connection can carry nothing legitimate.
247
+ * - `'admit-for-relay'` — positively an outsider (or an unreplicated member —
248
+ * indistinguishable), but this node runs the relay server: admit the
249
+ * connection so a reservation can be asked for, decide at the reservation
250
+ * seam, and drop the connection if no reservation is admitted in time.
251
+ */
252
+ export type InboundConnectionVerdict = 'admit' | 'deny' | 'admit-for-relay';
253
+
254
+ /**
255
+ * The admission decisions the gate defers to — implemented by
256
+ * `CadreNode.admitInboundControlConnection` /
257
+ * `CadreNode.admitControlRelayReservation` (enrollment windows, anchor state,
258
+ * bootstrap infra, delegate grants, the authorized-member set, the
259
+ * unauthorized-reservation budget). Kept injectable so the gater's
260
+ * composition/fail-open behavior is unit-testable without a full node.
261
+ */
262
+ export interface InboundAdmissionPolicy {
263
+ /** Verdict on an inbound encrypted connection from this peer. */
264
+ admitInbound(remotePeerId: string): Promise<InboundConnectionVerdict> | InboundConnectionVerdict;
265
+ /** Should this peer be granted a circuit-relay reservation slot? */
266
+ admitRelayReservation(remotePeerId: string): Promise<boolean> | boolean;
267
+ /**
268
+ * True while this node's control-database bring-up is in flight — the
269
+ * {@link createMembershipConnectionGater} quiet period (see the module doc).
270
+ * Peer-independent, unlike the two decisions above, and SYNCHRONOUS: it is
271
+ * read on the outbound-dial path, where an await would be a new stall.
272
+ *
273
+ * Optional — a policy that omits it is never quiet, which is the right default
274
+ * for every caller that is not a booting `CadreNode`.
275
+ */
276
+ bringUpInFlight?(): boolean;
277
+ }
278
+
279
+ /**
280
+ * Bounded budget of concurrent relay reservations for peers the membership
281
+ * check could not place. `tryAdmit` is the whole surface: an already-admitted
282
+ * peer refreshes its entry (a reservation refresh never double-counts), a new
283
+ * peer takes a free slot or is refused. Entries expire after `ttlMs` (see
284
+ * {@link UNAUTHORIZED_RESERVATION_TTL_MS} for why that mirrors the relay
285
+ * server's own hold). Injectable `now` keeps expiry testable without fake
286
+ * timers. Authorized members and delegates are never run through this — the
287
+ * policy admits them before consulting the budget, and `release`s the slot such
288
+ * a peer took while it was still unplaceable, so the boot-ordering window a
289
+ * member passes through costs the budget nothing once its row lands.
290
+ */
291
+ export class UnauthorizedReservationBudget {
292
+ private readonly admitted = new Map<string, number>();
293
+
294
+ constructor(
295
+ private readonly cap: number = MAX_UNAUTHORIZED_RELAY_RESERVATIONS,
296
+ private readonly ttlMs: number = UNAUTHORIZED_RESERVATION_TTL_MS
297
+ ) {}
298
+
299
+ /** Number of live (unexpired at last prune) entries — test/diagnostic surface. */
300
+ get size(): number {
301
+ return this.admitted.size;
302
+ }
303
+
304
+ /** Admit (or refresh) `remotePeerId` if a slot is free; false when the cap is spent. */
305
+ tryAdmit(remotePeerId: string, now: number = Date.now()): boolean {
306
+ this.prune(now);
307
+ if (!this.admitted.has(remotePeerId) && this.admitted.size >= this.cap) {
308
+ log('Unauthorized-reservation budget spent (%d/%d) — refusing %s', this.admitted.size, this.cap, remotePeerId);
309
+ return false;
310
+ }
311
+ this.admitted.set(remotePeerId, now + this.ttlMs);
312
+ return true;
313
+ }
314
+
315
+ /**
316
+ * Give back the slot `remotePeerId` holds, if any — called when the peer has
317
+ * become admissible on its own merits (its membership row replicated, or a
318
+ * delegate grant landed), so the slot it took during the boot-ordering window
319
+ * does not stay spent for the remaining TTL on a peer that no longer needs it.
320
+ */
321
+ release(remotePeerId: string): void {
322
+ this.admitted.delete(remotePeerId);
323
+ }
324
+
325
+ private prune(now: number): void {
326
+ for (const [peerId, expiresAt] of this.admitted) {
327
+ if (expiresAt <= now) {
328
+ this.admitted.delete(peerId);
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Build the control node's connection gater: the caller-supplied gater (if any)
336
+ * with membership admission composed onto `denyInboundEncryptedConnection` —
337
+ * the earliest checkpoint where the remote's authenticated PeerId is known —
338
+ * and reservation admission composed onto `denyInboundRelayReservation` — the
339
+ * hook the circuit-relay server consults per RESERVE request (inert on a node
340
+ * whose relay server is off; libp2p never calls it there).
341
+ *
342
+ * `denyDialPeer` is composed too, but ONLY for the bring-up quiet period (see
343
+ * the module doc): outside that window outbound dials are never gated by
344
+ * membership — this node decides for itself who to talk to.
345
+ *
346
+ * Composition semantics: every hook of `base` is preserved as-is; on the three
347
+ * composed hooks a deny from EITHER the base gater or the admission policy
348
+ * denies. A policy error — or a decision slower than `decisionTimeoutMs` —
349
+ * takes the fail-open outcome (connection admitted / reservation admitted, see
350
+ * module doc); the base gater's verdict is still honored first.
351
+ *
352
+ * An `'admit-for-relay'` verdict admits the connection and arms a
353
+ * `reserveDeadlineMs` timer against it; the timer is disarmed when a
354
+ * reservation for that peer is ADMITTED at the reservation hook, and aborts the
355
+ * underlying `MultiaddrConnection` when it fires first.
356
+ *
357
+ * NOTE: `base` is spread, so a gater passed as a CLASS INSTANCE would lose its
358
+ * prototype methods; every caller in this repo (and libp2p's own default)
359
+ * supplies a plain object. If a class-based gater ever shows up, delegate
360
+ * per-hook instead of spreading.
361
+ *
362
+ * Deny timing, as observed by the denied dialer: noise negotiates the muxer in
363
+ * the security handshake's early data, so the DIALER's upgrade may complete
364
+ * (its `dial()` resolves) before this receiver-side hook runs. The deny then
365
+ * aborts the receiver's upgrade — the receiver never registers the connection
366
+ * and never creates its muxer, so no protocol can ever be negotiated — and the
367
+ * dialer sees its "open" connection close moments later.
368
+ *
369
+ * Control node only: strand cohort nodes legitimately connect cross-party
370
+ * peers, so `CadreNode` threads the raw configured gater to them unchanged.
371
+ */
372
+ export function createMembershipConnectionGater(
373
+ policy: InboundAdmissionPolicy,
374
+ base?: ConnectionGater,
375
+ decisionTimeoutMs: number = ADMISSION_DECISION_TIMEOUT_MS,
376
+ reserveDeadlineMs: number = RELAY_ADMISSION_RESERVE_DEADLINE_MS
377
+ ): ConnectionGater {
378
+ const pendingReservations = new PendingReserveDeadlines(reserveDeadlineMs);
379
+ const quiet = (): boolean => policy.bringUpInFlight?.() ?? false;
380
+ return {
381
+ ...base,
382
+ denyDialPeer: async (peerId: PeerId): Promise<boolean> => {
383
+ if (await base?.denyDialPeer?.(peerId)) {
384
+ return true;
385
+ }
386
+ if (quiet()) {
387
+ log('Control-database bring-up in flight — refusing the outbound dial to %s', peerId.toString());
388
+ return true;
389
+ }
390
+ return false;
391
+ },
392
+ denyInboundEncryptedConnection: async (peerId: PeerId, maConn: MultiaddrConnection): Promise<boolean> => {
393
+ if (await base?.denyInboundEncryptedConnection?.(peerId, maConn)) {
394
+ return true;
395
+ }
396
+ if (quiet()) {
397
+ log('Control-database bring-up in flight — refusing the inbound connection from %s', peerId.toString());
398
+ return true;
399
+ }
400
+ const remotePeerId = peerId.toString();
401
+ let verdict: InboundConnectionVerdict;
402
+ try {
403
+ verdict = await decideWithinDeadline(
404
+ () => policy.admitInbound(remotePeerId), 'admit', decisionTimeoutMs, `admitInbound(${remotePeerId})`
405
+ );
406
+ } catch (error) {
407
+ log('admitInbound threw for %s — admitting (fail-open; stream gates decide): %o', remotePeerId, error);
408
+ return false;
409
+ }
410
+ if (verdict === 'admit-for-relay') {
411
+ pendingReservations.arm(remotePeerId, maConn);
412
+ return false;
413
+ }
414
+ return verdict === 'deny';
415
+ },
416
+ denyInboundRelayReservation: async (peerId: PeerId): Promise<boolean> => {
417
+ if (await base?.denyInboundRelayReservation?.(peerId)) {
418
+ return true;
419
+ }
420
+ const remotePeerId = peerId.toString();
421
+ let admitted: boolean;
422
+ try {
423
+ admitted = await decideWithinDeadline(
424
+ () => policy.admitRelayReservation(remotePeerId), true, decisionTimeoutMs, `admitRelayReservation(${remotePeerId})`
425
+ );
426
+ } catch (error) {
427
+ log('admitRelayReservation threw for %s — admitting (fail-open): %o', remotePeerId, error);
428
+ admitted = true;
429
+ }
430
+ if (admitted) {
431
+ pendingReservations.disarm(remotePeerId);
432
+ }
433
+ return !admitted;
434
+ }
435
+ };
436
+ }
437
+
438
+ /**
439
+ * Run one admission decision under `timeoutMs`, resolving to `fallback` (the
440
+ * fail-open outcome) if it has not settled in time — see
441
+ * {@link ADMISSION_DECISION_TIMEOUT_MS} for why an unbounded await is not safe
442
+ * on the connection hook. The timer is always cleared so a decided call never
443
+ * holds the event loop open.
444
+ */
445
+ async function decideWithinDeadline<T>(
446
+ decide: () => Promise<T> | T,
447
+ fallback: T,
448
+ timeoutMs: number,
449
+ what: string
450
+ ): Promise<T> {
451
+ let timer: ReturnType<typeof setTimeout> | undefined;
452
+ try {
453
+ return await Promise.race([
454
+ Promise.resolve().then(decide),
455
+ new Promise<T>((resolve) => {
456
+ timer = setTimeout(() => {
457
+ log('%s exceeded %dms — taking the fail-open outcome (stream gates decide)', what, timeoutMs);
458
+ resolve(fallback);
459
+ }, timeoutMs);
460
+ })
461
+ ]);
462
+ } finally {
463
+ clearTimeout(timer);
464
+ }
465
+ }
466
+
467
+ /** One armed not-reserving deadline: the connection it will abort, and its timer. */
468
+ interface PendingReserveDeadline {
469
+ maConn: MultiaddrConnection;
470
+ timer: ReturnType<typeof setTimeout>;
471
+ }
472
+
473
+ /**
474
+ * The not-reserving deadlines for `'admit-for-relay'` connections, keyed by
475
+ * remote peerId (a Set per peer — one peer can hold several in-flight
476
+ * connections). `arm` starts a timer that aborts the connection; `disarm`
477
+ * (called when a reservation for that peer is admitted) cancels every pending
478
+ * timer for the peer. Timers are unref'd so an armed deadline never holds a
479
+ * process open, and a timer that fires against an already-closed connection is
480
+ * a swallowed no-op.
481
+ *
482
+ * Disarming is final for the connections it cancelled: a peer that reserved
483
+ * once and then lets its reservation lapse keeps a mute connection (the
484
+ * fail-closed stream gates refuse it every members-only protocol) until either
485
+ * side closes it. Bounded — only a peer the reservation policy already admitted
486
+ * can reach that state, so unplaceable peers are bounded by the budget cap.
487
+ */
488
+ class PendingReserveDeadlines {
489
+ private readonly byPeer = new Map<string, Set<PendingReserveDeadline>>();
490
+
491
+ constructor(private readonly deadlineMs: number) {}
492
+
493
+ arm(remotePeerId: string, maConn: MultiaddrConnection): void {
494
+ const entry: PendingReserveDeadline = {
495
+ maConn,
496
+ timer: setTimeout(() => this.expire(remotePeerId, entry), this.deadlineMs)
497
+ };
498
+ (entry.timer as { unref?: () => void }).unref?.();
499
+ let entries = this.byPeer.get(remotePeerId);
500
+ if (!entries) {
501
+ entries = new Set();
502
+ this.byPeer.set(remotePeerId, entries);
503
+ }
504
+ entries.add(entry);
505
+ log('Admitted %s for relay only — dropping the connection unless a reservation is admitted within %dms', remotePeerId, this.deadlineMs);
506
+ }
507
+
508
+ disarm(remotePeerId: string): void {
509
+ const entries = this.byPeer.get(remotePeerId);
510
+ if (!entries) {
511
+ return;
512
+ }
513
+ this.byPeer.delete(remotePeerId);
514
+ for (const entry of entries) {
515
+ clearTimeout(entry.timer);
516
+ }
517
+ }
518
+
519
+ private expire(remotePeerId: string, entry: PendingReserveDeadline): void {
520
+ const entries = this.byPeer.get(remotePeerId);
521
+ entries?.delete(entry);
522
+ if (entries?.size === 0) {
523
+ this.byPeer.delete(remotePeerId);
524
+ }
525
+ log('Relay-only admission expired for %s — no reservation admitted within %dms, aborting the connection', remotePeerId, this.deadlineMs);
526
+ try {
527
+ entry.maConn.abort(new Error(`relay-only admission expired: no relay reservation admitted within ${this.deadlineMs}ms`));
528
+ } catch (error) {
529
+ // Aborting a connection that already closed on its own is not a failure.
530
+ log('Aborting the expired relay-only connection from %s threw: %o', remotePeerId, error);
531
+ }
532
+ }
533
+ }