@serfab/cadre-core 1.17.0 → 1.19.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 (148) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-invite-protocol.d.ts +35 -13
  3. package/dist/cadre-invite-protocol.js +90 -25
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-node.d.ts +586 -255
  6. package/dist/cadre-node.js +1182 -566
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +66 -9
  9. package/dist/control-cohort.js +67 -9
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +451 -193
  12. package/dist/control-database.js +701 -359
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +46 -43
  15. package/dist/control-formation-recorder.js +77 -78
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-protocol-guard.d.ts +6 -0
  18. package/dist/control-protocol-guard.js +14 -2
  19. package/dist/control-protocol-guard.js.map +1 -1
  20. package/dist/control-schema-version.d.ts +48 -31
  21. package/dist/control-schema-version.js +67 -45
  22. package/dist/control-schema-version.js.map +1 -1
  23. package/dist/control-schema.d.ts +8 -8
  24. package/dist/control-schema.js +16 -16
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +42 -7
  27. package/dist/control-write-retry.js +87 -19
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/enrolled-machine-store.d.ts +3 -3
  30. package/dist/enrolled-machine-store.js +3 -3
  31. package/dist/formation-token.d.ts +48 -0
  32. package/dist/formation-token.js +26 -0
  33. package/dist/formation-token.js.map +1 -0
  34. package/dist/index.d.ts +15 -14
  35. package/dist/index.js +17 -14
  36. package/dist/index.js.map +1 -1
  37. package/dist/joined-strand-store.d.ts +26 -9
  38. package/dist/joined-strand-store.js +63 -24
  39. package/dist/joined-strand-store.js.map +1 -1
  40. package/dist/link-budget.d.ts +9 -7
  41. package/dist/link-budget.js +9 -7
  42. package/dist/link-budget.js.map +1 -1
  43. package/dist/member-redemption.d.ts +15 -9
  44. package/dist/member-redemption.js +13 -8
  45. package/dist/member-redemption.js.map +1 -1
  46. package/dist/membership-view.d.ts +91 -0
  47. package/dist/membership-view.js +110 -0
  48. package/dist/membership-view.js.map +1 -0
  49. package/dist/owner-grant-protocol.d.ts +11 -7
  50. package/dist/owner-grant-protocol.js +31 -17
  51. package/dist/owner-grant-protocol.js.map +1 -1
  52. package/dist/peer-addr-book.d.ts +10 -1
  53. package/dist/peer-addr-book.js +4 -3
  54. package/dist/peer-addr-book.js.map +1 -1
  55. package/dist/peer-join-backfill.d.ts +96 -30
  56. package/dist/peer-join-backfill.js +272 -128
  57. package/dist/peer-join-backfill.js.map +1 -1
  58. package/dist/pending-join-runner.d.ts +48 -18
  59. package/dist/pending-join-runner.js +74 -56
  60. package/dist/pending-join-runner.js.map +1 -1
  61. package/dist/seed-bootstrap.d.ts +47 -40
  62. package/dist/seed-bootstrap.js +66 -61
  63. package/dist/seed-bootstrap.js.map +1 -1
  64. package/dist/self-address-update.d.ts +13 -0
  65. package/dist/self-address-update.js +19 -0
  66. package/dist/self-address-update.js.map +1 -0
  67. package/dist/strand-database.d.ts +23 -2
  68. package/dist/strand-database.js +26 -6
  69. package/dist/strand-database.js.map +1 -1
  70. package/dist/strand-first-sync-gate.d.ts +176 -89
  71. package/dist/strand-first-sync-gate.js +288 -122
  72. package/dist/strand-first-sync-gate.js.map +1 -1
  73. package/dist/strand-formation-deadlines.d.ts +9 -9
  74. package/dist/strand-formation-deadlines.js +9 -9
  75. package/dist/strand-formation-manager.d.ts +23 -17
  76. package/dist/strand-formation-manager.js +46 -44
  77. package/dist/strand-formation-manager.js.map +1 -1
  78. package/dist/strand-formation-protocol.d.ts +5 -8
  79. package/dist/strand-formation-protocol.js +101 -16
  80. package/dist/strand-formation-protocol.js.map +1 -1
  81. package/dist/strand-instance-manager.d.ts +26 -17
  82. package/dist/strand-instance-manager.js +119 -50
  83. package/dist/strand-instance-manager.js.map +1 -1
  84. package/dist/strand-membership-reconciler.d.ts +16 -13
  85. package/dist/strand-membership-reconciler.js +28 -22
  86. package/dist/strand-membership-reconciler.js.map +1 -1
  87. package/dist/strand-membership-writer.d.ts +40 -28
  88. package/dist/strand-membership-writer.js +212 -153
  89. package/dist/strand-membership-writer.js.map +1 -1
  90. package/dist/strand-move.js +5 -2
  91. package/dist/strand-move.js.map +1 -1
  92. package/dist/strand-revocation-enforcer.d.ts +134 -60
  93. package/dist/strand-revocation-enforcer.js +144 -71
  94. package/dist/strand-revocation-enforcer.js.map +1 -1
  95. package/dist/strand-solicitation.d.ts +50 -39
  96. package/dist/strand-solicitation.js +10 -3
  97. package/dist/strand-solicitation.js.map +1 -1
  98. package/dist/strand-watcher.d.ts +80 -13
  99. package/dist/strand-watcher.js +152 -48
  100. package/dist/strand-watcher.js.map +1 -1
  101. package/dist/table-change-watch.d.ts +63 -0
  102. package/dist/table-change-watch.js +209 -0
  103. package/dist/table-change-watch.js.map +1 -0
  104. package/dist/timeout-scheduler.d.ts +4 -3
  105. package/dist/timeout-scheduler.js +4 -3
  106. package/dist/timeout-scheduler.js.map +1 -1
  107. package/dist/types.d.ts +90 -49
  108. package/dist/types.js +4 -4
  109. package/dist/types.js.map +1 -1
  110. package/package.json +8 -8
  111. package/src/cadre-invite-protocol.ts +119 -31
  112. package/src/cadre-node.ts +1289 -588
  113. package/src/control-cohort.ts +86 -9
  114. package/src/control-database.ts +848 -403
  115. package/src/control-formation-recorder.ts +359 -353
  116. package/src/control-protocol-guard.ts +16 -2
  117. package/src/control-schema-version.ts +101 -51
  118. package/src/control-schema.ts +16 -16
  119. package/src/control-write-retry.ts +91 -19
  120. package/src/delegate-admission.ts +351 -351
  121. package/src/enrolled-machine-store.ts +3 -3
  122. package/src/formation-token.ts +63 -0
  123. package/src/index.ts +30 -18
  124. package/src/joined-strand-store.ts +69 -24
  125. package/src/link-budget.ts +9 -7
  126. package/src/member-redemption.ts +20 -13
  127. package/src/membership-view.ts +144 -0
  128. package/src/owner-grant-protocol.ts +33 -18
  129. package/src/peer-addr-book.ts +4 -3
  130. package/src/peer-join-backfill.ts +961 -761
  131. package/src/pending-join-runner.ts +76 -53
  132. package/src/seed-bootstrap.ts +72 -67
  133. package/src/self-address-update.ts +19 -0
  134. package/src/strand-database.ts +352 -322
  135. package/src/strand-first-sync-gate.ts +334 -134
  136. package/src/strand-formation-deadlines.ts +227 -227
  137. package/src/strand-formation-manager.ts +731 -725
  138. package/src/strand-formation-protocol.ts +122 -24
  139. package/src/strand-instance-manager.ts +2092 -2026
  140. package/src/strand-membership-reconciler.ts +31 -25
  141. package/src/strand-membership-writer.ts +2093 -2019
  142. package/src/strand-move.ts +5 -2
  143. package/src/strand-revocation-enforcer.ts +740 -654
  144. package/src/strand-solicitation.ts +61 -34
  145. package/src/strand-watcher.ts +175 -56
  146. package/src/table-change-watch.ts +285 -0
  147. package/src/timeout-scheduler.ts +29 -28
  148. package/src/types.ts +89 -51
@@ -1,351 +1,351 @@
1
- /**
2
- * Delegate-peer admission grants for a party control node that also runs the
3
- * circuit-relay server.
4
- *
5
- * A strand node runs as its own libp2p instance with its own transport peerId,
6
- * derived from the cadre identity key + strandId (`strand-transport-key.ts`).
7
- * That derivation uses the member's PRIVATE seed by design, so no third party —
8
- * including a co-cadre relay — can recompute it: a sibling's relay sees an
9
- * unknown peerId it has no way to place as a member.
10
- *
11
- * The resolution (docs/strands.md → "Relay willingness"): a party control
12
- * node running a relay is party-private infrastructure — it relays for its own
13
- * party's nodes, including the extra transport identities its members' strand
14
- * nodes run as. The mechanism is a **member-announced delegate grant**: before
15
- * a member's control node starts a strand node, it tells its siblings (over the
16
- * already-authenticated `/sereus/strand-addr/1.0.0` RPC) the derived peerId
17
- * that strand node will use, and the receiver holds a short-lived, in-memory
18
- * admission grant for exactly that peerId. The grant is consulted by the
19
- * connection-level gate (`CadreNode.admitInboundControlConnection`) and by the
20
- * relay-reservation admission (`CadreNode.admitControlRelayReservation`) — a
21
- * granted delegate is admitted outright, never drawing on the small
22
- * unauthorized-reservation budget that unplaced peers share
23
- * (`membership-connection-gater.ts` → "The relay-reservation seam") and never
24
- * put on the provisional connection deadline. The fail-closed per-stream gate
25
- * (`authorizeInboundControlStream`) never honors a grant, so a delegate gets
26
- * the connection and its reservation (all a circuit-relay `hop` needs) and
27
- * nothing more.
28
- *
29
- * A grant is a delegation of trust to an already-trusted member: the receiver
30
- * cannot verify that the announced peerId really is the member's strand node,
31
- * so an authorized member can hand CONNECTION-level access to a peerId of its
32
- * choosing for up to {@link DELEGATE_GRANT_TTL_MS}. The caps below bound how
33
- * far a buggy or compromised member can stretch that.
34
- *
35
- * The receiver side is {@link DelegateAdmissionStore}. The announcer side keeps
36
- * its own throttle state (a "last announced at" map that `CadreNode` owns) and
37
- * decides from it, per reconcile pass, which relays are due a re-announce —
38
- * {@link dueRelayAnnounces} and {@link prunePeerStrandKeys} are that
39
- * decision, pure and testable, kept here beside the TTL they are derived from.
40
- */
41
-
42
- import debug from 'debug';
43
- import { multiaddr } from '@multiformats/multiaddr';
44
- import { peerIdFromString } from '@libp2p/peer-id';
45
- import type { StrandAddrOutcome } from './strand-addr-protocol.js';
46
-
47
- const log = debug('sereus:cadre:delegate-admission');
48
-
49
- /**
50
- * How long one announced delegate grant stays valid without a re-announce.
51
- * Announcers refresh at half this (`CadreNode`'s reconcile pass), so a live
52
- * strand's grant never lapses while its control node can still reach the relay.
53
- */
54
- export const DELEGATE_GRANT_TTL_MS = 30 * 60 * 1000;
55
-
56
- /** Cap on live grants per announcing member (soonest-expiry evicted first). */
57
- export const MAX_DELEGATE_GRANTS_PER_MEMBER = 32;
58
-
59
- /** Cap on live grants in total (soonest-expiry evicted first). */
60
- export const MAX_DELEGATE_GRANTS = 256;
61
-
62
- /** One recorded delegate admission grant. */
63
- interface DelegateGrant {
64
- /** The authorized member whose control node announced this delegate. */
65
- announcerPeerId: string;
66
- /** The strand the delegate serves — diagnostics + per-strand replace semantics. */
67
- strandId: string;
68
- /** The delegate transport peerId the grant admits. */
69
- delegatePeerId: string;
70
- /** Epoch ms after which the grant is inert. */
71
- expiresAt: number;
72
- }
73
-
74
- /**
75
- * Composite map key for per-(peer, strand) state: the receiver's
76
- * replace-per-(announcer, strand) grant, the announcer's per-(target, strand)
77
- * throttle timestamp, and `CadreNode`'s per-(sibling, strand) strand-addr ask
78
- * due time.
79
- */
80
- export function peerStrandKey(peerId: string, strandId: string): string {
81
- return `${peerId}\n${strandId}`;
82
- }
83
-
84
- /** The `peerId` half of a {@link peerStrandKey} (a peer id never contains a newline). */
85
- function peerIdOfKey(key: string): string {
86
- return key.slice(0, key.indexOf('\n'));
87
- }
88
-
89
- /** The `strandId` half of a {@link peerStrandKey}. */
90
- function strandIdOfKey(key: string): string {
91
- return key.slice(key.indexOf('\n') + 1);
92
- }
93
-
94
- /**
95
- * In-memory store of delegate admission grants, keyed by (announcer, strandId)
96
- * so a re-announce REPLACES the previous delegate peerId rather than
97
- * accumulating — a restarted strand cannot leak grants. Expired entries are
98
- * pruned lazily on every read/write. Injectable `now` keeps expiry testable
99
- * without fake timers.
100
- */
101
- export class DelegateAdmissionStore {
102
- private readonly grants = new Map<string, DelegateGrant>();
103
- private readonly ttlMs: number;
104
-
105
- constructor(ttlMs: number = DELEGATE_GRANT_TTL_MS) {
106
- this.ttlMs = ttlMs;
107
- }
108
-
109
- /** Number of live (unexpired at last prune) grants — test/diagnostic surface. */
110
- get size(): number {
111
- return this.grants.size;
112
- }
113
-
114
- /**
115
- * Record (or refresh) a grant: `delegatePeerId` acts on behalf of
116
- * `announcerPeerId` for `strandId`, valid for the store's TTL from `now`.
117
- * Enforces {@link MAX_DELEGATE_GRANTS_PER_MEMBER} and
118
- * {@link MAX_DELEGATE_GRANTS}, evicting the soonest-expiry grant first —
119
- * biased against the stalest delegation, never against the fresh announce.
120
- */
121
- grant(announcerPeerId: string, strandId: string, delegatePeerId: string, now: number = Date.now()): void {
122
- this.prune(now);
123
- const key = peerStrandKey(announcerPeerId, strandId);
124
- if (!this.grants.has(key)) {
125
- this.evictAtCap(
126
- MAX_DELEGATE_GRANTS_PER_MEMBER,
127
- (g) => g.announcerPeerId === announcerPeerId
128
- );
129
- this.evictAtCap(MAX_DELEGATE_GRANTS, () => true);
130
- }
131
- this.grants.set(key, { announcerPeerId, strandId, delegatePeerId, expiresAt: now + this.ttlMs });
132
- log('Delegate grant recorded: %s → %s (strand %s, %d live)', announcerPeerId, delegatePeerId, strandId, this.grants.size);
133
- }
134
-
135
- /**
136
- * Drop every grant. Called when the owning node stops: grants are scoped to
137
- * the session that recorded them, and outliving it would admit delegates
138
- * announced against a node that no longer exists.
139
- */
140
- clear(): void {
141
- this.grants.clear();
142
- }
143
-
144
- /** Is `remotePeerId` covered by a live grant? Prunes expired entries as it goes. */
145
- has(remotePeerId: string, now: number = Date.now()): boolean {
146
- this.prune(now);
147
- for (const grant of this.grants.values()) {
148
- if (grant.delegatePeerId === remotePeerId) {
149
- return true;
150
- }
151
- }
152
- return false;
153
- }
154
-
155
- /** Drop every grant whose expiry has passed. */
156
- private prune(now: number): void {
157
- for (const [key, grant] of this.grants) {
158
- if (grant.expiresAt <= now) {
159
- this.grants.delete(key);
160
- }
161
- }
162
- }
163
-
164
- /**
165
- * If the grants matching `filter` are at `cap`, evict the soonest-expiry one
166
- * among them to make room for the insert the caller is about to do.
167
- */
168
- private evictAtCap(cap: number, filter: (grant: DelegateGrant) => boolean): void {
169
- let count = 0;
170
- let victimKey: string | undefined;
171
- let victimExpiry = Number.POSITIVE_INFINITY;
172
- for (const [key, grant] of this.grants) {
173
- if (!filter(grant)) {
174
- continue;
175
- }
176
- count++;
177
- if (grant.expiresAt < victimExpiry) {
178
- victimExpiry = grant.expiresAt;
179
- victimKey = key;
180
- }
181
- }
182
- if (count >= cap && victimKey !== undefined) {
183
- const victim = this.grants.get(victimKey)!;
184
- this.grants.delete(victimKey);
185
- log('Delegate grant cap %d hit — evicted soonest-expiry grant %s → %s (strand %s)',
186
- cap, victim.announcerPeerId, victim.delegatePeerId, victim.strandId);
187
- }
188
- }
189
- }
190
-
191
- /** A circuit relay named by a multiaddr, as an announce target. */
192
- export interface CircuitRelayTarget {
193
- /** The relay's peerId — the `<relay>` in `…/p2p/<relay>/p2p-circuit…`. */
194
- relayPeerId: string;
195
- /**
196
- * The relay's DIRECT dial multiaddr (the prefix before `/p2p-circuit`),
197
- * usable as a dial fallback when no connection to the relay is open yet.
198
- */
199
- relayAddr: string;
200
- }
201
-
202
- /**
203
- * Extract the circuit relays named by a multiaddr list — for each
204
- * `…/p2p/<relay>/p2p-circuit…` entry, the relay's peerId and its direct dial
205
- * prefix. A bare `/p2p-circuit` (no relay named), a non-circuit addr, an
206
- * unparsable addr, and an unparsable relay peerId are all skipped. A trailing
207
- * `/p2p/<dst>` after the circuit component names the DESTINATION, never the
208
- * relay, and is ignored. Deduplicated by relay peerId (first addr wins).
209
- */
210
- export function extractCircuitRelayTargets(addrs: string[]): CircuitRelayTarget[] {
211
- const byRelay = new Map<string, CircuitRelayTarget>();
212
- for (const addr of addrs) {
213
- const target = circuitRelayTarget(addr);
214
- if (target && !byRelay.has(target.relayPeerId)) {
215
- byRelay.set(target.relayPeerId, target);
216
- }
217
- }
218
- return [...byRelay.values()];
219
- }
220
-
221
- /**
222
- * Of `relays`, the ones whose grant for `strandId` is due a re-announce: never
223
- * announced to, or last announced at least half the TTL ago. Half, so a failed
224
- * refresh has one more attempt before the grant lapses — a lapsed grant means
225
- * the relay denies the strand node's reservation re-dial.
226
- *
227
- * `announceAt` is the announcer-side "last announced at" map, keyed by
228
- * {@link peerStrandKey} on the peer announced TO.
229
- */
230
- export function dueRelayAnnounces(
231
- announceAt: ReadonlyMap<string, number>,
232
- relays: readonly CircuitRelayTarget[],
233
- strandId: string,
234
- now: number,
235
- ttlMs: number = DELEGATE_GRANT_TTL_MS
236
- ): CircuitRelayTarget[] {
237
- return relays.filter(
238
- (relay) => now - (announceAt.get(peerStrandKey(relay.relayPeerId, strandId)) ?? 0) >= ttlMs / 2
239
- );
240
- }
241
-
242
- /**
243
- * Fold one announce pass's outcomes into `refusing`, the relay peerIds remembered
244
- * as refusing the strand-addr protocol. A relay whose negotiation refused it
245
- * (`unsupported`) is added; one that replied with any status is removed, because
246
- * it serves the protocol now. A relay that did not reply (`unreachable`) is left
247
- * as it was: being down says nothing about what it serves. Only `relayPeerIds`
248
- * are read, so a sibling asked in the same pass is never recorded. Mutates in
249
- * place.
250
- */
251
- export function recordRelayRefusals(
252
- refusing: Set<string>,
253
- relayPeerIds: readonly string[],
254
- outcomes: ReadonlyMap<string, StrandAddrOutcome>
255
- ): void {
256
- for (const relayPeerId of relayPeerIds) {
257
- switch (outcomes.get(relayPeerId)) {
258
- case 'unsupported':
259
- refusing.add(relayPeerId);
260
- break;
261
- case 'answered':
262
- case 'empty':
263
- case 'unavailable':
264
- refusing.delete(relayPeerId);
265
- break;
266
- }
267
- }
268
- }
269
-
270
- /** Of `relays`, the ones not remembered in `refusing` (see {@link recordRelayRefusals}). */
271
- export function relaysNotRefusing(
272
- relays: readonly CircuitRelayTarget[],
273
- refusing: ReadonlySet<string>
274
- ): CircuitRelayTarget[] {
275
- return relays.filter((relay) => !refusing.has(relay.relayPeerId));
276
- }
277
-
278
- /**
279
- * Drop the entries of a {@link peerStrandKey}-keyed map whose strand is no longer
280
- * running, and — when `livePeerIds` is given — whose peer is not among them. A
281
- * stopped strand needs no refresh, a departed peer is owed a fresh start when it
282
- * returns, and without this the map grows for the node's lifetime. Mutates in
283
- * place.
284
- */
285
- export function prunePeerStrandKeys(
286
- byKey: Map<string, number>,
287
- runningStrandIds: ReadonlySet<string>,
288
- livePeerIds?: ReadonlySet<string>
289
- ): void {
290
- for (const key of byKey.keys()) {
291
- const peerGone = livePeerIds !== undefined && !livePeerIds.has(peerIdOfKey(key));
292
- if (peerGone || !runningStrandIds.has(strandIdOfKey(key))) {
293
- byKey.delete(key);
294
- }
295
- }
296
- }
297
-
298
- /**
299
- * The circuit relay named by `addr`, throwing when `addr` is unparsable or names
300
- * no relay. The throwing form exists for CONFIG-facing callers (`relay-addrs.ts`),
301
- * where a typo must fail loudly rather than cost the node its reachability;
302
- * {@link extractCircuitRelayTargets} reads runtime-discovered addrs instead and
303
- * logs-and-skips.
304
- */
305
- export function circuitRelayTargetOrThrow(addr: string): CircuitRelayTarget {
306
- const target = parseCircuitRelayTarget(addr);
307
- if (!target) {
308
- throw new Error(`Multiaddr names no circuit relay (expected …/p2p/<relayPeerId>/p2p-circuit): ${addr}`);
309
- }
310
- return target;
311
- }
312
-
313
- /**
314
- * The circuit relay named by `addr`; null when `addr` is well-formed but simply
315
- * names no relay (a direct listen addr, a bare `/p2p-circuit`), and THROWS when
316
- * `addr` is malformed (unparsable multiaddr, garbage relay peerId). The two
317
- * callers want opposite things from the first case and the same thing from the
318
- * second, so the distinction lives here rather than in either of them.
319
- */
320
- function parseCircuitRelayTarget(addr: string): CircuitRelayTarget | null {
321
- const ma = multiaddr(addr);
322
- const components = ma.getComponents();
323
- const circuitIdx = components.findIndex((c) => c.name === 'p2p-circuit');
324
- if (circuitIdx < 1) {
325
- return null;
326
- }
327
- const relay = components[circuitIdx - 1];
328
- if (relay.name !== 'p2p' || !relay.value) {
329
- return null;
330
- }
331
- peerIdFromString(relay.value); // validate; throws on garbage
332
- return {
333
- relayPeerId: relay.value,
334
- relayAddr: ma.decapsulate('/p2p-circuit').toString()
335
- };
336
- }
337
-
338
- /**
339
- * One-addr body of {@link extractCircuitRelayTargets}; null when `addr` names no
340
- * relay. Only a MALFORMED addr logs — the addr lists this reads (a node's live
341
- * multiaddrs, its configured listen addrs) are mostly direct addrs, and calling
342
- * each of those "unparsable" is both wrong and per-announce noise.
343
- */
344
- function circuitRelayTarget(addr: string): CircuitRelayTarget | null {
345
- try {
346
- return parseCircuitRelayTarget(addr);
347
- } catch (err) {
348
- log('Skipping unparsable relay addr %s: %o', addr, err);
349
- return null;
350
- }
351
- }
1
+ /**
2
+ * Delegate-peer admission grants for a party control node that also runs the
3
+ * circuit-relay server.
4
+ *
5
+ * A strand node runs as its own libp2p instance with its own transport peerId,
6
+ * derived from the cadre identity key + strandId (`strand-transport-key.ts`).
7
+ * That derivation uses the member's PRIVATE seed by design, so no third party —
8
+ * including a co-cadre relay — can recompute it: a sibling's relay sees an
9
+ * unknown peerId it has no way to place as a member.
10
+ *
11
+ * The resolution (docs/strands.md → "Relay willingness"): a party control
12
+ * node running a relay is party-private infrastructure — it relays for its own
13
+ * party's nodes, including the extra transport identities its members' strand
14
+ * nodes run as. The mechanism is a **member-announced delegate grant**: before
15
+ * a member's control node starts a strand node, it tells its siblings (over the
16
+ * already-authenticated `/sereus/strand-addr/1.0.0` RPC) the derived peerId
17
+ * that strand node will use, and the receiver holds a short-lived, in-memory
18
+ * admission grant for exactly that peerId. The grant is consulted by the
19
+ * connection-level gate (`CadreNode.admitInboundControlConnection`) and by the
20
+ * relay-reservation admission (`CadreNode.admitControlRelayReservation`) — a
21
+ * granted delegate is admitted outright, never drawing on the small
22
+ * unauthorized-reservation budget that unplaced peers share
23
+ * (`membership-connection-gater.ts` → "The relay-reservation seam") and never
24
+ * put on the provisional connection deadline. The fail-closed per-stream gate
25
+ * (`authorizeInboundControlStream`) never honors a grant, so a delegate gets
26
+ * the connection and its reservation (all a circuit-relay `hop` needs) and
27
+ * nothing more.
28
+ *
29
+ * A grant is a delegation of trust to an already-trusted member: the receiver
30
+ * cannot verify that the announced peerId really is the member's strand node,
31
+ * so an authorized member can hand CONNECTION-level access to a peerId of its
32
+ * choosing for up to {@link DELEGATE_GRANT_TTL_MS}. The caps below bound how
33
+ * far a buggy or compromised member can stretch that.
34
+ *
35
+ * The receiver side is {@link DelegateAdmissionStore}. The announcer side keeps
36
+ * its own throttle state (a "last announced at" map that `CadreNode` owns) and
37
+ * decides from it, per reconcile pass, which relays are due a re-announce —
38
+ * {@link dueRelayAnnounces} and {@link prunePeerStrandKeys} are that
39
+ * decision, pure and testable, kept here beside the TTL they are derived from.
40
+ */
41
+
42
+ import debug from 'debug';
43
+ import { multiaddr } from '@multiformats/multiaddr';
44
+ import { peerIdFromString } from '@libp2p/peer-id';
45
+ import type { StrandAddrOutcome } from './strand-addr-protocol.js';
46
+
47
+ const log = debug('sereus:cadre:delegate-admission');
48
+
49
+ /**
50
+ * How long one announced delegate grant stays valid without a re-announce.
51
+ * Announcers refresh at half this (`CadreNode`'s reconcile pass), so a live
52
+ * strand's grant never lapses while its control node can still reach the relay.
53
+ */
54
+ export const DELEGATE_GRANT_TTL_MS = 30 * 60 * 1000;
55
+
56
+ /** Cap on live grants per announcing member (soonest-expiry evicted first). */
57
+ export const MAX_DELEGATE_GRANTS_PER_MEMBER = 32;
58
+
59
+ /** Cap on live grants in total (soonest-expiry evicted first). */
60
+ export const MAX_DELEGATE_GRANTS = 256;
61
+
62
+ /** One recorded delegate admission grant. */
63
+ interface DelegateGrant {
64
+ /** The authorized member whose control node announced this delegate. */
65
+ announcerPeerId: string;
66
+ /** The strand the delegate serves — diagnostics + per-strand replace semantics. */
67
+ strandId: string;
68
+ /** The delegate transport peerId the grant admits. */
69
+ delegatePeerId: string;
70
+ /** Epoch ms after which the grant is inert. */
71
+ expiresAt: number;
72
+ }
73
+
74
+ /**
75
+ * Composite map key for per-(peer, strand) state: the receiver's
76
+ * replace-per-(announcer, strand) grant, the announcer's per-(target, strand)
77
+ * throttle timestamp, and `CadreNode`'s per-(sibling, strand) strand-addr ask
78
+ * due time.
79
+ */
80
+ export function peerStrandKey(peerId: string, strandId: string): string {
81
+ return `${peerId}\n${strandId}`;
82
+ }
83
+
84
+ /** The `peerId` half of a {@link peerStrandKey} (a peer id never contains a newline). */
85
+ function peerIdOfKey(key: string): string {
86
+ return key.slice(0, key.indexOf('\n'));
87
+ }
88
+
89
+ /** The `strandId` half of a {@link peerStrandKey}. */
90
+ function strandIdOfKey(key: string): string {
91
+ return key.slice(key.indexOf('\n') + 1);
92
+ }
93
+
94
+ /**
95
+ * In-memory store of delegate admission grants, keyed by (announcer, strandId)
96
+ * so a re-announce REPLACES the previous delegate peerId rather than
97
+ * accumulating — a restarted strand cannot leak grants. Expired entries are
98
+ * pruned lazily on every read/write. Injectable `now` keeps expiry testable
99
+ * without fake timers.
100
+ */
101
+ export class DelegateAdmissionStore {
102
+ private readonly grants = new Map<string, DelegateGrant>();
103
+ private readonly ttlMs: number;
104
+
105
+ constructor(ttlMs: number = DELEGATE_GRANT_TTL_MS) {
106
+ this.ttlMs = ttlMs;
107
+ }
108
+
109
+ /** Number of live (unexpired at last prune) grants — test/diagnostic surface. */
110
+ get size(): number {
111
+ return this.grants.size;
112
+ }
113
+
114
+ /**
115
+ * Record (or refresh) a grant: `delegatePeerId` acts on behalf of
116
+ * `announcerPeerId` for `strandId`, valid for the store's TTL from `now`.
117
+ * Enforces {@link MAX_DELEGATE_GRANTS_PER_MEMBER} and
118
+ * {@link MAX_DELEGATE_GRANTS}, evicting the soonest-expiry grant first —
119
+ * biased against the stalest delegation, never against the fresh announce.
120
+ */
121
+ grant(announcerPeerId: string, strandId: string, delegatePeerId: string, now: number = Date.now()): void {
122
+ this.prune(now);
123
+ const key = peerStrandKey(announcerPeerId, strandId);
124
+ if (!this.grants.has(key)) {
125
+ this.evictAtCap(
126
+ MAX_DELEGATE_GRANTS_PER_MEMBER,
127
+ (g) => g.announcerPeerId === announcerPeerId
128
+ );
129
+ this.evictAtCap(MAX_DELEGATE_GRANTS, () => true);
130
+ }
131
+ this.grants.set(key, { announcerPeerId, strandId, delegatePeerId, expiresAt: now + this.ttlMs });
132
+ log('Delegate grant recorded: %s → %s (strand %s, %d live)', announcerPeerId, delegatePeerId, strandId, this.grants.size);
133
+ }
134
+
135
+ /**
136
+ * Drop every grant. Called when the owning node stops: grants are scoped to
137
+ * the session that recorded them, and outliving it would admit delegates
138
+ * announced against a node that no longer exists.
139
+ */
140
+ clear(): void {
141
+ this.grants.clear();
142
+ }
143
+
144
+ /** Is `remotePeerId` covered by a live grant? Prunes expired entries as it goes. */
145
+ has(remotePeerId: string, now: number = Date.now()): boolean {
146
+ this.prune(now);
147
+ for (const grant of this.grants.values()) {
148
+ if (grant.delegatePeerId === remotePeerId) {
149
+ return true;
150
+ }
151
+ }
152
+ return false;
153
+ }
154
+
155
+ /** Drop every grant whose expiry has passed. */
156
+ private prune(now: number): void {
157
+ for (const [key, grant] of this.grants) {
158
+ if (grant.expiresAt <= now) {
159
+ this.grants.delete(key);
160
+ }
161
+ }
162
+ }
163
+
164
+ /**
165
+ * If the grants matching `filter` are at `cap`, evict the soonest-expiry one
166
+ * among them to make room for the insert the caller is about to do.
167
+ */
168
+ private evictAtCap(cap: number, filter: (grant: DelegateGrant) => boolean): void {
169
+ let count = 0;
170
+ let victimKey: string | undefined;
171
+ let victimExpiry = Number.POSITIVE_INFINITY;
172
+ for (const [key, grant] of this.grants) {
173
+ if (!filter(grant)) {
174
+ continue;
175
+ }
176
+ count++;
177
+ if (grant.expiresAt < victimExpiry) {
178
+ victimExpiry = grant.expiresAt;
179
+ victimKey = key;
180
+ }
181
+ }
182
+ if (count >= cap && victimKey !== undefined) {
183
+ const victim = this.grants.get(victimKey)!;
184
+ this.grants.delete(victimKey);
185
+ log('Delegate grant cap %d hit — evicted soonest-expiry grant %s → %s (strand %s)',
186
+ cap, victim.announcerPeerId, victim.delegatePeerId, victim.strandId);
187
+ }
188
+ }
189
+ }
190
+
191
+ /** A circuit relay named by a multiaddr, as an announce target. */
192
+ export interface CircuitRelayTarget {
193
+ /** The relay's peerId — the `<relay>` in `…/p2p/<relay>/p2p-circuit…`. */
194
+ relayPeerId: string;
195
+ /**
196
+ * The relay's DIRECT dial multiaddr (the prefix before `/p2p-circuit`),
197
+ * usable as a dial fallback when no connection to the relay is open yet.
198
+ */
199
+ relayAddr: string;
200
+ }
201
+
202
+ /**
203
+ * Extract the circuit relays named by a multiaddr list — for each
204
+ * `…/p2p/<relay>/p2p-circuit…` entry, the relay's peerId and its direct dial
205
+ * prefix. A bare `/p2p-circuit` (no relay named), a non-circuit addr, an
206
+ * unparsable addr, and an unparsable relay peerId are all skipped. A trailing
207
+ * `/p2p/<dst>` after the circuit component names the DESTINATION, never the
208
+ * relay, and is ignored. Deduplicated by relay peerId (first addr wins).
209
+ */
210
+ export function extractCircuitRelayTargets(addrs: string[]): CircuitRelayTarget[] {
211
+ const byRelay = new Map<string, CircuitRelayTarget>();
212
+ for (const addr of addrs) {
213
+ const target = circuitRelayTarget(addr);
214
+ if (target && !byRelay.has(target.relayPeerId)) {
215
+ byRelay.set(target.relayPeerId, target);
216
+ }
217
+ }
218
+ return [...byRelay.values()];
219
+ }
220
+
221
+ /**
222
+ * Of `relays`, the ones whose grant for `strandId` is due a re-announce: never
223
+ * announced to, or last announced at least half the TTL ago. Half, so a failed
224
+ * refresh has one more attempt before the grant lapses — a lapsed grant means
225
+ * the relay denies the strand node's reservation re-dial.
226
+ *
227
+ * `announceAt` is the announcer-side "last announced at" map, keyed by
228
+ * {@link peerStrandKey} on the peer announced TO.
229
+ */
230
+ export function dueRelayAnnounces(
231
+ announceAt: ReadonlyMap<string, number>,
232
+ relays: readonly CircuitRelayTarget[],
233
+ strandId: string,
234
+ now: number,
235
+ ttlMs: number = DELEGATE_GRANT_TTL_MS
236
+ ): CircuitRelayTarget[] {
237
+ return relays.filter(
238
+ (relay) => now - (announceAt.get(peerStrandKey(relay.relayPeerId, strandId)) ?? 0) >= ttlMs / 2
239
+ );
240
+ }
241
+
242
+ /**
243
+ * Fold one announce pass's outcomes into `refusing`, the relay peerIds remembered
244
+ * as refusing the strand-addr protocol. A relay whose negotiation refused it
245
+ * (`unsupported`) is added; one that replied with any status is removed, because
246
+ * it serves the protocol now. A relay that did not reply (`unreachable`) is left
247
+ * as it was: being down says nothing about what it serves. Only `relayPeerIds`
248
+ * are read, so a sibling asked in the same pass is never recorded. Mutates in
249
+ * place.
250
+ */
251
+ export function recordRelayRefusals(
252
+ refusing: Set<string>,
253
+ relayPeerIds: readonly string[],
254
+ outcomes: ReadonlyMap<string, StrandAddrOutcome>
255
+ ): void {
256
+ for (const relayPeerId of relayPeerIds) {
257
+ switch (outcomes.get(relayPeerId)) {
258
+ case 'unsupported':
259
+ refusing.add(relayPeerId);
260
+ break;
261
+ case 'answered':
262
+ case 'empty':
263
+ case 'unavailable':
264
+ refusing.delete(relayPeerId);
265
+ break;
266
+ }
267
+ }
268
+ }
269
+
270
+ /** Of `relays`, the ones not remembered in `refusing` (see {@link recordRelayRefusals}). */
271
+ export function relaysNotRefusing(
272
+ relays: readonly CircuitRelayTarget[],
273
+ refusing: ReadonlySet<string>
274
+ ): CircuitRelayTarget[] {
275
+ return relays.filter((relay) => !refusing.has(relay.relayPeerId));
276
+ }
277
+
278
+ /**
279
+ * Drop the entries of a {@link peerStrandKey}-keyed map whose strand is no longer
280
+ * running, and — when `livePeerIds` is given — whose peer is not among them. A
281
+ * stopped strand needs no refresh, a departed peer is owed a fresh start when it
282
+ * returns, and without this the map grows for the node's lifetime. Mutates in
283
+ * place.
284
+ */
285
+ export function prunePeerStrandKeys(
286
+ byKey: Map<string, number>,
287
+ runningStrandIds: ReadonlySet<string>,
288
+ livePeerIds?: ReadonlySet<string>
289
+ ): void {
290
+ for (const key of byKey.keys()) {
291
+ const peerGone = livePeerIds !== undefined && !livePeerIds.has(peerIdOfKey(key));
292
+ if (peerGone || !runningStrandIds.has(strandIdOfKey(key))) {
293
+ byKey.delete(key);
294
+ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * The circuit relay named by `addr`, throwing when `addr` is unparsable or names
300
+ * no relay. The throwing form exists for CONFIG-facing callers (`relay-addrs.ts`),
301
+ * where a typo must fail loudly rather than cost the node its reachability;
302
+ * {@link extractCircuitRelayTargets} reads runtime-discovered addrs instead and
303
+ * logs-and-skips.
304
+ */
305
+ export function circuitRelayTargetOrThrow(addr: string): CircuitRelayTarget {
306
+ const target = parseCircuitRelayTarget(addr);
307
+ if (!target) {
308
+ throw new Error(`Multiaddr names no circuit relay (expected …/p2p/<relayPeerId>/p2p-circuit): ${addr}`);
309
+ }
310
+ return target;
311
+ }
312
+
313
+ /**
314
+ * The circuit relay named by `addr`; null when `addr` is well-formed but simply
315
+ * names no relay (a direct listen addr, a bare `/p2p-circuit`), and THROWS when
316
+ * `addr` is malformed (unparsable multiaddr, garbage relay peerId). The two
317
+ * callers want opposite things from the first case and the same thing from the
318
+ * second, so the distinction lives here rather than in either of them.
319
+ */
320
+ function parseCircuitRelayTarget(addr: string): CircuitRelayTarget | null {
321
+ const ma = multiaddr(addr);
322
+ const components = ma.getComponents();
323
+ const circuitIdx = components.findIndex((c) => c.name === 'p2p-circuit');
324
+ if (circuitIdx < 1) {
325
+ return null;
326
+ }
327
+ const relay = components[circuitIdx - 1];
328
+ if (relay.name !== 'p2p' || !relay.value) {
329
+ return null;
330
+ }
331
+ peerIdFromString(relay.value); // validate; throws on garbage
332
+ return {
333
+ relayPeerId: relay.value,
334
+ relayAddr: ma.decapsulate('/p2p-circuit').toString()
335
+ };
336
+ }
337
+
338
+ /**
339
+ * One-addr body of {@link extractCircuitRelayTargets}; null when `addr` names no
340
+ * relay. Only a MALFORMED addr logs — the addr lists this reads (a node's live
341
+ * multiaddrs, its configured listen addrs) are mostly direct addrs, and calling
342
+ * each of those "unparsable" is both wrong and per-announce noise.
343
+ */
344
+ function circuitRelayTarget(addr: string): CircuitRelayTarget | null {
345
+ try {
346
+ return parseCircuitRelayTarget(addr);
347
+ } catch (err) {
348
+ log('Skipping unparsable relay addr %s: %o', addr, err);
349
+ return null;
350
+ }
351
+ }