@serfab/cadre-core 1.16.0 → 1.18.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 (268) hide show
  1. package/README.md +4 -3
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +81 -142
  6. package/dist/cadre-invite-protocol.js +168 -458
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +1089 -475
  12. package/dist/cadre-node.js +2170 -1037
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +88 -32
  18. package/dist/control-cohort.js +91 -33
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +856 -318
  21. package/dist/control-database.js +1422 -701
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-formation-recorder.d.ts +46 -43
  24. package/dist/control-formation-recorder.js +77 -78
  25. package/dist/control-formation-recorder.js.map +1 -1
  26. package/dist/control-protocol-guard.d.ts +11 -4
  27. package/dist/control-protocol-guard.js +20 -5
  28. package/dist/control-protocol-guard.js.map +1 -1
  29. package/dist/control-schema-version.d.ts +97 -0
  30. package/dist/control-schema-version.js +225 -0
  31. package/dist/control-schema-version.js.map +1 -0
  32. package/dist/control-schema.d.ts +45 -5
  33. package/dist/control-schema.js +262 -78
  34. package/dist/control-schema.js.map +1 -1
  35. package/dist/control-write-retry.d.ts +45 -10
  36. package/dist/control-write-retry.js +92 -22
  37. package/dist/control-write-retry.js.map +1 -1
  38. package/dist/delegate-admission.d.ts +13 -0
  39. package/dist/delegate-admission.js +27 -0
  40. package/dist/delegate-admission.js.map +1 -1
  41. package/dist/ed25519-key.d.ts +10 -7
  42. package/dist/ed25519-key.js +23 -20
  43. package/dist/ed25519-key.js.map +1 -1
  44. package/dist/edge-node/config.d.ts +122 -0
  45. package/dist/edge-node/config.js +199 -0
  46. package/dist/edge-node/config.js.map +1 -0
  47. package/dist/edge-node/dissolve.d.ts +95 -0
  48. package/dist/edge-node/dissolve.js +184 -0
  49. package/dist/edge-node/dissolve.js.map +1 -0
  50. package/dist/edge-node/edge-join.d.ts +44 -0
  51. package/dist/edge-node/edge-join.js +227 -0
  52. package/dist/edge-node/edge-join.js.map +1 -0
  53. package/dist/edge-node/edge-node.d.ts +295 -0
  54. package/dist/edge-node/edge-node.js +704 -0
  55. package/dist/edge-node/edge-node.js.map +1 -0
  56. package/dist/edge-node/index.d.ts +18 -0
  57. package/dist/edge-node/index.js +18 -0
  58. package/dist/edge-node/index.js.map +1 -0
  59. package/dist/edge-node/join-credential.d.ts +11 -0
  60. package/dist/edge-node/join-credential.js +11 -0
  61. package/dist/edge-node/join-credential.js.map +1 -0
  62. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  63. package/dist/edge-node/lazy-raw-storage.js +134 -0
  64. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  65. package/dist/edge-node/node-local.d.ts +60 -0
  66. package/dist/edge-node/node-local.js +58 -0
  67. package/dist/edge-node/node-local.js.map +1 -0
  68. package/dist/edge-node/options.d.ts +83 -0
  69. package/dist/edge-node/options.js +110 -0
  70. package/dist/edge-node/options.js.map +1 -0
  71. package/dist/edge-node/owner-grant.d.ts +41 -0
  72. package/dist/edge-node/owner-grant.js +38 -0
  73. package/dist/edge-node/owner-grant.js.map +1 -0
  74. package/dist/edge-node/platform.d.ts +140 -0
  75. package/dist/edge-node/platform.js +12 -0
  76. package/dist/edge-node/platform.js.map +1 -0
  77. package/dist/edge-node/strand-move.d.ts +115 -0
  78. package/dist/edge-node/strand-move.js +223 -0
  79. package/dist/edge-node/strand-move.js.map +1 -0
  80. package/dist/edge-node/strands.d.ts +28 -0
  81. package/dist/edge-node/strands.js +60 -0
  82. package/dist/edge-node/strands.js.map +1 -0
  83. package/dist/enrolled-machine-store.d.ts +3 -3
  84. package/dist/enrolled-machine-store.js +3 -3
  85. package/dist/formation-token.d.ts +48 -0
  86. package/dist/formation-token.js +26 -0
  87. package/dist/formation-token.js.map +1 -0
  88. package/dist/index.d.ts +27 -17
  89. package/dist/index.js +39 -17
  90. package/dist/index.js.map +1 -1
  91. package/dist/joined-strand-store.d.ts +29 -12
  92. package/dist/joined-strand-store.js +66 -27
  93. package/dist/joined-strand-store.js.map +1 -1
  94. package/dist/key-store.d.ts +9 -4
  95. package/dist/key-store.js +9 -4
  96. package/dist/key-store.js.map +1 -1
  97. package/dist/link-budget.d.ts +9 -7
  98. package/dist/link-budget.js +9 -7
  99. package/dist/link-budget.js.map +1 -1
  100. package/dist/machine-removal.d.ts +19 -39
  101. package/dist/machine-removal.js +24 -98
  102. package/dist/machine-removal.js.map +1 -1
  103. package/dist/member-redemption.d.ts +256 -0
  104. package/dist/member-redemption.js +490 -0
  105. package/dist/member-redemption.js.map +1 -0
  106. package/dist/membership-view.d.ts +91 -0
  107. package/dist/membership-view.js +110 -0
  108. package/dist/membership-view.js.map +1 -0
  109. package/dist/owner-anchor-sync.d.ts +4 -4
  110. package/dist/owner-anchor-sync.js +16 -15
  111. package/dist/owner-anchor-sync.js.map +1 -1
  112. package/dist/owner-grant-protocol.d.ts +251 -0
  113. package/dist/owner-grant-protocol.js +344 -0
  114. package/dist/owner-grant-protocol.js.map +1 -0
  115. package/dist/owner-key-export.d.ts +70 -0
  116. package/dist/owner-key-export.js +279 -0
  117. package/dist/owner-key-export.js.map +1 -0
  118. package/dist/owner-key-flows.d.ts +55 -0
  119. package/dist/owner-key-flows.js +60 -0
  120. package/dist/owner-key-flows.js.map +1 -0
  121. package/dist/owner-key-store.d.ts +31 -0
  122. package/dist/owner-key-store.js +81 -0
  123. package/dist/owner-key-store.js.map +1 -0
  124. package/dist/owner-keys.d.ts +78 -0
  125. package/dist/owner-keys.js +133 -0
  126. package/dist/owner-keys.js.map +1 -0
  127. package/dist/owner-signer.d.ts +54 -0
  128. package/dist/owner-signer.js +51 -0
  129. package/dist/owner-signer.js.map +1 -0
  130. package/dist/peer-addr-book.d.ts +10 -1
  131. package/dist/peer-addr-book.js +4 -3
  132. package/dist/peer-addr-book.js.map +1 -1
  133. package/dist/peer-authorization.d.ts +83 -18
  134. package/dist/peer-authorization.js +123 -53
  135. package/dist/peer-authorization.js.map +1 -1
  136. package/dist/peer-join-backfill.d.ts +89 -20
  137. package/dist/peer-join-backfill.js +265 -108
  138. package/dist/peer-join-backfill.js.map +1 -1
  139. package/dist/pending-join-runner.d.ts +48 -18
  140. package/dist/pending-join-runner.js +74 -56
  141. package/dist/pending-join-runner.js.map +1 -1
  142. package/dist/provider/rent-node.js +1 -1
  143. package/dist/provider/rent-node.js.map +1 -1
  144. package/dist/seed-bootstrap.d.ts +162 -133
  145. package/dist/seed-bootstrap.js +300 -283
  146. package/dist/seed-bootstrap.js.map +1 -1
  147. package/dist/seed-trust-policy.js +4 -4
  148. package/dist/strand-addr-protocol.d.ts +4 -1
  149. package/dist/strand-addr-protocol.js +8 -2
  150. package/dist/strand-addr-protocol.js.map +1 -1
  151. package/dist/strand-cohort.d.ts +3 -0
  152. package/dist/strand-cohort.js.map +1 -1
  153. package/dist/strand-database.d.ts +24 -2
  154. package/dist/strand-database.js +29 -6
  155. package/dist/strand-database.js.map +1 -1
  156. package/dist/strand-first-sync-gate.d.ts +176 -88
  157. package/dist/strand-first-sync-gate.js +288 -121
  158. package/dist/strand-first-sync-gate.js.map +1 -1
  159. package/dist/strand-formation-deadlines.d.ts +9 -9
  160. package/dist/strand-formation-deadlines.js +9 -9
  161. package/dist/strand-formation-manager.d.ts +23 -17
  162. package/dist/strand-formation-manager.js +46 -44
  163. package/dist/strand-formation-manager.js.map +1 -1
  164. package/dist/strand-formation-protocol.d.ts +5 -8
  165. package/dist/strand-formation-protocol.js +101 -16
  166. package/dist/strand-formation-protocol.js.map +1 -1
  167. package/dist/strand-instance-manager.d.ts +27 -18
  168. package/dist/strand-instance-manager.js +119 -50
  169. package/dist/strand-instance-manager.js.map +1 -1
  170. package/dist/strand-membership-reconciler.d.ts +16 -13
  171. package/dist/strand-membership-reconciler.js +30 -22
  172. package/dist/strand-membership-reconciler.js.map +1 -1
  173. package/dist/strand-membership-writer.d.ts +45 -22
  174. package/dist/strand-membership-writer.js +226 -131
  175. package/dist/strand-membership-writer.js.map +1 -1
  176. package/dist/strand-move.d.ts +6 -5
  177. package/dist/strand-move.js +36 -32
  178. package/dist/strand-move.js.map +1 -1
  179. package/dist/strand-peer-redial.js +2 -3
  180. package/dist/strand-peer-redial.js.map +1 -1
  181. package/dist/strand-revocation-enforcer.d.ts +134 -60
  182. package/dist/strand-revocation-enforcer.js +144 -71
  183. package/dist/strand-revocation-enforcer.js.map +1 -1
  184. package/dist/strand-solicitation.d.ts +50 -39
  185. package/dist/strand-solicitation.js +10 -3
  186. package/dist/strand-solicitation.js.map +1 -1
  187. package/dist/strand-watcher.d.ts +80 -13
  188. package/dist/strand-watcher.js +152 -48
  189. package/dist/strand-watcher.js.map +1 -1
  190. package/dist/table-change-watch.d.ts +63 -0
  191. package/dist/table-change-watch.js +209 -0
  192. package/dist/table-change-watch.js.map +1 -0
  193. package/dist/timeout-scheduler.d.ts +4 -3
  194. package/dist/timeout-scheduler.js +4 -3
  195. package/dist/timeout-scheduler.js.map +1 -1
  196. package/dist/types.d.ts +374 -150
  197. package/dist/types.js +4 -4
  198. package/dist/types.js.map +1 -1
  199. package/package.json +17 -8
  200. package/src/bootstrap-peer-store-file.ts +44 -44
  201. package/src/bootstrap-peer-store.ts +214 -214
  202. package/src/cadre-invite-protocol.ts +740 -1052
  203. package/src/cadre-join-request.ts +24 -3
  204. package/src/cadre-node.ts +2402 -1079
  205. package/src/control-authorization.ts +47 -14
  206. package/src/control-cohort.ts +204 -128
  207. package/src/control-database.ts +1749 -820
  208. package/src/control-formation-recorder.ts +359 -353
  209. package/src/control-protocol-guard.ts +23 -6
  210. package/src/control-schema-version.ts +301 -0
  211. package/src/control-schema.ts +273 -78
  212. package/src/control-write-retry.ts +641 -567
  213. package/src/delegate-admission.ts +351 -314
  214. package/src/ed25519-key.ts +174 -171
  215. package/src/edge-node/config.ts +277 -0
  216. package/src/edge-node/dissolve.ts +251 -0
  217. package/src/edge-node/edge-join.ts +319 -0
  218. package/src/edge-node/edge-node.ts +1045 -0
  219. package/src/edge-node/index.ts +59 -0
  220. package/src/edge-node/join-credential.ts +16 -0
  221. package/src/edge-node/lazy-raw-storage.ts +178 -0
  222. package/src/edge-node/node-local.ts +84 -0
  223. package/src/edge-node/options.ts +179 -0
  224. package/src/edge-node/owner-grant.ts +63 -0
  225. package/src/edge-node/platform.ts +151 -0
  226. package/src/edge-node/strand-move.ts +270 -0
  227. package/src/edge-node/strands.ts +72 -0
  228. package/src/enrolled-machine-store.ts +3 -3
  229. package/src/formation-token.ts +63 -0
  230. package/src/index.ts +899 -789
  231. package/src/joined-strand-store.ts +73 -28
  232. package/src/key-store.ts +10 -4
  233. package/src/link-budget.ts +9 -7
  234. package/src/machine-removal.ts +26 -111
  235. package/src/member-redemption.ts +649 -0
  236. package/src/membership-view.ts +144 -0
  237. package/src/owner-anchor-sync.ts +19 -18
  238. package/src/owner-grant-protocol.ts +547 -0
  239. package/src/owner-key-export.ts +348 -0
  240. package/src/owner-key-flows.ts +109 -0
  241. package/src/owner-key-store.ts +85 -0
  242. package/src/owner-keys.ts +178 -0
  243. package/src/owner-signer.ts +94 -0
  244. package/src/peer-addr-book.ts +4 -3
  245. package/src/peer-authorization.ts +558 -488
  246. package/src/peer-join-backfill.ts +971 -761
  247. package/src/pending-join-runner.ts +76 -53
  248. package/src/provider/rent-node.ts +1 -1
  249. package/src/seed-bootstrap.ts +343 -349
  250. package/src/seed-trust-policy.ts +4 -4
  251. package/src/strand-addr-protocol.ts +547 -538
  252. package/src/strand-cohort.ts +4 -0
  253. package/src/strand-database.ts +352 -319
  254. package/src/strand-first-sync-gate.ts +334 -133
  255. package/src/strand-formation-deadlines.ts +227 -227
  256. package/src/strand-formation-manager.ts +731 -725
  257. package/src/strand-formation-protocol.ts +122 -24
  258. package/src/strand-instance-manager.ts +2092 -2026
  259. package/src/strand-membership-reconciler.ts +33 -25
  260. package/src/strand-membership-writer.ts +2093 -1974
  261. package/src/strand-move.ts +50 -38
  262. package/src/strand-peer-redial.ts +2 -3
  263. package/src/strand-revocation-enforcer.ts +740 -654
  264. package/src/strand-solicitation.ts +61 -34
  265. package/src/strand-watcher.ts +175 -56
  266. package/src/table-change-watch.ts +285 -0
  267. package/src/timeout-scheduler.ts +29 -28
  268. package/src/types.ts +391 -152
@@ -1,314 +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
-
46
- const log = debug('sereus:cadre:delegate-admission');
47
-
48
- /**
49
- * How long one announced delegate grant stays valid without a re-announce.
50
- * Announcers refresh at half this (`CadreNode`'s reconcile pass), so a live
51
- * strand's grant never lapses while its control node can still reach the relay.
52
- */
53
- export const DELEGATE_GRANT_TTL_MS = 30 * 60 * 1000;
54
-
55
- /** Cap on live grants per announcing member (soonest-expiry evicted first). */
56
- export const MAX_DELEGATE_GRANTS_PER_MEMBER = 32;
57
-
58
- /** Cap on live grants in total (soonest-expiry evicted first). */
59
- export const MAX_DELEGATE_GRANTS = 256;
60
-
61
- /** One recorded delegate admission grant. */
62
- interface DelegateGrant {
63
- /** The authorized member whose control node announced this delegate. */
64
- announcerPeerId: string;
65
- /** The strand the delegate serves — diagnostics + per-strand replace semantics. */
66
- strandId: string;
67
- /** The delegate transport peerId the grant admits. */
68
- delegatePeerId: string;
69
- /** Epoch ms after which the grant is inert. */
70
- expiresAt: number;
71
- }
72
-
73
- /**
74
- * Composite map key for per-(peer, strand) state: the receiver's
75
- * replace-per-(announcer, strand) grant, the announcer's per-(target, strand)
76
- * throttle timestamp, and `CadreNode`'s per-(sibling, strand) strand-addr ask
77
- * due time.
78
- */
79
- export function peerStrandKey(peerId: string, strandId: string): string {
80
- return `${peerId}\n${strandId}`;
81
- }
82
-
83
- /** The `peerId` half of a {@link peerStrandKey} (a peer id never contains a newline). */
84
- function peerIdOfKey(key: string): string {
85
- return key.slice(0, key.indexOf('\n'));
86
- }
87
-
88
- /** The `strandId` half of a {@link peerStrandKey}. */
89
- function strandIdOfKey(key: string): string {
90
- return key.slice(key.indexOf('\n') + 1);
91
- }
92
-
93
- /**
94
- * In-memory store of delegate admission grants, keyed by (announcer, strandId)
95
- * so a re-announce REPLACES the previous delegate peerId rather than
96
- * accumulating — a restarted strand cannot leak grants. Expired entries are
97
- * pruned lazily on every read/write. Injectable `now` keeps expiry testable
98
- * without fake timers.
99
- */
100
- export class DelegateAdmissionStore {
101
- private readonly grants = new Map<string, DelegateGrant>();
102
- private readonly ttlMs: number;
103
-
104
- constructor(ttlMs: number = DELEGATE_GRANT_TTL_MS) {
105
- this.ttlMs = ttlMs;
106
- }
107
-
108
- /** Number of live (unexpired at last prune) grants — test/diagnostic surface. */
109
- get size(): number {
110
- return this.grants.size;
111
- }
112
-
113
- /**
114
- * Record (or refresh) a grant: `delegatePeerId` acts on behalf of
115
- * `announcerPeerId` for `strandId`, valid for the store's TTL from `now`.
116
- * Enforces {@link MAX_DELEGATE_GRANTS_PER_MEMBER} and
117
- * {@link MAX_DELEGATE_GRANTS}, evicting the soonest-expiry grant first —
118
- * biased against the stalest delegation, never against the fresh announce.
119
- */
120
- grant(announcerPeerId: string, strandId: string, delegatePeerId: string, now: number = Date.now()): void {
121
- this.prune(now);
122
- const key = peerStrandKey(announcerPeerId, strandId);
123
- if (!this.grants.has(key)) {
124
- this.evictAtCap(
125
- MAX_DELEGATE_GRANTS_PER_MEMBER,
126
- (g) => g.announcerPeerId === announcerPeerId
127
- );
128
- this.evictAtCap(MAX_DELEGATE_GRANTS, () => true);
129
- }
130
- this.grants.set(key, { announcerPeerId, strandId, delegatePeerId, expiresAt: now + this.ttlMs });
131
- log('Delegate grant recorded: %s → %s (strand %s, %d live)', announcerPeerId, delegatePeerId, strandId, this.grants.size);
132
- }
133
-
134
- /**
135
- * Drop every grant. Called when the owning node stops: grants are scoped to
136
- * the session that recorded them, and outliving it would admit delegates
137
- * announced against a node that no longer exists.
138
- */
139
- clear(): void {
140
- this.grants.clear();
141
- }
142
-
143
- /** Is `remotePeerId` covered by a live grant? Prunes expired entries as it goes. */
144
- has(remotePeerId: string, now: number = Date.now()): boolean {
145
- this.prune(now);
146
- for (const grant of this.grants.values()) {
147
- if (grant.delegatePeerId === remotePeerId) {
148
- return true;
149
- }
150
- }
151
- return false;
152
- }
153
-
154
- /** Drop every grant whose expiry has passed. */
155
- private prune(now: number): void {
156
- for (const [key, grant] of this.grants) {
157
- if (grant.expiresAt <= now) {
158
- this.grants.delete(key);
159
- }
160
- }
161
- }
162
-
163
- /**
164
- * If the grants matching `filter` are at `cap`, evict the soonest-expiry one
165
- * among them to make room for the insert the caller is about to do.
166
- */
167
- private evictAtCap(cap: number, filter: (grant: DelegateGrant) => boolean): void {
168
- let count = 0;
169
- let victimKey: string | undefined;
170
- let victimExpiry = Number.POSITIVE_INFINITY;
171
- for (const [key, grant] of this.grants) {
172
- if (!filter(grant)) {
173
- continue;
174
- }
175
- count++;
176
- if (grant.expiresAt < victimExpiry) {
177
- victimExpiry = grant.expiresAt;
178
- victimKey = key;
179
- }
180
- }
181
- if (count >= cap && victimKey !== undefined) {
182
- const victim = this.grants.get(victimKey)!;
183
- this.grants.delete(victimKey);
184
- log('Delegate grant cap %d hit — evicted soonest-expiry grant %s → %s (strand %s)',
185
- cap, victim.announcerPeerId, victim.delegatePeerId, victim.strandId);
186
- }
187
- }
188
- }
189
-
190
- /** A circuit relay named by a multiaddr, as an announce target. */
191
- export interface CircuitRelayTarget {
192
- /** The relay's peerId — the `<relay>` in `…/p2p/<relay>/p2p-circuit…`. */
193
- relayPeerId: string;
194
- /**
195
- * The relay's DIRECT dial multiaddr (the prefix before `/p2p-circuit`),
196
- * usable as a dial fallback when no connection to the relay is open yet.
197
- */
198
- relayAddr: string;
199
- }
200
-
201
- /**
202
- * Extract the circuit relays named by a multiaddr list — for each
203
- * `…/p2p/<relay>/p2p-circuit…` entry, the relay's peerId and its direct dial
204
- * prefix. A bare `/p2p-circuit` (no relay named), a non-circuit addr, an
205
- * unparsable addr, and an unparsable relay peerId are all skipped. A trailing
206
- * `/p2p/<dst>` after the circuit component names the DESTINATION, never the
207
- * relay, and is ignored. Deduplicated by relay peerId (first addr wins).
208
- */
209
- export function extractCircuitRelayTargets(addrs: string[]): CircuitRelayTarget[] {
210
- const byRelay = new Map<string, CircuitRelayTarget>();
211
- for (const addr of addrs) {
212
- const target = circuitRelayTarget(addr);
213
- if (target && !byRelay.has(target.relayPeerId)) {
214
- byRelay.set(target.relayPeerId, target);
215
- }
216
- }
217
- return [...byRelay.values()];
218
- }
219
-
220
- /**
221
- * Of `relays`, the ones whose grant for `strandId` is due a re-announce: never
222
- * announced to, or last announced at least half the TTL ago. Half, so a failed
223
- * refresh has one more attempt before the grant lapses — a lapsed grant means
224
- * the relay denies the strand node's reservation re-dial.
225
- *
226
- * `announceAt` is the announcer-side "last announced at" map, keyed by
227
- * {@link peerStrandKey} on the peer announced TO.
228
- */
229
- export function dueRelayAnnounces(
230
- announceAt: ReadonlyMap<string, number>,
231
- relays: readonly CircuitRelayTarget[],
232
- strandId: string,
233
- now: number,
234
- ttlMs: number = DELEGATE_GRANT_TTL_MS
235
- ): CircuitRelayTarget[] {
236
- return relays.filter(
237
- (relay) => now - (announceAt.get(peerStrandKey(relay.relayPeerId, strandId)) ?? 0) >= ttlMs / 2
238
- );
239
- }
240
-
241
- /**
242
- * Drop the entries of a {@link peerStrandKey}-keyed map whose strand is no longer
243
- * running, and — when `livePeerIds` is given — whose peer is not among them. A
244
- * stopped strand needs no refresh, a departed peer is owed a fresh start when it
245
- * returns, and without this the map grows for the node's lifetime. Mutates in
246
- * place.
247
- */
248
- export function prunePeerStrandKeys(
249
- byKey: Map<string, number>,
250
- runningStrandIds: ReadonlySet<string>,
251
- livePeerIds?: ReadonlySet<string>
252
- ): void {
253
- for (const key of byKey.keys()) {
254
- const peerGone = livePeerIds !== undefined && !livePeerIds.has(peerIdOfKey(key));
255
- if (peerGone || !runningStrandIds.has(strandIdOfKey(key))) {
256
- byKey.delete(key);
257
- }
258
- }
259
- }
260
-
261
- /**
262
- * The circuit relay named by `addr`, throwing when `addr` is unparsable or names
263
- * no relay. The throwing form exists for CONFIG-facing callers (`relay-addrs.ts`),
264
- * where a typo must fail loudly rather than cost the node its reachability;
265
- * {@link extractCircuitRelayTargets} reads runtime-discovered addrs instead and
266
- * logs-and-skips.
267
- */
268
- export function circuitRelayTargetOrThrow(addr: string): CircuitRelayTarget {
269
- const target = parseCircuitRelayTarget(addr);
270
- if (!target) {
271
- throw new Error(`Multiaddr names no circuit relay (expected …/p2p/<relayPeerId>/p2p-circuit): ${addr}`);
272
- }
273
- return target;
274
- }
275
-
276
- /**
277
- * The circuit relay named by `addr`; null when `addr` is well-formed but simply
278
- * names no relay (a direct listen addr, a bare `/p2p-circuit`), and THROWS when
279
- * `addr` is malformed (unparsable multiaddr, garbage relay peerId). The two
280
- * callers want opposite things from the first case and the same thing from the
281
- * second, so the distinction lives here rather than in either of them.
282
- */
283
- function parseCircuitRelayTarget(addr: string): CircuitRelayTarget | null {
284
- const ma = multiaddr(addr);
285
- const components = ma.getComponents();
286
- const circuitIdx = components.findIndex((c) => c.name === 'p2p-circuit');
287
- if (circuitIdx < 1) {
288
- return null;
289
- }
290
- const relay = components[circuitIdx - 1];
291
- if (relay.name !== 'p2p' || !relay.value) {
292
- return null;
293
- }
294
- peerIdFromString(relay.value); // validate; throws on garbage
295
- return {
296
- relayPeerId: relay.value,
297
- relayAddr: ma.decapsulate('/p2p-circuit').toString()
298
- };
299
- }
300
-
301
- /**
302
- * One-addr body of {@link extractCircuitRelayTargets}; null when `addr` names no
303
- * relay. Only a MALFORMED addr logs — the addr lists this reads (a node's live
304
- * multiaddrs, its configured listen addrs) are mostly direct addrs, and calling
305
- * each of those "unparsable" is both wrong and per-announce noise.
306
- */
307
- function circuitRelayTarget(addr: string): CircuitRelayTarget | null {
308
- try {
309
- return parseCircuitRelayTarget(addr);
310
- } catch (err) {
311
- log('Skipping unparsable relay addr %s: %o', addr, err);
312
- return null;
313
- }
314
- }
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
+ }