@serfab/cadre-core 1.14.0 → 1.16.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 (171) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +64 -29
  3. package/dist/cadre-invite-protocol.js +207 -38
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +375 -79
  9. package/dist/cadre-node.js +758 -186
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-database.d.ts +92 -12
  12. package/dist/control-database.js +323 -142
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-schema.d.ts +1 -1
  15. package/dist/control-schema.js +19 -3
  16. package/dist/control-schema.js.map +1 -1
  17. package/dist/ed25519-key.d.ts +7 -9
  18. package/dist/ed25519-key.js +20 -10
  19. package/dist/ed25519-key.js.map +1 -1
  20. package/dist/fetch-impl.d.ts +12 -0
  21. package/dist/fetch-impl.js +23 -0
  22. package/dist/fetch-impl.js.map +1 -0
  23. package/dist/file-durable-slot.js +2 -1
  24. package/dist/file-durable-slot.js.map +1 -1
  25. package/dist/file-safe-name.d.ts +25 -0
  26. package/dist/file-safe-name.js +29 -0
  27. package/dist/file-safe-name.js.map +1 -0
  28. package/dist/formation-approval.js +5 -11
  29. package/dist/formation-approval.js.map +1 -1
  30. package/dist/fs-atomic.d.ts +0 -19
  31. package/dist/fs-atomic.js +3 -24
  32. package/dist/fs-atomic.js.map +1 -1
  33. package/dist/index.d.ts +12 -5
  34. package/dist/index.js +31 -6
  35. package/dist/index.js.map +1 -1
  36. package/dist/invitation-bootstrap.d.ts +50 -12
  37. package/dist/invitation-bootstrap.js +76 -13
  38. package/dist/invitation-bootstrap.js.map +1 -1
  39. package/dist/joined-strand-store.d.ts +7 -0
  40. package/dist/joined-strand-store.js +9 -0
  41. package/dist/joined-strand-store.js.map +1 -1
  42. package/dist/key-store-file.js +2 -1
  43. package/dist/key-store-file.js.map +1 -1
  44. package/dist/link-budget.d.ts +9 -4
  45. package/dist/link-budget.js +9 -4
  46. package/dist/link-budget.js.map +1 -1
  47. package/dist/machine-removal.d.ts +78 -0
  48. package/dist/machine-removal.js +166 -0
  49. package/dist/machine-removal.js.map +1 -0
  50. package/dist/membership-connection-gater.d.ts +6 -5
  51. package/dist/membership-connection-gater.js +6 -5
  52. package/dist/membership-connection-gater.js.map +1 -1
  53. package/dist/node-claim-payload.d.ts +55 -0
  54. package/dist/node-claim-payload.js +108 -4
  55. package/dist/node-claim-payload.js.map +1 -1
  56. package/dist/node-default-transports.d.ts +27 -0
  57. package/dist/node-default-transports.js +35 -0
  58. package/dist/node-default-transports.js.map +1 -0
  59. package/dist/node-transports.d.ts +30 -0
  60. package/dist/node-transports.js +40 -0
  61. package/dist/node-transports.js.map +1 -0
  62. package/dist/peer-dial.d.ts +124 -27
  63. package/dist/peer-dial.js +254 -30
  64. package/dist/peer-dial.js.map +1 -1
  65. package/dist/peer-join-backfill.js +6 -0
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/peer-record.d.ts +6 -45
  68. package/dist/peer-record.js +8 -50
  69. package/dist/peer-record.js.map +1 -1
  70. package/dist/primary-lan-address.d.ts +8 -0
  71. package/dist/primary-lan-address.js +46 -0
  72. package/dist/primary-lan-address.js.map +1 -0
  73. package/dist/provider/client.d.ts +131 -0
  74. package/dist/provider/client.js +226 -0
  75. package/dist/provider/client.js.map +1 -0
  76. package/dist/provider/descriptor.d.ts +41 -0
  77. package/dist/provider/descriptor.js +103 -0
  78. package/dist/provider/descriptor.js.map +1 -0
  79. package/dist/provider/index.d.ts +9 -0
  80. package/dist/provider/index.js +10 -0
  81. package/dist/provider/index.js.map +1 -0
  82. package/dist/provider/oauth.d.ts +125 -0
  83. package/dist/provider/oauth.js +199 -0
  84. package/dist/provider/oauth.js.map +1 -0
  85. package/dist/provider/rent-node.d.ts +101 -0
  86. package/dist/provider/rent-node.js +220 -0
  87. package/dist/provider/rent-node.js.map +1 -0
  88. package/dist/relay-addrs.d.ts +9 -4
  89. package/dist/relay-addrs.js +9 -4
  90. package/dist/relay-addrs.js.map +1 -1
  91. package/dist/relay-reservation.d.ts +51 -16
  92. package/dist/relay-reservation.js +107 -24
  93. package/dist/relay-reservation.js.map +1 -1
  94. package/dist/seed-bootstrap.d.ts +22 -5
  95. package/dist/seed-bootstrap.js +48 -25
  96. package/dist/seed-bootstrap.js.map +1 -1
  97. package/dist/strand-commit-durability.d.ts +54 -0
  98. package/dist/strand-commit-durability.js +74 -0
  99. package/dist/strand-commit-durability.js.map +1 -0
  100. package/dist/strand-database.d.ts +23 -8
  101. package/dist/strand-database.js +41 -13
  102. package/dist/strand-database.js.map +1 -1
  103. package/dist/strand-first-sync-gate.d.ts +7 -1
  104. package/dist/strand-first-sync-gate.js +7 -1
  105. package/dist/strand-first-sync-gate.js.map +1 -1
  106. package/dist/strand-formation-protocol.js +4 -1
  107. package/dist/strand-formation-protocol.js.map +1 -1
  108. package/dist/strand-fret-addrs.d.ts +19 -1
  109. package/dist/strand-fret-addrs.js +27 -9
  110. package/dist/strand-fret-addrs.js.map +1 -1
  111. package/dist/strand-instance-manager.d.ts +81 -19
  112. package/dist/strand-instance-manager.js +216 -105
  113. package/dist/strand-instance-manager.js.map +1 -1
  114. package/dist/strand-move.d.ts +124 -0
  115. package/dist/strand-move.js +438 -0
  116. package/dist/strand-move.js.map +1 -0
  117. package/dist/strand-network-config.js +7 -6
  118. package/dist/strand-network-config.js.map +1 -1
  119. package/dist/strand-peer-redial.d.ts +113 -0
  120. package/dist/strand-peer-redial.js +236 -0
  121. package/dist/strand-peer-redial.js.map +1 -0
  122. package/dist/strand-wake-protocol.js +10 -7
  123. package/dist/strand-wake-protocol.js.map +1 -1
  124. package/dist/types.d.ts +212 -4
  125. package/dist/types.js +10 -0
  126. package/dist/types.js.map +1 -1
  127. package/package.json +18 -10
  128. package/src/cadre-invite-protocol.ts +1052 -864
  129. package/src/cadre-join-request.ts +167 -0
  130. package/src/cadre-node.ts +10003 -9381
  131. package/src/control-database.ts +4663 -4451
  132. package/src/control-schema.ts +19 -3
  133. package/src/ed25519-key.ts +20 -10
  134. package/src/fetch-impl.ts +23 -0
  135. package/src/file-durable-slot.ts +64 -63
  136. package/src/file-safe-name.ts +32 -0
  137. package/src/formation-approval.ts +604 -610
  138. package/src/fs-atomic.ts +92 -117
  139. package/src/index.ts +71 -4
  140. package/src/invitation-bootstrap.ts +91 -13
  141. package/src/joined-strand-store.ts +508 -498
  142. package/src/key-store-file.ts +142 -141
  143. package/src/link-budget.ts +9 -4
  144. package/src/machine-removal.ts +216 -0
  145. package/src/membership-connection-gater.ts +638 -637
  146. package/src/node-claim-payload.ts +138 -4
  147. package/src/node-default-transports.ts +38 -0
  148. package/src/node-transports.ts +49 -0
  149. package/src/peer-dial.ts +604 -336
  150. package/src/peer-join-backfill.ts +6 -0
  151. package/src/peer-record.ts +159 -202
  152. package/src/primary-lan-address.ts +47 -0
  153. package/src/provider/client.ts +326 -0
  154. package/src/provider/descriptor.ts +129 -0
  155. package/src/provider/index.ts +59 -0
  156. package/src/provider/oauth.ts +285 -0
  157. package/src/provider/rent-node.ts +275 -0
  158. package/src/relay-addrs.ts +9 -4
  159. package/src/relay-reservation.ts +1211 -1121
  160. package/src/seed-bootstrap.ts +1502 -1471
  161. package/src/strand-commit-durability.ts +116 -0
  162. package/src/strand-database.ts +46 -14
  163. package/src/strand-first-sync-gate.ts +7 -1
  164. package/src/strand-formation-protocol.ts +1039 -1036
  165. package/src/strand-fret-addrs.ts +179 -145
  166. package/src/strand-instance-manager.ts +2026 -1883
  167. package/src/strand-move.ts +585 -0
  168. package/src/strand-network-config.ts +7 -6
  169. package/src/strand-peer-redial.ts +307 -0
  170. package/src/strand-wake-protocol.ts +10 -7
  171. package/src/types.ts +220 -4
@@ -1,637 +1,638 @@
1
- /**
2
- * Control-network inbound admission gates: the encrypted-connection checkpoint
3
- * and the circuit-relay reservation checkpoint.
4
- *
5
- * The fail-closed layer of the membership enforcement chain is per-stream: the
6
- * control node's protocol guard (`control-protocol-guard.ts`) refuses a peer that
7
- * is not an authorized member on every protocol not declared open to strangers,
8
- * before its handler runs, and the replicated rows an outsider *can* still write
9
- * are disbelieved at read time. This module is NOT a stranger boundary on an
10
- * enrolled node — outside the bring-up quiet period it admits every inbound
11
- * connection. Its jobs are:
12
- *
13
- * - the bring-up quiet period (below), the one state in which it denies;
14
- * - deciding which admitted peers carry a deadline: a peer the policy cannot
15
- * place is admitted PROVISIONALLY, and its connection is closed at the
16
- * deadline unless the policy admits it by then (see "Provisional admission");
17
- * - handing circuit-relay reservations to the reservation policy (see "The
18
- * relay-reservation seam").
19
- *
20
- * Why a stranger's connection is admitted at all: the gate sees only a peer id,
21
- * after the encrypted handshake and before any protocol, so it cannot tell an
22
- * outsider from a device carrying a cadre invitation this node has not received
23
- * yet, or from a sibling whose membership row is still replicating. Refusing
24
- * would fail those at the connection; admitting costs a mute connection for a
25
- * bounded time, because the protocol guard refuses it every members-only protocol.
26
- *
27
- * ## Admitted outright
28
- *
29
- * A peer the policy CAN place gets no deadline. Besides members, configured
30
- * infrastructure and an un-enrolled node's whole world (empty anchor or empty
31
- * authorized set), that covers the stranger windows: states in which this node
32
- * expects a stranger on one of the `stranger-open` protocols declared in
33
- * `controlProtocolClasses` (`control-protocol-guard.ts`):
34
- *
35
- * - `/sereus/seed/1.0.0` — enrollment seed delivery.
36
- * An owner dials a brand-new node to seed it (the new node has no members
37
- * yet, so it admits everyone outright). The handler's own trust decision is the
38
- * anchored seed-trust policy — or, on a node started with a claim secret
39
- * (`CadreNodeConfig.claim`), the claim-secret policy. Such a node is the one
40
- * stranger-facing state that is NOT "admit the connection and let the
41
- * per-stream gates sort it out": while it waits to be claimed
42
- * (`CadreNode.isAwaitingClaim`) its connection is admitted, because the claim
43
- * seed has to ride one, but its control-DB streams and relay reservations are
44
- * refused ahead of every admission below, the empty-anchor one included. An
45
- * unclaimed node has no siblings to replicate from, and one on a public
46
- * address must not relay for anyone. The claim anchors the owner, after which
47
- * the ordinary rules apply.
48
- * - `/sereus/formation/1.0.0` — cross-party
49
- * strand formation via open invitations. Stranger-facing BY DESIGN: the
50
- * initiator is another party, and its token is only checkable inside the
51
- * protocol. The window is keyed on EXPECTATION of a stranger, not capability
52
- * to serve one: it is open only while this node has at least one UNEXPIRED,
53
- * NOT-FULLY-CONSUMED open invitation outstanding
54
- * (`StrandSolicitationService.hasOutstandingInvitation` — the tokens this
55
- * process minted or published, plus any still-redeemable `FormationInvite`
56
- * row the usage recorder can see). Registering the responder does NOT open
57
- * it, so on every node — each registers one at `CadreNode.start` — a stranger
58
- * with no invitation in play stays on the provisional deadline. The
59
- * handler's own trust decision remains the per-token check, which is
60
- * strictly finer than this one: a peer admitted here can still be rejected
61
- * in-protocol for a bogus or spent token.
62
- * - `/sereus/cadre-invite/1.0.0` — redemption
63
- * of a cadre invitation at any member machine. Stranger-facing BY DESIGN:
64
- * the device is not a member until the redemption writes its row, and its
65
- * proof of possession (a signature with the invitation's private key) is
66
- * only checkable inside the protocol. Keyed on expectation, as formation's
67
- * is: open only while this node holds at least one LIVE `CadreInvite` row
68
- * (`ControlDatabase.hasLiveCadreInvite`: not withdrawn, unexpired, uses left,
69
- * issuer still an owner). A member that has not yet received the device's
70
- * row by replication admits the device provisionally instead, seats the row
71
- * from the invitation bundle during the redemption, and so admits it at the
72
- * deadline's re-check.
73
- *
74
- * There is one further connection-level carve-out, which is NOT a protocol
75
- * window:
76
- *
77
- * - **Announced delegate peers** (`delegate-admission.ts`). A member's strand
78
- * node runs as a separate libp2p identity whose peerId no sibling can
79
- * recompute. Before starting a strand node, a member's control node
80
- * announces that peerId over the already-authenticated strand-addr RPC, and
81
- * the receiver holds a short-lived grant for it
82
- * (`CadreNode.grantDelegateAdmission`). The grant admits the CONNECTION
83
- * outright, admits the peer's RESERVATION at the seam below (without
84
- * spending the unauthorized budget), and nothing else — it is deliberately
85
- * invisible to the protocol guard, so a delegate still gets refused on every
86
- * members-only protocol.
87
- *
88
- * ## Provisional admission
89
- *
90
- * A peer the policy cannot place (`'admit-provisionally'`) gets its connection
91
- * and a deadline — {@link PROVISIONAL_ADMISSION_DEADLINE_MS} at the default
92
- * declaration; `CadreNode` passes the one derived from its OWN declared link,
93
- * because the member is the machine that decides. At the deadline the gate asks
94
- * the policy again:
95
- *
96
- * - `'admit'` — the peer has since become a member, holds a delegate grant, or
97
- * this node now holds a live invitation (a redeeming device's row was
98
- * seated): the connection stays, and nothing is re-armed;
99
- * - `'admit-provisionally'` — the connection is closed (a bounded graceful
100
- * close, with an abort only as the escalation);
101
- * - the question throws or outlasts the decision deadline — the connection
102
- * stays (see "Fail-open, deliberately").
103
- *
104
- * A connection that is already gone by then costs no question. One peer may hold
105
- * several provisional connections; each deadline re-asks on its own.
106
- *
107
- * NOTE: the gate does not stop a provisionally admitted stranger from being
108
- * counted into this node's FRET ring and control-database cohort by opening or
109
- * advertising the party's protocols — FRET and Optimystic decide that from the
110
- * protocols libp2p records the peer as serving. Ticket
111
- * `stranger-joins-the-control-cohort-by-advertising-protocols` (blocked).
112
- *
113
- * ## The relay-reservation seam
114
- *
115
- * A circuit-relay reservation is established by the reserving peer DIALING the
116
- * relay, so at the relay it rides an inbound connection — and closing that
117
- * connection kills the reservation. For a genuine member whose `CadrePeer` row
118
- * has not yet replicated to this node that would NOT be self-healing the way a
119
- * data connection's close is: an outbound reconcile dial re-establishes a data
120
- * link, but no outbound dial can grant the REMOTE peer a reservation, and a
121
- * relay-only peer has no address of its own to dial back — the reservation IS
122
- * its address. Boot ordering makes the window ordinary (a node reserves at the
123
- * end of its own `start()`, before its row could have replicated anywhere), and
124
- * a stalled replication makes it unbounded. So the reservation is decided on its
125
- * own terms, not by the connection's:
126
- *
127
- * - It is decided at libp2p's `denyInboundRelayReservation` hook (the
128
- * circuit-relay server consults it per RESERVE request): the policy admits
129
- * members, delegates and configured infra outright, and admits peers it
130
- * cannot place only within a bounded budget
131
- * ({@link UnauthorizedReservationBudget}) — a member whose row is in flight
132
- * always finds a slot under any sane cap, while outsiders cannot annex the
133
- * party's relay capacity. COUNT and lifetime are the only bounds on such a
134
- * peer: a party-run relay forwards without libp2p's per-connection data and
135
- * duration limit by default (`relay-server.ts`), so what a granted slot
136
- * carries is not capped.
137
- * - An ADMITTED reservation disarms every provisional deadline the peer holds
138
- * here, so its connection outlives the deadline even while the peer is still
139
- * unplaced. A refused one disarms nothing.
140
- *
141
- * The deadline clears on reservation ADMISSION, not on the reservation's own
142
- * success: this gate cannot observe the server-side `reserve()` outcome, so a
143
- * peer whose admitted reservation is then refused for server capacity keeps its
144
- * connection until either side closes it — bounded and mute, so harmless.
145
- *
146
- * Everything else a control node serves is either libp2p plumbing open to any
147
- * connection (identify, ping, hole punching, AutoNAT, the relay protocols) or
148
- * members-only — wake, strand-addr, the Optimystic control-DB protocols and FRET —
149
- * and the fail-closed layer for all of it is the protocol guard
150
- * (`control-protocol-guard.ts`), one seam that wraps every handler at dispatch,
151
- * with a protocol nobody classed treated as members-only. The two layers
152
- * complement, not duplicate: this connection gate is fail-open over a live DB
153
- * read and decides only how long a connection lasts, the protocol guard is
154
- * fail-closed and has NO stranger carve-outs — a live invitation admits a
155
- * stranger's connection outright for redemption, yet its repo and FRET streams
156
- * are still refused.
157
- *
158
- * ## The bring-up quiet period
159
- *
160
- * One state is decided BEFORE any of the above, and is not about the remote peer
161
- * at all: while this node's control-database bring-up is in flight
162
- * (`InboundAdmissionPolicy.bringUpInFlight`), the gate denies BOTH directions —
163
- * `denyDialPeer` and `denyInboundEncryptedConnection` — and then opens.
164
- *
165
- * The invariant it protects is "`ControlDatabase.initialize()` runs while this
166
- * node holds ZERO control connections". Building that database is a long chain
167
- * of cohort-consulting block probes, and every connected same-party sibling is in
168
- * the cohort those probes consult. A sibling that has not yet replicated this
169
- * node's `CadrePeer` row correctly refuses them at its own fail-closed per-stream
170
- * gate, so ONE connection in this window turns bring-up into
171
- * `BlockUnavailableError` and `start()` rejects. Retrying cannot converge: the
172
- * condition that would clear the refusal is this node's own row reaching the
173
- * sibling, and writing that row needs the database the retry is building. So the
174
- * only fix is to not be in the conversation yet — which is what this window is.
175
- *
176
- * The ordering is arranged so nothing SHOULD open a connection here anyway:
177
- * `network.relayAddrs` resolves to a listener that dials nothing and reserves
178
- * after bring-up (`relay-addrs.ts`), `controlNetwork.bootstrapNodes` is dialed
179
- * after bring-up (`CadreNode.dialControlBootstrapPeers`), and the control-cohort
180
- * reconcile pass is scheduled post-start. This window is what makes that a property
181
- * rather than an accident of ordering — the live cases it catches are peers the node
182
- * remembers from a previous run, which the connection manager auto-dials, and
183
- * inbound dials.
184
- *
185
- * A denial here costs a retry, not a partition: libp2p's connection manager
186
- * re-dials on its auto-dial cadence, the reservation supervisor re-drives, and a
187
- * denied inbound peer reconnects. The window opens on bring-up FAILURE too (via
188
- * `CadreNode.cleanup`), so teardown is never gated.
189
- *
190
- * ## Fail-open, deliberately
191
- *
192
- * Outside that window this layer never denies, and it closes a provisional
193
- * connection only on a positive answer that the peer is still unplaced. Any
194
- * error, missing dependency, timeout or ambiguous state admits the connection
195
- * outright (and the reservation), or keeps a provisional connection at its
196
- * deadline, and defers to the fail-closed stream gates — a DB hiccup must not
197
- * partition a legitimate cadre.
198
- */
199
-
200
- import debug from 'debug';
201
- import type { ConnectionGater, PeerId, MultiaddrConnection } from '@libp2p/interface';
202
- import { withDeadline } from './control-stream.js';
203
- import { PARTY_RELAY_RESERVATION_TTL_MS } from './relay-server.js';
204
- import { ADMISSION_DECISION_TIMEOUT_MS, relayedRequestBudgetMs } from './link-budget.js';
205
-
206
- const log = debug('sereus:cadre:connection-gater');
207
-
208
- /**
209
- * How long a provisionally admitted connection lasts before the gate re-asks the
210
- * policy and closes it unless the peer is admissible by then (see the module
211
- * doc's "Provisional admission").
212
- *
213
- * {@link relayedRequestBudgetMs}: 28 500 ms at the default declaration. The
214
- * longest exchange a stranger legitimately starts on a fresh connection is a
215
- * cadre invitation redemption, and the device abandons each member address after
216
- * that same budget of its own declared link (`redeemAtMembers`): it covers the
217
- * dial, the request, the member's catch-up push, the seat and the redemption. So
218
- * a member never cuts an exchange the device is still waiting on, and a
219
- * reservation request lands well inside it. This is the value at the default
220
- * declaration; `CadreNode` passes the one derived from its OWN declaration.
221
- *
222
- * NOTE: accepted tradeoff — any stranger can hold a mute connection to any
223
- * enrolled member for this long, weighed against letting a device redeem an
224
- * invitation at a member that has not yet received its row (maintainer,
225
- * 2026-10-07, plan `cadre-invite-redeemable-before-the-row-replicates`). It can
226
- * open only stranger-open and transport streams (the protocol guard), and
227
- * libp2p's connection-manager limits bound how many it holds. Revisit if stranger
228
- * connections show up as load on a member.
229
- */
230
- export const PROVISIONAL_ADMISSION_DEADLINE_MS = relayedRequestBudgetMs();
231
-
232
- /**
233
- * Bound on the graceful close of an expired provisional connection. It exists
234
- * because `AbstractMultiaddrConnection.close()` awaits an `idle`/`drain` event
235
- * when the connection still has unsent bytes, and that wait has no timeout of
236
- * its own — an unsignalled one never ends. This gate writes nothing to a
237
- * stranger, so the wait is not reachable from here; the bound is here so a timer
238
- * callback can never hold an unending await.
239
- *
240
- * Applied through {@link withDeadline}, not `AbortSignal.timeout`: the latter is
241
- * not reliably present on React Native/Hermes, which loads this same module.
242
- */
243
- // eslint-disable-next-line no-restricted-syntax -- link-independent: bounds a local connection close inside a timer callback, and expiry aborts the connection instead
244
- export const PROVISIONAL_ADMISSION_CLOSE_TIMEOUT_MS = 2_000;
245
-
246
- /**
247
- * Default cap on concurrent relay reservations held by peers the membership
248
- * check could not place (see {@link UnauthorizedReservationBudget}). Small on
249
- * purpose: it exists for the handful of genuine members whose rows are still
250
- * in flight, not as public relay capacity. Overridable per node via
251
- * `network.unauthorizedRelayReservationCap` (0 refuses every unauthorized
252
- * reservation — the strict pre-seam posture).
253
- *
254
- * NOTE: this cap shares the relay server's own reservation store, whose
255
- * party-run default size is `PARTY_RELAY_MAX_RESERVATIONS` (128,
256
- * `relay-server.ts`); unplaced peers may therefore occupy up to this many of
257
- * those slots, and the rest are what members and delegates compete for. The two
258
- * are kept apart by hand — if this is raised, or an embedder shrinks the store
259
- * through `network.relayServerInit.reservations.maxReservations`, keep this one
260
- * well under the store's, or a fleet of unplaceable peers can crowd genuine
261
- * members out of the server's own store (which the gate cannot override).
262
- */
263
- export const MAX_UNAUTHORIZED_RELAY_RESERVATIONS = 8;
264
-
265
- /**
266
- * The connection-level outcome of the admission policy:
267
- * - `'admit'` — a peer with a legitimate claim on the connection (member,
268
- * delegate, infra, open stranger window, or any fail-open state); no deadline.
269
- * - `'admit-provisionally'` — a peer the policy cannot place (an outsider, a
270
- * device whose invitation row this node does not hold yet, or a member whose
271
- * row is still replicating — indistinguishable here): admit the connection,
272
- * and at the deadline ask again and close it unless the answer is `'admit'`.
273
- *
274
- * There is no deny: the gate's only denial is the bring-up quiet period, which
275
- * is not a judgement of the peer.
276
- */
277
- export type InboundConnectionVerdict = 'admit' | 'admit-provisionally';
278
-
279
- /**
280
- * The admission decisions the gate defers to — implemented by
281
- * `CadreNode.admitInboundControlConnection` /
282
- * `CadreNode.admitControlRelayReservation` (enrollment windows, anchor state,
283
- * bootstrap infra, delegate grants, the authorized-member set, the
284
- * unauthorized-reservation budget). Kept injectable so the gater's
285
- * composition/fail-open behavior is unit-testable without a full node.
286
- */
287
- export interface InboundAdmissionPolicy {
288
- /**
289
- * Verdict on an inbound encrypted connection from this peer. Asked again at a
290
- * provisional connection's deadline, so it must answer from current state.
291
- */
292
- admitInbound(remotePeerId: string): Promise<InboundConnectionVerdict> | InboundConnectionVerdict;
293
- /** Should this peer be granted a circuit-relay reservation slot? */
294
- admitRelayReservation(remotePeerId: string): Promise<boolean> | boolean;
295
- /**
296
- * True while this node's control-database bring-up is in flight — the
297
- * {@link createMembershipConnectionGater} quiet period (see the module doc).
298
- * Peer-independent, unlike the two decisions above, and SYNCHRONOUS: it is
299
- * read on the outbound-dial path, where an await would be a new stall.
300
- *
301
- * Optional — a policy that omits it is never quiet, which is the right default
302
- * for every caller that is not a booting `CadreNode`.
303
- */
304
- bringUpInFlight?(): boolean;
305
- }
306
-
307
- /**
308
- * Bounded budget of concurrent relay reservations for peers the membership
309
- * check could not place. `tryAdmit` is the whole surface: an already-admitted
310
- * peer refreshes its entry (a reservation refresh never double-counts), a new
311
- * peer takes a free slot or is refused. Entries expire after `ttlMs`, which
312
- * `CadreNode` passes as the relay server's own resolved `reservationTtl`
313
- * (`relay-server.ts`): the server holds an unrefreshed reservation exactly that
314
- * long, and a live reserver re-requests (re-hitting the admission hook,
315
- * refreshing its entry) well before expiry — so the live entry count tracks the
316
- * server's own occupancy without this module reaching into the server's
317
- * reservation store. Injectable `now` keeps expiry testable without fake
318
- * timers. Authorized members and delegates are never run through this — the
319
- * policy admits them before consulting the budget, and `release`s the slot such
320
- * a peer took while it was still unplaceable, so the boot-ordering window a
321
- * member passes through costs the budget nothing once its row lands.
322
- */
323
- export class UnauthorizedReservationBudget {
324
- private readonly admitted = new Map<string, number>();
325
-
326
- constructor(
327
- /** Most peers admitted at once — `network.unauthorizedRelayReservationCap`, resolved. */
328
- readonly cap: number = MAX_UNAUTHORIZED_RELAY_RESERVATIONS,
329
- private readonly ttlMs: number = PARTY_RELAY_RESERVATION_TTL_MS
330
- ) {}
331
-
332
- /** Number of live (unexpired at last prune) entries — test/diagnostic surface. */
333
- get size(): number {
334
- return this.admitted.size;
335
- }
336
-
337
- /** Admit (or refresh) `remotePeerId` if a slot is free; false when the cap is spent. */
338
- tryAdmit(remotePeerId: string, now: number = Date.now()): boolean {
339
- this.prune(now);
340
- if (!this.admitted.has(remotePeerId) && this.admitted.size >= this.cap) {
341
- log('Unauthorized-reservation budget spent (%d/%d) — refusing %s', this.admitted.size, this.cap, remotePeerId);
342
- return false;
343
- }
344
- this.admitted.set(remotePeerId, now + this.ttlMs);
345
- return true;
346
- }
347
-
348
- /**
349
- * Give back the slot `remotePeerId` holds, if any — called when the peer has
350
- * become admissible on its own merits (its membership row replicated, or a
351
- * delegate grant landed), so the slot it took during the boot-ordering window
352
- * does not stay spent for the remaining TTL on a peer that no longer needs it.
353
- */
354
- release(remotePeerId: string): void {
355
- this.admitted.delete(remotePeerId);
356
- }
357
-
358
- private prune(now: number): void {
359
- for (const [peerId, expiresAt] of this.admitted) {
360
- if (expiresAt <= now) {
361
- this.admitted.delete(peerId);
362
- }
363
- }
364
- }
365
- }
366
-
367
- /**
368
- * Build the control node's connection gater: the caller-supplied gater (if any)
369
- * with membership admission composed onto `denyInboundEncryptedConnection` —
370
- * the earliest checkpoint where the remote's authenticated PeerId is known —
371
- * and reservation admission composed onto `denyInboundRelayReservation` — the
372
- * hook the circuit-relay server consults per RESERVE request (inert on a node
373
- * whose relay server is off; libp2p never calls it there).
374
- *
375
- * `denyDialPeer` is composed too, but ONLY for the bring-up quiet period (see
376
- * the module doc): outside that window outbound dials are never gated by
377
- * membership — this node decides for itself who to talk to.
378
- *
379
- * Composition semantics: every hook of `base` is preserved as-is; on the three
380
- * composed hooks a deny from the base gater denies, and so does the quiet
381
- * period. The admission policy never denies a connection. A policy error — or a
382
- * decision slower than `decisionTimeoutMs` — takes the fail-open outcome
383
- * (connection admitted outright / reservation admitted, see module doc); the
384
- * base gater's verdict is still honored first.
385
- *
386
- * An `'admit-provisionally'` verdict admits the connection and arms a
387
- * `provisionalDeadlineMs` timer against it. When the timer fires the policy is
388
- * asked again, and the underlying `MultiaddrConnection` is closed unless it now
389
- * answers `'admit'`; a reservation for that peer ADMITTED at the reservation
390
- * hook disarms the timer first.
391
- *
392
- * NOTE: `base` is spread, so a gater passed as a CLASS INSTANCE would lose its
393
- * prototype methods; every caller in this repo (and libp2p's own default)
394
- * supplies a plain object. If a class-based gater ever shows up, delegate
395
- * per-hook instead of spreading.
396
- *
397
- * Deny timing, as observed by the denied dialer (a quiet-period or base-gater
398
- * deny): noise negotiates the muxer in the security handshake's early data, so
399
- * the DIALER's upgrade may complete (its `dial()` resolves) before this
400
- * receiver-side hook runs. The deny then aborts the receiver's upgrade — the
401
- * receiver never registers the connection and never creates its muxer, so no
402
- * protocol can ever be negotiated — and the dialer sees its "open" connection
403
- * close moments later.
404
- *
405
- * Control node only: strand cohort nodes legitimately connect cross-party
406
- * peers, so cadre membership never gates them. An OPEN strand's node receives
407
- * the raw configured gater unchanged; a CLOSED strand's node composes its own
408
- * revoked-peer denial onto it instead — a deny-list on positive revocation
409
- * evidence, not this module's membership admission — see
410
- * `strand-revocation-enforcer.ts`.
411
- */
412
- export function createMembershipConnectionGater(
413
- policy: InboundAdmissionPolicy,
414
- base?: ConnectionGater,
415
- decisionTimeoutMs: number = ADMISSION_DECISION_TIMEOUT_MS,
416
- provisionalDeadlineMs: number = PROVISIONAL_ADMISSION_DEADLINE_MS
417
- ): ConnectionGater {
418
- const decideInbound = (remotePeerId: string): Promise<InboundConnectionVerdict> => decideWithinDeadline(
419
- () => policy.admitInbound(remotePeerId), 'admit', decisionTimeoutMs, `admitInbound(${remotePeerId})`
420
- );
421
- const provisional = new ProvisionalAdmissions(provisionalDeadlineMs, decideInbound);
422
- const quiet = (): boolean => policy.bringUpInFlight?.() ?? false;
423
- return {
424
- ...base,
425
- denyDialPeer: async (peerId: PeerId): Promise<boolean> => {
426
- if (await base?.denyDialPeer?.(peerId)) {
427
- return true;
428
- }
429
- if (quiet()) {
430
- log('Control-database bring-up in flight — refusing the outbound dial to %s', peerId.toString());
431
- return true;
432
- }
433
- return false;
434
- },
435
- denyInboundEncryptedConnection: async (peerId: PeerId, maConn: MultiaddrConnection): Promise<boolean> => {
436
- if (await base?.denyInboundEncryptedConnection?.(peerId, maConn)) {
437
- return true;
438
- }
439
- if (quiet()) {
440
- log('Control-database bring-up in flight — refusing the inbound connection from %s', peerId.toString());
441
- return true;
442
- }
443
- const remotePeerId = peerId.toString();
444
- let verdict: InboundConnectionVerdict;
445
- try {
446
- verdict = await decideInbound(remotePeerId);
447
- } catch (error) {
448
- log('admitInbound threw for %s — admitting (fail-open; stream gates decide): %o', remotePeerId, error);
449
- return false;
450
- }
451
- if (verdict === 'admit-provisionally') {
452
- provisional.arm(remotePeerId, maConn);
453
- }
454
- return false;
455
- },
456
- denyInboundRelayReservation: async (peerId: PeerId): Promise<boolean> => {
457
- if (await base?.denyInboundRelayReservation?.(peerId)) {
458
- return true;
459
- }
460
- const remotePeerId = peerId.toString();
461
- let admitted: boolean;
462
- try {
463
- admitted = await decideWithinDeadline(
464
- () => policy.admitRelayReservation(remotePeerId), true, decisionTimeoutMs, `admitRelayReservation(${remotePeerId})`
465
- );
466
- } catch (error) {
467
- log('admitRelayReservation threw for %s — admitting (fail-open): %o', remotePeerId, error);
468
- admitted = true;
469
- }
470
- if (admitted) {
471
- provisional.disarm(remotePeerId);
472
- }
473
- return !admitted;
474
- }
475
- };
476
- }
477
-
478
- /**
479
- * Run one admission decision under `timeoutMs`, resolving to `fallback` (the
480
- * fail-open outcome) if it has not settled in time — see
481
- * {@link ADMISSION_DECISION_TIMEOUT_MS} for why an unbounded await is not safe
482
- * on the connection hook. The timer is always cleared so a decided call never
483
- * holds the event loop open. Exported for the strand revoked-peer gate
484
- * (`strand-revocation-enforcer.ts`), which bounds its connection hooks with the
485
- * same fail-open contract.
486
- */
487
- export async function decideWithinDeadline<T>(
488
- decide: () => Promise<T> | T,
489
- fallback: T,
490
- timeoutMs: number,
491
- what: string
492
- ): Promise<T> {
493
- let timer: ReturnType<typeof setTimeout> | undefined;
494
- try {
495
- return await Promise.race([
496
- Promise.resolve().then(decide),
497
- new Promise<T>((resolve) => {
498
- timer = setTimeout(() => {
499
- log('%s exceeded %dms — taking the fail-open outcome (stream gates decide)', what, timeoutMs);
500
- resolve(fallback);
501
- }, timeoutMs);
502
- })
503
- ]);
504
- } finally {
505
- clearTimeout(timer);
506
- }
507
- }
508
-
509
- /** One armed provisional deadline: the connection it may close, and its timer. */
510
- interface ProvisionalAdmission {
511
- maConn: MultiaddrConnection;
512
- timer: ReturnType<typeof setTimeout>;
513
- }
514
-
515
- /**
516
- * The deadlines of `'admit-provisionally'` connections, keyed by remote peerId
517
- * (a Set per peer — one peer can hold several in-flight connections). `arm`
518
- * starts a timer that re-asks the policy and closes the connection unless it now
519
- * admits; `disarm` (called when a reservation for that peer is admitted) cancels
520
- * every pending deadline for the peer, one whose question is already out
521
- * included. Timers are unref'd so an armed deadline never holds a process open.
522
- *
523
- * Disarming is final for the connections it cancelled: a peer that reserved
524
- * once and then lets its reservation lapse keeps a mute connection (the
525
- * fail-closed stream gates refuse it every members-only protocol) until either
526
- * side closes it. Bounded — only a peer the reservation policy already admitted
527
- * can reach that state, so unplaceable peers are bounded by the budget cap.
528
- */
529
- class ProvisionalAdmissions {
530
- private readonly byPeer = new Map<string, Set<ProvisionalAdmission>>();
531
-
532
- constructor(
533
- private readonly deadlineMs: number,
534
- private readonly decide: (remotePeerId: string) => Promise<InboundConnectionVerdict>
535
- ) {}
536
-
537
- arm(remotePeerId: string, maConn: MultiaddrConnection): void {
538
- const entry: ProvisionalAdmission = {
539
- maConn,
540
- timer: setTimeout(() => void this.expire(remotePeerId, entry), this.deadlineMs)
541
- };
542
- (entry.timer as { unref?: () => void }).unref?.();
543
- let entries = this.byPeer.get(remotePeerId);
544
- if (!entries) {
545
- entries = new Set();
546
- this.byPeer.set(remotePeerId, entries);
547
- }
548
- entries.add(entry);
549
- log('Admitted %s provisionally — closing the connection in %dms unless it is admissible by then', remotePeerId, this.deadlineMs);
550
- }
551
-
552
- disarm(remotePeerId: string): void {
553
- const entries = this.byPeer.get(remotePeerId);
554
- if (!entries) {
555
- return;
556
- }
557
- this.byPeer.delete(remotePeerId);
558
- for (const entry of entries) {
559
- clearTimeout(entry.timer);
560
- }
561
- }
562
-
563
- private async expire(remotePeerId: string, entry: ProvisionalAdmission): Promise<void> {
564
- if (entry.maConn.status !== 'open') {
565
- this.release(remotePeerId, entry);
566
- return;
567
- }
568
- const verdict = await this.recheck(remotePeerId);
569
- // False when a reservation admitted while the question was out disarmed this deadline.
570
- if (!this.release(remotePeerId, entry)) {
571
- return;
572
- }
573
- if (verdict === 'admit') {
574
- log('%s is admissible at its provisional deadline — keeping the connection', remotePeerId);
575
- return;
576
- }
577
- log('Provisional admission expired for %s — still unplaced after %dms, closing the connection', remotePeerId, this.deadlineMs);
578
- await this.drop(remotePeerId, entry.maConn);
579
- }
580
-
581
- /** The policy's answer at the deadline; a throw keeps the connection (fail-open). */
582
- private async recheck(remotePeerId: string): Promise<InboundConnectionVerdict> {
583
- try {
584
- return await this.decide(remotePeerId);
585
- } catch (error) {
586
- log('admitInbound threw for %s at its provisional deadline — keeping the connection (fail-open): %o', remotePeerId, error);
587
- return 'admit';
588
- }
589
- }
590
-
591
- /** Forget `entry`; false when a `disarm` already took it. */
592
- private release(remotePeerId: string, entry: ProvisionalAdmission): boolean {
593
- const entries = this.byPeer.get(remotePeerId);
594
- if (!entries?.delete(entry)) {
595
- return false;
596
- }
597
- if (entries.size === 0) {
598
- this.byPeer.delete(remotePeerId);
599
- }
600
- return true;
601
- }
602
-
603
- /**
604
- * End an expired provisional connection: a bounded graceful close, with
605
- * `abort()` only as the escalation. The order cannot be reversed — `abort()`
606
- * marks the connection `aborted`, and `close()` returns immediately on any
607
- * status that is not `open`, so aborting first would make the close a silent
608
- * no-op. Closing a connection the peer already dropped returns at once
609
- * without throwing, so the fallback stays unreached in the ordinary case.
610
- */
611
- private async drop(remotePeerId: string, maConn: MultiaddrConnection): Promise<void> {
612
- try {
613
- await withDeadline(
614
- PROVISIONAL_ADMISSION_CLOSE_TIMEOUT_MS,
615
- `provisional close for ${remotePeerId}`,
616
- (signal) => maConn.close({ signal })
617
- );
618
- return;
619
- } catch (error) {
620
- log('Closing the expired provisional connection from %s failed — aborting: %o', remotePeerId, error);
621
- }
622
- // NOTE: this fallback cannot actually free a WebSocket. `@libp2p/websockets`'
623
- // `sendReset()` calls `websocket.close(1006)`, and 1006 is a reserved code
624
- // RFC 6455 forbids an endpoint from sending, so `ws` throws and
625
- // `AbstractMessageStream.abort()` swallows it — the local end flips to
626
- // `aborted` while the socket stays up. Measured against
627
- // @libp2p/websockets@10.1.3 (see tickets/blocked/report-libp2p-websockets-abort-close-code.md).
628
- // `MultiaddrConnection` exposes no way down to the raw socket, so this is as
629
- // far as this layer can go. Revisit — drop the fallback, or go back to a
630
- // plain `abort()` — once that transport sends a legal reset code.
631
- try {
632
- maConn.abort(new Error(`provisional admission expired: still unplaced after ${this.deadlineMs}ms`));
633
- } catch (error) {
634
- log('Aborting the expired provisional connection from %s threw: %o', remotePeerId, error);
635
- }
636
- }
637
- }
1
+ /**
2
+ * Control-network inbound admission gates: the encrypted-connection checkpoint
3
+ * and the circuit-relay reservation checkpoint.
4
+ *
5
+ * The fail-closed layer of the membership enforcement chain is per-stream: the
6
+ * control node's protocol guard (`control-protocol-guard.ts`) refuses a peer that
7
+ * is not an authorized member on every protocol not declared open to strangers,
8
+ * before its handler runs, and the replicated rows an outsider *can* still write
9
+ * are disbelieved at read time. This module is NOT a stranger boundary on an
10
+ * enrolled node — outside the bring-up quiet period it admits every inbound
11
+ * connection. Its jobs are:
12
+ *
13
+ * - the bring-up quiet period (below), the one state in which it denies;
14
+ * - deciding which admitted peers carry a deadline: a peer the policy cannot
15
+ * place is admitted PROVISIONALLY, and its connection is closed at the
16
+ * deadline unless the policy admits it by then (see "Provisional admission");
17
+ * - handing circuit-relay reservations to the reservation policy (see "The
18
+ * relay-reservation seam").
19
+ *
20
+ * Why a stranger's connection is admitted at all: the gate sees only a peer id,
21
+ * after the encrypted handshake and before any protocol, so it cannot tell an
22
+ * outsider from a device carrying a cadre invitation this node has not received
23
+ * yet, or from a sibling whose membership row is still replicating. Refusing
24
+ * would fail those at the connection; admitting costs a mute connection for a
25
+ * bounded time, because the protocol guard refuses it every members-only protocol.
26
+ *
27
+ * ## Admitted outright
28
+ *
29
+ * A peer the policy CAN place gets no deadline. Besides members, configured
30
+ * infrastructure and an un-enrolled node's whole world (empty anchor or empty
31
+ * authorized set), that covers the stranger windows: states in which this node
32
+ * expects a stranger on one of the `stranger-open` protocols declared in
33
+ * `controlProtocolClasses` (`control-protocol-guard.ts`):
34
+ *
35
+ * - `/sereus/seed/1.0.0` — enrollment seed delivery.
36
+ * An owner dials a brand-new node to seed it (the new node has no members
37
+ * yet, so it admits everyone outright). The handler's own trust decision is the
38
+ * anchored seed-trust policy — or, on a node started with a claim secret
39
+ * (`CadreNodeConfig.claim`), the claim-secret policy. Such a node is the one
40
+ * stranger-facing state that is NOT "admit the connection and let the
41
+ * per-stream gates sort it out": while it waits to be claimed
42
+ * (`CadreNode.isAwaitingClaim`) its connection is admitted, because the claim
43
+ * seed has to ride one, but its control-DB streams and relay reservations are
44
+ * refused ahead of every admission below, the empty-anchor one included. An
45
+ * unclaimed node has no siblings to replicate from, and one on a public
46
+ * address must not relay for anyone. The claim anchors the owner, after which
47
+ * the ordinary rules apply.
48
+ * - `/sereus/formation/1.0.0` — cross-party
49
+ * strand formation via open invitations. Stranger-facing BY DESIGN: the
50
+ * initiator is another party, and its token is only checkable inside the
51
+ * protocol. The window is keyed on EXPECTATION of a stranger, not capability
52
+ * to serve one: it is open only while this node has at least one UNEXPIRED,
53
+ * NOT-FULLY-CONSUMED open invitation outstanding
54
+ * (`StrandSolicitationService.hasOutstandingInvitation` — the tokens this
55
+ * process minted or published, plus any still-redeemable `FormationInvite`
56
+ * row the usage recorder can see). Registering the responder does NOT open
57
+ * it, so on every node — each registers one at `CadreNode.start` — a stranger
58
+ * with no invitation in play stays on the provisional deadline. The
59
+ * handler's own trust decision remains the per-token check, which is
60
+ * strictly finer than this one: a peer admitted here can still be rejected
61
+ * in-protocol for a bogus or spent token.
62
+ * - `/sereus/cadre-invite/1.0.0` — redemption
63
+ * of a cadre invitation at any member machine. Stranger-facing BY DESIGN:
64
+ * the device is not a member until the redemption writes its row, and its
65
+ * proof of possession (a signature with the invitation's private key) is
66
+ * only checkable inside the protocol. Keyed on expectation, as formation's
67
+ * is: open only while this node holds at least one LIVE `CadreInvite` row
68
+ * (`ControlDatabase.hasLiveCadreInvite`: not withdrawn, unexpired, uses left,
69
+ * issuer still an owner). A member that has not yet received the device's
70
+ * row by replication admits the device provisionally instead, seats the row
71
+ * from the invitation bundle during the redemption, and so admits it at the
72
+ * deadline's re-check.
73
+ *
74
+ * There is one further connection-level carve-out, which is NOT a protocol
75
+ * window:
76
+ *
77
+ * - **Announced delegate peers** (`delegate-admission.ts`). A member's strand
78
+ * node runs as a separate libp2p identity whose peerId no sibling can
79
+ * recompute. Before starting a strand node, a member's control node
80
+ * announces that peerId over the already-authenticated strand-addr RPC, and
81
+ * the receiver holds a short-lived grant for it
82
+ * (`CadreNode.grantDelegateAdmission`). The grant admits the CONNECTION
83
+ * outright, admits the peer's RESERVATION at the seam below (without
84
+ * spending the unauthorized budget), and nothing else — it is deliberately
85
+ * invisible to the protocol guard, so a delegate still gets refused on every
86
+ * members-only protocol.
87
+ *
88
+ * ## Provisional admission
89
+ *
90
+ * A peer the policy cannot place (`'admit-provisionally'`) gets its connection
91
+ * and a deadline — {@link PROVISIONAL_ADMISSION_DEADLINE_MS} at the default
92
+ * declaration; `CadreNode` passes the one derived from its OWN declared link,
93
+ * because the member is the machine that decides. At the deadline the gate asks
94
+ * the policy again:
95
+ *
96
+ * - `'admit'` — the peer has since become a member, holds a delegate grant, or
97
+ * this node now holds a live invitation (a redeeming device's row was
98
+ * seated): the connection stays, and nothing is re-armed;
99
+ * - `'admit-provisionally'` — the connection is closed (a bounded graceful
100
+ * close, with an abort only as the escalation);
101
+ * - the question throws or outlasts the decision deadline — the connection
102
+ * stays (see "Fail-open, deliberately").
103
+ *
104
+ * A connection that is already gone by then costs no question. One peer may hold
105
+ * several provisional connections; each deadline re-asks on its own.
106
+ *
107
+ * The gate does not decide which peers this node sends control writes to. A
108
+ * provisionally admitted stranger can open or advertise the party's protocols;
109
+ * what keeps it out of the control-database cohort is the control node's
110
+ * membership predicate (`CadreNode.isControlNetworkMember`, wired as Optimystic's
111
+ * `isNetworkMember`). FRET's ring still counts it: `docs/architecture.md` →
112
+ * "Per-stream protocol guard" → "What it does not cover".
113
+ *
114
+ * ## The relay-reservation seam
115
+ *
116
+ * A circuit-relay reservation is established by the reserving peer DIALING the
117
+ * relay, so at the relay it rides an inbound connection — and closing that
118
+ * connection kills the reservation. For a genuine member whose `CadrePeer` row
119
+ * has not yet replicated to this node that would NOT be self-healing the way a
120
+ * data connection's close is: an outbound reconcile dial re-establishes a data
121
+ * link, but no outbound dial can grant the REMOTE peer a reservation, and a
122
+ * relay-only peer has no address of its own to dial back — the reservation IS
123
+ * its address. Boot ordering makes the window ordinary (a node reserves at the
124
+ * end of its own `start()`, before its row could have replicated anywhere), and
125
+ * a stalled replication makes it unbounded. So the reservation is decided on its
126
+ * own terms, not by the connection's:
127
+ *
128
+ * - It is decided at libp2p's `denyInboundRelayReservation` hook (the
129
+ * circuit-relay server consults it per RESERVE request): the policy admits
130
+ * members, delegates and configured infra outright, and admits peers it
131
+ * cannot place only within a bounded budget
132
+ * ({@link UnauthorizedReservationBudget}) — a member whose row is in flight
133
+ * always finds a slot under any sane cap, while outsiders cannot annex the
134
+ * party's relay capacity. COUNT and lifetime are the only bounds on such a
135
+ * peer: a party-run relay forwards without libp2p's per-connection data and
136
+ * duration limit by default (`relay-server.ts`), so what a granted slot
137
+ * carries is not capped.
138
+ * - An ADMITTED reservation disarms every provisional deadline the peer holds
139
+ * here, so its connection outlives the deadline even while the peer is still
140
+ * unplaced. A refused one disarms nothing.
141
+ *
142
+ * The deadline clears on reservation ADMISSION, not on the reservation's own
143
+ * success: this gate cannot observe the server-side `reserve()` outcome, so a
144
+ * peer whose admitted reservation is then refused for server capacity keeps its
145
+ * connection until either side closes it — bounded and mute, so harmless.
146
+ *
147
+ * Everything else a control node serves is either libp2p plumbing open to any
148
+ * connection (identify, ping, hole punching, AutoNAT, the relay protocols) or
149
+ * members-only — wake, strand-addr, the Optimystic control-DB protocols and FRET —
150
+ * and the fail-closed layer for all of it is the protocol guard
151
+ * (`control-protocol-guard.ts`), one seam that wraps every handler at dispatch,
152
+ * with a protocol nobody classed treated as members-only. The two layers
153
+ * complement, not duplicate: this connection gate is fail-open over a live DB
154
+ * read and decides only how long a connection lasts, the protocol guard is
155
+ * fail-closed and has NO stranger carve-outs — a live invitation admits a
156
+ * stranger's connection outright for redemption, yet its repo and FRET streams
157
+ * are still refused.
158
+ *
159
+ * ## The bring-up quiet period
160
+ *
161
+ * One state is decided BEFORE any of the above, and is not about the remote peer
162
+ * at all: while this node's control-database bring-up is in flight
163
+ * (`InboundAdmissionPolicy.bringUpInFlight`), the gate denies BOTH directions —
164
+ * `denyDialPeer` and `denyInboundEncryptedConnection` — and then opens.
165
+ *
166
+ * The invariant it protects is "`ControlDatabase.initialize()` runs while this
167
+ * node holds ZERO control connections". Building that database is a long chain
168
+ * of cohort-consulting block probes, and every connected same-party sibling is in
169
+ * the cohort those probes consult. A sibling that has not yet replicated this
170
+ * node's `CadrePeer` row correctly refuses them at its own fail-closed per-stream
171
+ * gate, so ONE connection in this window turns bring-up into
172
+ * `BlockUnavailableError` and `start()` rejects. Retrying cannot converge: the
173
+ * condition that would clear the refusal is this node's own row reaching the
174
+ * sibling, and writing that row needs the database the retry is building. So the
175
+ * only fix is to not be in the conversation yet — which is what this window is.
176
+ *
177
+ * The ordering is arranged so nothing SHOULD open a connection here anyway:
178
+ * `network.relayAddrs` resolves to a listener that dials nothing and reserves
179
+ * after bring-up (`relay-addrs.ts`), `controlNetwork.bootstrapNodes` is dialed
180
+ * after bring-up (`CadreNode.dialControlBootstrapPeers`), and the control-cohort
181
+ * reconcile pass is scheduled post-start. This window is what makes that a property
182
+ * rather than an accident of ordering — the live cases it catches are peers the node
183
+ * remembers from a previous run, which the connection manager auto-dials, and
184
+ * inbound dials.
185
+ *
186
+ * A denial here costs a retry, not a partition: libp2p's connection manager
187
+ * re-dials on its auto-dial cadence, the reservation supervisor re-drives, and a
188
+ * denied inbound peer reconnects. The window opens on bring-up FAILURE too (via
189
+ * `CadreNode.cleanup`), so teardown is never gated.
190
+ *
191
+ * ## Fail-open, deliberately
192
+ *
193
+ * Outside that window this layer never denies, and it closes a provisional
194
+ * connection only on a positive answer that the peer is still unplaced. Any
195
+ * error, missing dependency, timeout or ambiguous state admits the connection
196
+ * outright (and the reservation), or keeps a provisional connection at its
197
+ * deadline, and defers to the fail-closed stream gates — a DB hiccup must not
198
+ * partition a legitimate cadre.
199
+ */
200
+
201
+ import debug from 'debug';
202
+ import type { ConnectionGater, PeerId, MultiaddrConnection } from '@libp2p/interface';
203
+ import { withDeadline } from './control-stream.js';
204
+ import { PARTY_RELAY_RESERVATION_TTL_MS } from './relay-server.js';
205
+ import { ADMISSION_DECISION_TIMEOUT_MS, relayedRequestBudgetMs } from './link-budget.js';
206
+
207
+ const log = debug('sereus:cadre:connection-gater');
208
+
209
+ /**
210
+ * How long a provisionally admitted connection lasts before the gate re-asks the
211
+ * policy and closes it unless the peer is admissible by then (see the module
212
+ * doc's "Provisional admission").
213
+ *
214
+ * {@link relayedRequestBudgetMs}: 28 500 ms at the default declaration. The
215
+ * longest exchange a stranger legitimately starts on a fresh connection is a
216
+ * cadre invitation redemption, and the device abandons each member address after
217
+ * that same budget of its own declared link (`redeemAtMembers`): it covers the
218
+ * dial, the request, the member's catch-up push, the seat and the redemption. So
219
+ * a member never cuts an exchange the device is still waiting on, and a
220
+ * reservation request lands well inside it. This is the value at the default
221
+ * declaration; `CadreNode` passes the one derived from its OWN declaration.
222
+ *
223
+ * NOTE: accepted tradeoff — any stranger can hold a mute connection to any
224
+ * enrolled member for this long, weighed against letting a device redeem an
225
+ * invitation at a member that has not yet received its row (maintainer,
226
+ * 2026-10-07, plan `cadre-invite-redeemable-before-the-row-replicates`). It can
227
+ * open only stranger-open and transport streams (the protocol guard), and
228
+ * libp2p's connection-manager limits bound how many it holds. Revisit if stranger
229
+ * connections show up as load on a member.
230
+ */
231
+ export const PROVISIONAL_ADMISSION_DEADLINE_MS = relayedRequestBudgetMs();
232
+
233
+ /**
234
+ * Bound on the graceful close of an expired provisional connection. It exists
235
+ * because `AbstractMultiaddrConnection.close()` awaits an `idle`/`drain` event
236
+ * when the connection still has unsent bytes, and that wait has no timeout of
237
+ * its own — an unsignalled one never ends. This gate writes nothing to a
238
+ * stranger, so the wait is not reachable from here; the bound is here so a timer
239
+ * callback can never hold an unending await.
240
+ *
241
+ * Applied through {@link withDeadline}, not `AbortSignal.timeout`: the latter is
242
+ * not reliably present on React Native/Hermes, which loads this same module.
243
+ */
244
+ // eslint-disable-next-line no-restricted-syntax -- link-independent: bounds a local connection close inside a timer callback, and expiry aborts the connection instead
245
+ export const PROVISIONAL_ADMISSION_CLOSE_TIMEOUT_MS = 2_000;
246
+
247
+ /**
248
+ * Default cap on concurrent relay reservations held by peers the membership
249
+ * check could not place (see {@link UnauthorizedReservationBudget}). Small on
250
+ * purpose: it exists for the handful of genuine members whose rows are still
251
+ * in flight, not as public relay capacity. Overridable per node via
252
+ * `network.unauthorizedRelayReservationCap` (0 refuses every unauthorized
253
+ * reservation — the strict pre-seam posture).
254
+ *
255
+ * NOTE: this cap shares the relay server's own reservation store, whose
256
+ * party-run default size is `PARTY_RELAY_MAX_RESERVATIONS` (128,
257
+ * `relay-server.ts`); unplaced peers may therefore occupy up to this many of
258
+ * those slots, and the rest are what members and delegates compete for. The two
259
+ * are kept apart by hand — if this is raised, or an embedder shrinks the store
260
+ * through `network.relayServerInit.reservations.maxReservations`, keep this one
261
+ * well under the store's, or a fleet of unplaceable peers can crowd genuine
262
+ * members out of the server's own store (which the gate cannot override).
263
+ */
264
+ export const MAX_UNAUTHORIZED_RELAY_RESERVATIONS = 8;
265
+
266
+ /**
267
+ * The connection-level outcome of the admission policy:
268
+ * - `'admit'` — a peer with a legitimate claim on the connection (member,
269
+ * delegate, infra, open stranger window, or any fail-open state); no deadline.
270
+ * - `'admit-provisionally'` — a peer the policy cannot place (an outsider, a
271
+ * device whose invitation row this node does not hold yet, or a member whose
272
+ * row is still replicating — indistinguishable here): admit the connection,
273
+ * and at the deadline ask again and close it unless the answer is `'admit'`.
274
+ *
275
+ * There is no deny: the gate's only denial is the bring-up quiet period, which
276
+ * is not a judgement of the peer.
277
+ */
278
+ export type InboundConnectionVerdict = 'admit' | 'admit-provisionally';
279
+
280
+ /**
281
+ * The admission decisions the gate defers to — implemented by
282
+ * `CadreNode.admitInboundControlConnection` /
283
+ * `CadreNode.admitControlRelayReservation` (enrollment windows, anchor state,
284
+ * bootstrap infra, delegate grants, the authorized-member set, the
285
+ * unauthorized-reservation budget). Kept injectable so the gater's
286
+ * composition/fail-open behavior is unit-testable without a full node.
287
+ */
288
+ export interface InboundAdmissionPolicy {
289
+ /**
290
+ * Verdict on an inbound encrypted connection from this peer. Asked again at a
291
+ * provisional connection's deadline, so it must answer from current state.
292
+ */
293
+ admitInbound(remotePeerId: string): Promise<InboundConnectionVerdict> | InboundConnectionVerdict;
294
+ /** Should this peer be granted a circuit-relay reservation slot? */
295
+ admitRelayReservation(remotePeerId: string): Promise<boolean> | boolean;
296
+ /**
297
+ * True while this node's control-database bring-up is in flight — the
298
+ * {@link createMembershipConnectionGater} quiet period (see the module doc).
299
+ * Peer-independent, unlike the two decisions above, and SYNCHRONOUS: it is
300
+ * read on the outbound-dial path, where an await would be a new stall.
301
+ *
302
+ * Optional — a policy that omits it is never quiet, which is the right default
303
+ * for every caller that is not a booting `CadreNode`.
304
+ */
305
+ bringUpInFlight?(): boolean;
306
+ }
307
+
308
+ /**
309
+ * Bounded budget of concurrent relay reservations for peers the membership
310
+ * check could not place. `tryAdmit` is the whole surface: an already-admitted
311
+ * peer refreshes its entry (a reservation refresh never double-counts), a new
312
+ * peer takes a free slot or is refused. Entries expire after `ttlMs`, which
313
+ * `CadreNode` passes as the relay server's own resolved `reservationTtl`
314
+ * (`relay-server.ts`): the server holds an unrefreshed reservation exactly that
315
+ * long, and a live reserver re-requests (re-hitting the admission hook,
316
+ * refreshing its entry) well before expiry — so the live entry count tracks the
317
+ * server's own occupancy without this module reaching into the server's
318
+ * reservation store. Injectable `now` keeps expiry testable without fake
319
+ * timers. Authorized members and delegates are never run through this — the
320
+ * policy admits them before consulting the budget, and `release`s the slot such
321
+ * a peer took while it was still unplaceable, so the boot-ordering window a
322
+ * member passes through costs the budget nothing once its row lands.
323
+ */
324
+ export class UnauthorizedReservationBudget {
325
+ private readonly admitted = new Map<string, number>();
326
+
327
+ constructor(
328
+ /** Most peers admitted at once — `network.unauthorizedRelayReservationCap`, resolved. */
329
+ readonly cap: number = MAX_UNAUTHORIZED_RELAY_RESERVATIONS,
330
+ private readonly ttlMs: number = PARTY_RELAY_RESERVATION_TTL_MS
331
+ ) {}
332
+
333
+ /** Number of live (unexpired at last prune) entries — test/diagnostic surface. */
334
+ get size(): number {
335
+ return this.admitted.size;
336
+ }
337
+
338
+ /** Admit (or refresh) `remotePeerId` if a slot is free; false when the cap is spent. */
339
+ tryAdmit(remotePeerId: string, now: number = Date.now()): boolean {
340
+ this.prune(now);
341
+ if (!this.admitted.has(remotePeerId) && this.admitted.size >= this.cap) {
342
+ log('Unauthorized-reservation budget spent (%d/%d) — refusing %s', this.admitted.size, this.cap, remotePeerId);
343
+ return false;
344
+ }
345
+ this.admitted.set(remotePeerId, now + this.ttlMs);
346
+ return true;
347
+ }
348
+
349
+ /**
350
+ * Give back the slot `remotePeerId` holds, if any — called when the peer has
351
+ * become admissible on its own merits (its membership row replicated, or a
352
+ * delegate grant landed), so the slot it took during the boot-ordering window
353
+ * does not stay spent for the remaining TTL on a peer that no longer needs it.
354
+ */
355
+ release(remotePeerId: string): void {
356
+ this.admitted.delete(remotePeerId);
357
+ }
358
+
359
+ private prune(now: number): void {
360
+ for (const [peerId, expiresAt] of this.admitted) {
361
+ if (expiresAt <= now) {
362
+ this.admitted.delete(peerId);
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Build the control node's connection gater: the caller-supplied gater (if any)
370
+ * with membership admission composed onto `denyInboundEncryptedConnection` —
371
+ * the earliest checkpoint where the remote's authenticated PeerId is known —
372
+ * and reservation admission composed onto `denyInboundRelayReservation` — the
373
+ * hook the circuit-relay server consults per RESERVE request (inert on a node
374
+ * whose relay server is off; libp2p never calls it there).
375
+ *
376
+ * `denyDialPeer` is composed too, but ONLY for the bring-up quiet period (see
377
+ * the module doc): outside that window outbound dials are never gated by
378
+ * membership — this node decides for itself who to talk to.
379
+ *
380
+ * Composition semantics: every hook of `base` is preserved as-is; on the three
381
+ * composed hooks a deny from the base gater denies, and so does the quiet
382
+ * period. The admission policy never denies a connection. A policy error — or a
383
+ * decision slower than `decisionTimeoutMs` — takes the fail-open outcome
384
+ * (connection admitted outright / reservation admitted, see module doc); the
385
+ * base gater's verdict is still honored first.
386
+ *
387
+ * An `'admit-provisionally'` verdict admits the connection and arms a
388
+ * `provisionalDeadlineMs` timer against it. When the timer fires the policy is
389
+ * asked again, and the underlying `MultiaddrConnection` is closed unless it now
390
+ * answers `'admit'`; a reservation for that peer ADMITTED at the reservation
391
+ * hook disarms the timer first.
392
+ *
393
+ * NOTE: `base` is spread, so a gater passed as a CLASS INSTANCE would lose its
394
+ * prototype methods; every caller in this repo (and libp2p's own default)
395
+ * supplies a plain object. If a class-based gater ever shows up, delegate
396
+ * per-hook instead of spreading.
397
+ *
398
+ * Deny timing, as observed by the denied dialer (a quiet-period or base-gater
399
+ * deny): noise negotiates the muxer in the security handshake's early data, so
400
+ * the DIALER's upgrade may complete (its `dial()` resolves) before this
401
+ * receiver-side hook runs. The deny then aborts the receiver's upgrade — the
402
+ * receiver never registers the connection and never creates its muxer, so no
403
+ * protocol can ever be negotiated — and the dialer sees its "open" connection
404
+ * close moments later.
405
+ *
406
+ * Control node only: strand cohort nodes legitimately connect cross-party
407
+ * peers, so cadre membership never gates them. An OPEN strand's node receives
408
+ * the raw configured gater unchanged; a CLOSED strand's node composes its own
409
+ * revoked-peer denial onto it instead — a deny-list on positive revocation
410
+ * evidence, not this module's membership admission — see
411
+ * `strand-revocation-enforcer.ts`.
412
+ */
413
+ export function createMembershipConnectionGater(
414
+ policy: InboundAdmissionPolicy,
415
+ base?: ConnectionGater,
416
+ decisionTimeoutMs: number = ADMISSION_DECISION_TIMEOUT_MS,
417
+ provisionalDeadlineMs: number = PROVISIONAL_ADMISSION_DEADLINE_MS
418
+ ): ConnectionGater {
419
+ const decideInbound = (remotePeerId: string): Promise<InboundConnectionVerdict> => decideWithinDeadline(
420
+ () => policy.admitInbound(remotePeerId), 'admit', decisionTimeoutMs, `admitInbound(${remotePeerId})`
421
+ );
422
+ const provisional = new ProvisionalAdmissions(provisionalDeadlineMs, decideInbound);
423
+ const quiet = (): boolean => policy.bringUpInFlight?.() ?? false;
424
+ return {
425
+ ...base,
426
+ denyDialPeer: async (peerId: PeerId): Promise<boolean> => {
427
+ if (await base?.denyDialPeer?.(peerId)) {
428
+ return true;
429
+ }
430
+ if (quiet()) {
431
+ log('Control-database bring-up in flight — refusing the outbound dial to %s', peerId.toString());
432
+ return true;
433
+ }
434
+ return false;
435
+ },
436
+ denyInboundEncryptedConnection: async (peerId: PeerId, maConn: MultiaddrConnection): Promise<boolean> => {
437
+ if (await base?.denyInboundEncryptedConnection?.(peerId, maConn)) {
438
+ return true;
439
+ }
440
+ if (quiet()) {
441
+ log('Control-database bring-up in flight — refusing the inbound connection from %s', peerId.toString());
442
+ return true;
443
+ }
444
+ const remotePeerId = peerId.toString();
445
+ let verdict: InboundConnectionVerdict;
446
+ try {
447
+ verdict = await decideInbound(remotePeerId);
448
+ } catch (error) {
449
+ log('admitInbound threw for %s — admitting (fail-open; stream gates decide): %o', remotePeerId, error);
450
+ return false;
451
+ }
452
+ if (verdict === 'admit-provisionally') {
453
+ provisional.arm(remotePeerId, maConn);
454
+ }
455
+ return false;
456
+ },
457
+ denyInboundRelayReservation: async (peerId: PeerId): Promise<boolean> => {
458
+ if (await base?.denyInboundRelayReservation?.(peerId)) {
459
+ return true;
460
+ }
461
+ const remotePeerId = peerId.toString();
462
+ let admitted: boolean;
463
+ try {
464
+ admitted = await decideWithinDeadline(
465
+ () => policy.admitRelayReservation(remotePeerId), true, decisionTimeoutMs, `admitRelayReservation(${remotePeerId})`
466
+ );
467
+ } catch (error) {
468
+ log('admitRelayReservation threw for %s — admitting (fail-open): %o', remotePeerId, error);
469
+ admitted = true;
470
+ }
471
+ if (admitted) {
472
+ provisional.disarm(remotePeerId);
473
+ }
474
+ return !admitted;
475
+ }
476
+ };
477
+ }
478
+
479
+ /**
480
+ * Run one admission decision under `timeoutMs`, resolving to `fallback` (the
481
+ * fail-open outcome) if it has not settled in time — see
482
+ * {@link ADMISSION_DECISION_TIMEOUT_MS} for why an unbounded await is not safe
483
+ * on the connection hook. The timer is always cleared so a decided call never
484
+ * holds the event loop open. Exported for the strand revoked-peer gate
485
+ * (`strand-revocation-enforcer.ts`), which bounds its connection hooks with the
486
+ * same fail-open contract.
487
+ */
488
+ export async function decideWithinDeadline<T>(
489
+ decide: () => Promise<T> | T,
490
+ fallback: T,
491
+ timeoutMs: number,
492
+ what: string
493
+ ): Promise<T> {
494
+ let timer: ReturnType<typeof setTimeout> | undefined;
495
+ try {
496
+ return await Promise.race([
497
+ Promise.resolve().then(decide),
498
+ new Promise<T>((resolve) => {
499
+ timer = setTimeout(() => {
500
+ log('%s exceeded %dms — taking the fail-open outcome (stream gates decide)', what, timeoutMs);
501
+ resolve(fallback);
502
+ }, timeoutMs);
503
+ })
504
+ ]);
505
+ } finally {
506
+ clearTimeout(timer);
507
+ }
508
+ }
509
+
510
+ /** One armed provisional deadline: the connection it may close, and its timer. */
511
+ interface ProvisionalAdmission {
512
+ maConn: MultiaddrConnection;
513
+ timer: ReturnType<typeof setTimeout>;
514
+ }
515
+
516
+ /**
517
+ * The deadlines of `'admit-provisionally'` connections, keyed by remote peerId
518
+ * (a Set per peer — one peer can hold several in-flight connections). `arm`
519
+ * starts a timer that re-asks the policy and closes the connection unless it now
520
+ * admits; `disarm` (called when a reservation for that peer is admitted) cancels
521
+ * every pending deadline for the peer, one whose question is already out
522
+ * included. Timers are unref'd so an armed deadline never holds a process open.
523
+ *
524
+ * Disarming is final for the connections it cancelled: a peer that reserved
525
+ * once and then lets its reservation lapse keeps a mute connection (the
526
+ * fail-closed stream gates refuse it every members-only protocol) until either
527
+ * side closes it. Bounded — only a peer the reservation policy already admitted
528
+ * can reach that state, so unplaceable peers are bounded by the budget cap.
529
+ */
530
+ class ProvisionalAdmissions {
531
+ private readonly byPeer = new Map<string, Set<ProvisionalAdmission>>();
532
+
533
+ constructor(
534
+ private readonly deadlineMs: number,
535
+ private readonly decide: (remotePeerId: string) => Promise<InboundConnectionVerdict>
536
+ ) {}
537
+
538
+ arm(remotePeerId: string, maConn: MultiaddrConnection): void {
539
+ const entry: ProvisionalAdmission = {
540
+ maConn,
541
+ timer: setTimeout(() => void this.expire(remotePeerId, entry), this.deadlineMs)
542
+ };
543
+ (entry.timer as { unref?: () => void }).unref?.();
544
+ let entries = this.byPeer.get(remotePeerId);
545
+ if (!entries) {
546
+ entries = new Set();
547
+ this.byPeer.set(remotePeerId, entries);
548
+ }
549
+ entries.add(entry);
550
+ log('Admitted %s provisionally — closing the connection in %dms unless it is admissible by then', remotePeerId, this.deadlineMs);
551
+ }
552
+
553
+ disarm(remotePeerId: string): void {
554
+ const entries = this.byPeer.get(remotePeerId);
555
+ if (!entries) {
556
+ return;
557
+ }
558
+ this.byPeer.delete(remotePeerId);
559
+ for (const entry of entries) {
560
+ clearTimeout(entry.timer);
561
+ }
562
+ }
563
+
564
+ private async expire(remotePeerId: string, entry: ProvisionalAdmission): Promise<void> {
565
+ if (entry.maConn.status !== 'open') {
566
+ this.release(remotePeerId, entry);
567
+ return;
568
+ }
569
+ const verdict = await this.recheck(remotePeerId);
570
+ // False when a reservation admitted while the question was out disarmed this deadline.
571
+ if (!this.release(remotePeerId, entry)) {
572
+ return;
573
+ }
574
+ if (verdict === 'admit') {
575
+ log('%s is admissible at its provisional deadline — keeping the connection', remotePeerId);
576
+ return;
577
+ }
578
+ log('Provisional admission expired for %s — still unplaced after %dms, closing the connection', remotePeerId, this.deadlineMs);
579
+ await this.drop(remotePeerId, entry.maConn);
580
+ }
581
+
582
+ /** The policy's answer at the deadline; a throw keeps the connection (fail-open). */
583
+ private async recheck(remotePeerId: string): Promise<InboundConnectionVerdict> {
584
+ try {
585
+ return await this.decide(remotePeerId);
586
+ } catch (error) {
587
+ log('admitInbound threw for %s at its provisional deadline — keeping the connection (fail-open): %o', remotePeerId, error);
588
+ return 'admit';
589
+ }
590
+ }
591
+
592
+ /** Forget `entry`; false when a `disarm` already took it. */
593
+ private release(remotePeerId: string, entry: ProvisionalAdmission): boolean {
594
+ const entries = this.byPeer.get(remotePeerId);
595
+ if (!entries?.delete(entry)) {
596
+ return false;
597
+ }
598
+ if (entries.size === 0) {
599
+ this.byPeer.delete(remotePeerId);
600
+ }
601
+ return true;
602
+ }
603
+
604
+ /**
605
+ * End an expired provisional connection: a bounded graceful close, with
606
+ * `abort()` only as the escalation. The order cannot be reversed — `abort()`
607
+ * marks the connection `aborted`, and `close()` returns immediately on any
608
+ * status that is not `open`, so aborting first would make the close a silent
609
+ * no-op. Closing a connection the peer already dropped returns at once
610
+ * without throwing, so the fallback stays unreached in the ordinary case.
611
+ */
612
+ private async drop(remotePeerId: string, maConn: MultiaddrConnection): Promise<void> {
613
+ try {
614
+ await withDeadline(
615
+ PROVISIONAL_ADMISSION_CLOSE_TIMEOUT_MS,
616
+ `provisional close for ${remotePeerId}`,
617
+ (signal) => maConn.close({ signal })
618
+ );
619
+ return;
620
+ } catch (error) {
621
+ log('Closing the expired provisional connection from %s failed — aborting: %o', remotePeerId, error);
622
+ }
623
+ // NOTE: this fallback cannot actually free a WebSocket. `@libp2p/websockets`'
624
+ // `sendReset()` calls `websocket.close(1006)`, and 1006 is a reserved code
625
+ // RFC 6455 forbids an endpoint from sending, so `ws` throws and
626
+ // `AbstractMessageStream.abort()` swallows it — the local end flips to
627
+ // `aborted` while the socket stays up. Measured against
628
+ // @libp2p/websockets@10.1.3 (see tickets/blocked/report-libp2p-websockets-abort-close-code.md).
629
+ // `MultiaddrConnection` exposes no way down to the raw socket, so this is as
630
+ // far as this layer can go. Revisit — drop the fallback, or go back to a
631
+ // plain `abort()` — once that transport sends a legal reset code.
632
+ try {
633
+ maConn.abort(new Error(`provisional admission expired: still unplaced after ${this.deadlineMs}ms`));
634
+ } catch (error) {
635
+ log('Aborting the expired provisional connection from %s threw: %o', remotePeerId, error);
636
+ }
637
+ }
638
+ }