@serfab/cadre-core 1.6.0 → 1.8.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 (179) hide show
  1. package/README.md +8 -6
  2. package/dist/cadre-node.d.ts +548 -137
  3. package/dist/cadre-node.js +1208 -383
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-authorization.d.ts +2 -2
  6. package/dist/control-authorization.js +1 -0
  7. package/dist/control-authorization.js.map +1 -1
  8. package/dist/control-cohort.d.ts +6 -0
  9. package/dist/control-cohort.js +6 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +46 -8
  12. package/dist/control-database.js +97 -18
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +37 -14
  15. package/dist/control-formation-recorder.js +60 -10
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +13 -12
  18. package/dist/control-read-retry.js +14 -12
  19. package/dist/control-read-retry.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +67 -5
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +9 -1
  24. package/dist/control-stream.js +24 -1
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +12 -2
  27. package/dist/control-write-retry.js +13 -2
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +11 -8
  30. package/dist/delegate-admission.js +19 -11
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/diagnostics/connection-path.d.ts +0 -7
  33. package/dist/diagnostics/connection-path.js +0 -7
  34. package/dist/diagnostics/connection-path.js.map +1 -1
  35. package/dist/formation-approval.d.ts +7 -0
  36. package/dist/formation-approval.js +9 -3
  37. package/dist/formation-approval.js.map +1 -1
  38. package/dist/fs-atomic.d.ts +11 -2
  39. package/dist/fs-atomic.js +13 -3
  40. package/dist/fs-atomic.js.map +1 -1
  41. package/dist/hibernation-manager.d.ts +111 -8
  42. package/dist/hibernation-manager.js +234 -49
  43. package/dist/hibernation-manager.js.map +1 -1
  44. package/dist/index.d.ts +13 -6
  45. package/dist/index.js +27 -5
  46. package/dist/index.js.map +1 -1
  47. package/dist/joined-strand-store.d.ts +177 -0
  48. package/dist/joined-strand-store.js +431 -0
  49. package/dist/joined-strand-store.js.map +1 -0
  50. package/dist/key-store-file.js +14 -5
  51. package/dist/key-store-file.js.map +1 -1
  52. package/dist/key-store.d.ts +6 -0
  53. package/dist/key-store.js.map +1 -1
  54. package/dist/link-budget.d.ts +367 -69
  55. package/dist/link-budget.js +375 -72
  56. package/dist/link-budget.js.map +1 -1
  57. package/dist/membership-connection-gater.d.ts +30 -43
  58. package/dist/membership-connection-gater.js +31 -42
  59. package/dist/membership-connection-gater.js.map +1 -1
  60. package/dist/node-local-snapshot.d.ts +5 -4
  61. package/dist/node-local-snapshot.js +6 -3
  62. package/dist/node-local-snapshot.js.map +1 -1
  63. package/dist/peer-dial.d.ts +24 -4
  64. package/dist/peer-dial.js +74 -5
  65. package/dist/peer-dial.js.map +1 -1
  66. package/dist/peer-join-backfill.d.ts +35 -15
  67. package/dist/peer-join-backfill.js +115 -55
  68. package/dist/peer-join-backfill.js.map +1 -1
  69. package/dist/push-fanout.d.ts +6 -0
  70. package/dist/push-fanout.js +6 -0
  71. package/dist/push-fanout.js.map +1 -1
  72. package/dist/relay-reservation.d.ts +3 -1
  73. package/dist/relay-reservation.js +3 -1
  74. package/dist/relay-reservation.js.map +1 -1
  75. package/dist/relay-server.d.ts +79 -0
  76. package/dist/relay-server.js +74 -0
  77. package/dist/relay-server.js.map +1 -0
  78. package/dist/seed-bootstrap.d.ts +57 -4
  79. package/dist/seed-bootstrap.js +107 -83
  80. package/dist/seed-bootstrap.js.map +1 -1
  81. package/dist/storage-scope.d.ts +33 -17
  82. package/dist/storage-scope.js +44 -23
  83. package/dist/storage-scope.js.map +1 -1
  84. package/dist/strand-addr-protocol.d.ts +58 -21
  85. package/dist/strand-addr-protocol.js +133 -85
  86. package/dist/strand-addr-protocol.js.map +1 -1
  87. package/dist/strand-database.d.ts +20 -2
  88. package/dist/strand-database.js +42 -5
  89. package/dist/strand-database.js.map +1 -1
  90. package/dist/strand-first-sync-gate.d.ts +59 -21
  91. package/dist/strand-first-sync-gate.js +58 -19
  92. package/dist/strand-first-sync-gate.js.map +1 -1
  93. package/dist/strand-formation-deadlines.d.ts +151 -0
  94. package/dist/strand-formation-deadlines.js +171 -0
  95. package/dist/strand-formation-deadlines.js.map +1 -0
  96. package/dist/strand-formation-manager.d.ts +63 -30
  97. package/dist/strand-formation-manager.js +67 -41
  98. package/dist/strand-formation-manager.js.map +1 -1
  99. package/dist/strand-formation-protocol.d.ts +45 -35
  100. package/dist/strand-formation-protocol.js +59 -122
  101. package/dist/strand-formation-protocol.js.map +1 -1
  102. package/dist/strand-instance-manager.d.ts +152 -6
  103. package/dist/strand-instance-manager.js +331 -42
  104. package/dist/strand-instance-manager.js.map +1 -1
  105. package/dist/strand-membership-writer.d.ts +6 -0
  106. package/dist/strand-membership-writer.js +11 -0
  107. package/dist/strand-membership-writer.js.map +1 -1
  108. package/dist/strand-peer-book-file.d.ts +12 -0
  109. package/dist/strand-peer-book-file.js +37 -0
  110. package/dist/strand-peer-book-file.js.map +1 -0
  111. package/dist/strand-peer-book-protocol.d.ts +168 -0
  112. package/dist/strand-peer-book-protocol.js +356 -0
  113. package/dist/strand-peer-book-protocol.js.map +1 -0
  114. package/dist/strand-peer-book-swap.d.ts +104 -0
  115. package/dist/strand-peer-book-swap.js +284 -0
  116. package/dist/strand-peer-book-swap.js.map +1 -0
  117. package/dist/strand-peer-book.d.ts +159 -0
  118. package/dist/strand-peer-book.js +361 -0
  119. package/dist/strand-peer-book.js.map +1 -0
  120. package/dist/strand-peer-observer.d.ts +88 -0
  121. package/dist/strand-peer-observer.js +180 -0
  122. package/dist/strand-peer-observer.js.map +1 -0
  123. package/dist/strand-revocation-enforcer.js +2 -1
  124. package/dist/strand-revocation-enforcer.js.map +1 -1
  125. package/dist/strand-solicitation.d.ts +36 -20
  126. package/dist/strand-solicitation.js +4 -4
  127. package/dist/strand-solicitation.js.map +1 -1
  128. package/dist/strand-wake-protocol.d.ts +80 -23
  129. package/dist/strand-wake-protocol.js +89 -64
  130. package/dist/strand-wake-protocol.js.map +1 -1
  131. package/dist/types.d.ts +181 -33
  132. package/dist/types.js +4 -3
  133. package/dist/types.js.map +1 -1
  134. package/package.json +11 -7
  135. package/src/cadre-node.ts +1316 -398
  136. package/src/control-authorization.ts +2 -1
  137. package/src/control-cohort.ts +6 -0
  138. package/src/control-database.ts +122 -18
  139. package/src/control-formation-recorder.ts +354 -305
  140. package/src/control-read-retry.ts +14 -12
  141. package/src/control-schema.ts +67 -5
  142. package/src/control-stream.ts +25 -1
  143. package/src/control-write-retry.ts +13 -2
  144. package/src/delegate-admission.ts +23 -13
  145. package/src/diagnostics/connection-path.ts +0 -7
  146. package/src/formation-approval.ts +610 -604
  147. package/src/fs-atomic.ts +14 -4
  148. package/src/hibernation-manager.ts +638 -437
  149. package/src/index.ts +91 -6
  150. package/src/joined-strand-store.ts +498 -0
  151. package/src/key-store-file.ts +14 -5
  152. package/src/key-store.ts +6 -0
  153. package/src/link-budget.ts +393 -74
  154. package/src/membership-connection-gater.ts +31 -45
  155. package/src/node-local-snapshot.ts +293 -289
  156. package/src/peer-dial.ts +81 -7
  157. package/src/peer-join-backfill.ts +120 -57
  158. package/src/push-fanout.ts +6 -0
  159. package/src/relay-reservation.ts +3 -1
  160. package/src/relay-server.ts +98 -0
  161. package/src/seed-bootstrap.ts +133 -91
  162. package/src/storage-scope.ts +44 -23
  163. package/src/strand-addr-protocol.ts +547 -458
  164. package/src/strand-database.ts +287 -242
  165. package/src/strand-first-sync-gate.ts +58 -19
  166. package/src/strand-formation-deadlines.ts +227 -0
  167. package/src/strand-formation-manager.ts +700 -647
  168. package/src/strand-formation-protocol.ts +103 -150
  169. package/src/strand-instance-manager.ts +387 -45
  170. package/src/strand-membership-writer.ts +12 -0
  171. package/src/strand-peer-book-file.ts +39 -0
  172. package/src/strand-peer-book-protocol.ts +466 -0
  173. package/src/strand-peer-book-swap.ts +338 -0
  174. package/src/strand-peer-book.ts +475 -0
  175. package/src/strand-peer-observer.ts +235 -0
  176. package/src/strand-revocation-enforcer.ts +2 -1
  177. package/src/strand-solicitation.ts +519 -500
  178. package/src/strand-wake-protocol.ts +401 -355
  179. package/src/types.ts +185 -33
@@ -4,8 +4,9 @@ import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-c
4
4
  import type { Libp2p, Connection } from '@libp2p/interface';
5
5
  import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
6
6
  import { peerIdFromString } from '@libp2p/peer-id';
7
- import { type ControlStream, writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
8
- import { dialPeerAddrs, DEFAULT_PEER_DIAL_BUDGET, type PeerDialBudget } from './peer-dial.js';
7
+ import { type ControlStream, withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
8
+ import { dialPeerAddrs, SelfRelayOnlyError, DEFAULT_PEER_DIAL_BUDGET, type PeerDialBudget } from './peer-dial.js';
9
+ import { relayedRequestBudgetMs } from './link-budget.js';
9
10
  import type {
10
11
  ControlNetworkSeed,
11
12
  SeedPeer,
@@ -42,15 +43,16 @@ export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
42
43
  /** Maximum seed message size (1MB) */
43
44
  const MAX_SEED_SIZE = 1024 * 1024;
44
45
 
45
- /** Default time the receiver waits for an inbound seed frame before aborting (ms). */
46
+ /**
47
+ * Default time the receiver waits for an inbound seed frame before aborting (ms). It covers the
48
+ * read only: the trust decision and the peer-store merge run after it.
49
+ */
50
+ // eslint-disable-next-line no-restricted-syntax -- link-independent: a receiver cap on one seed frame on a stream the peer already opened; it bounds a peer that opens a stream and never sends, not the dial
46
51
  const DEFAULT_SEED_READ_TIMEOUT_MS = 10_000;
47
52
 
48
53
  /** Default cap on concurrent inbound seed streams a single peer can pin open. */
49
54
  const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
50
55
 
51
- /** Default time the sender waits for a seed delivery (dial + ack read) before aborting (ms). */
52
- const DEFAULT_SEED_DELIVER_TIMEOUT_MS = 10_000;
53
-
54
56
  /**
55
57
  * Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
56
58
  *
@@ -122,6 +124,9 @@ function seedRejected(error: string): ApplySeedResult {
122
124
  return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
123
125
  }
124
126
 
127
+ /** What dialing a seed's owner peers produced — the owner-dial half of an {@link ApplySeedResult}. */
128
+ type OwnerDialCounts = Pick<ApplySeedResult, 'ownerDialsAttempted' | 'ownerDialsFailed'>;
129
+
125
130
  /**
126
131
  * Parse each address, dropping (and logging) any that is malformed, so one bad
127
132
  * entry cannot keep a peer's valid addresses from being dialed.
@@ -192,13 +197,28 @@ export interface SeedBootstrapConfig {
192
197
  * returned without applying any seed.
193
198
  */
194
199
  maxConcurrentSeeds?: number;
200
+ /**
201
+ * The host's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
202
+ * {@link seedDeliverTimeoutMs}'s default is derived. Unset means the declared default.
203
+ */
204
+ linkRoundTripMs?: number;
195
205
  /**
196
206
  * Time {@link SeedBootstrapService.deliverSeed} waits for the whole exchange —
197
- * dial, write, ack read — before aborting (ms). Defaults to
198
- * {@link DEFAULT_SEED_DELIVER_TIMEOUT_MS}. Bounds the SENDER against a seed
207
+ * dial, write, ack read — before aborting (ms). Bounds the SENDER against a seed
199
208
  * target that accepts the stream and then never replies; the target is a
200
209
  * not-yet-trusted node during onboarding, so this is the more exposed
201
210
  * direction than the receiver knobs above.
211
+ *
212
+ * Defaults to `relayedRequestBudgetMs(linkRoundTripMs)` (`link-budget.ts`; 23 s at the
213
+ * default declaration): one dial that may need a relay, then one request and its answer.
214
+ * Delivery does not set `runOnLimitedConnection`, so it does not use a limited relayed
215
+ * connection today; the relayed-dial count is the upper bound on the dial it can use, the
216
+ * same choice `CadreNode.controlDialBudget` makes for every address. It holds only link work
217
+ * because the receiver acks before its owner dials.
218
+ *
219
+ * NOTE: no transfer allowance — a seed is a peer list of a few KB, and `MAX_SEED_SIZE` (1 MiB)
220
+ * is a defensive cap. If seeds ever grow toward that cap, add an allowance the way
221
+ * `PUSH_TRANSFER_ALLOWANCE_MS` does.
202
222
  */
203
223
  seedDeliverTimeoutMs?: number;
204
224
  /**
@@ -256,7 +276,7 @@ export class SeedBootstrapService {
256
276
  this.trustPolicy = config.trustPolicy ?? anchoredTrustPolicy();
257
277
  this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
258
278
  this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
259
- this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? DEFAULT_SEED_DELIVER_TIMEOUT_MS;
279
+ this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? relayedRequestBudgetMs(config.linkRoundTripMs);
260
280
  this.dialBudget = config.dialBudget ?? DEFAULT_PEER_DIAL_BUDGET;
261
281
 
262
282
  // Derive public key from private key if not provided
@@ -305,20 +325,23 @@ export class SeedBootstrapService {
305
325
  * `controlDatabase` for dialing and known-key lookup, and must NOT bind a
306
326
  * discarded closure to the shared node (a handler leak, and a second
307
327
  * `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
328
+ *
329
+ * Rejects when libp2p refuses the registration; the node and database are
330
+ * kept only once the handler is in place, so a failed service holds nothing
331
+ * for {@link shutdown} to unhandle.
308
332
  */
309
- initialize(
333
+ async initialize(
310
334
  libp2pNode: Libp2p,
311
335
  controlDatabase: ControlDatabase,
312
336
  options?: { registerHandler?: boolean }
313
- ): void {
314
- this.libp2pNode = libp2pNode;
315
- this.controlDatabase = controlDatabase;
316
-
337
+ ): Promise<void> {
317
338
  // Register the seed protocol handler unless the caller opted out (temp services).
318
339
  if (options?.registerHandler ?? true) {
319
- this.registerProtocolHandler();
340
+ await this.registerProtocolHandler(libp2pNode);
320
341
  }
321
342
 
343
+ this.libp2pNode = libp2pNode;
344
+ this.controlDatabase = controlDatabase;
322
345
  log('SeedBootstrapService initialized');
323
346
  }
324
347
 
@@ -738,6 +761,22 @@ export class SeedBootstrapService {
738
761
  async applySeed(
739
762
  seed: ControlNetworkSeed,
740
763
  options?: { trustPolicy?: SeedTrustPolicy }
764
+ ): Promise<ApplySeedResult> {
765
+ const merged = await this.verifyAndMergeSeed(seed, options);
766
+ if (!merged.success) {
767
+ return merged;
768
+ }
769
+ return { ...merged, ...await this.dialSeedOwners(seed) };
770
+ }
771
+
772
+ /**
773
+ * The first half of {@link applySeed}: check the signature and the signer's trust, then merge
774
+ * the seed's peer addresses into the peer store. Every rejection happens here, so this result is
775
+ * what the inbound handler acks with; the owner dials that follow cannot change it.
776
+ */
777
+ private async verifyAndMergeSeed(
778
+ seed: ControlNetworkSeed,
779
+ options?: { trustPolicy?: SeedTrustPolicy }
741
780
  ): Promise<ApplySeedResult> {
742
781
  if (!this.libp2pNode) {
743
782
  return seedRejected('Service not initialized');
@@ -796,12 +835,28 @@ export class SeedBootstrapService {
796
835
  }
797
836
  }
798
837
 
799
- // Dial owner peers to establish connections. Best-effort and COUNTED: an
800
- // owner that is momentarily down leaves this node seeded but unconnected,
801
- // which the caller can only see if the outcome is reported (see
802
- // `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
803
- // `CadreNode.dialColdStartBootstrap` retries these same addresses on every
804
- // control-cohort reconcile pass until the control database has siblings.
838
+ log('Merged seed: %d peers added', peersAdded);
839
+ return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
840
+ }
841
+
842
+ /**
843
+ * The second half of {@link applySeed}: dial the seed's owner peers to establish connections.
844
+ *
845
+ * Best-effort and COUNTED: an owner that is momentarily down leaves this node seeded but
846
+ * unconnected, which the caller can only see if the outcome is reported (see
847
+ * `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
848
+ * `CadreNode.dialColdStartBootstrap` retries these same addresses on every control-cohort
849
+ * reconcile pass until the control database has siblings.
850
+ *
851
+ * The inbound handler runs this AFTER it has acked and closed the stream
852
+ * ({@link handleSeedStream}), because an unreachable owner can take `dialBudget.totalMs`.
853
+ */
854
+ private async dialSeedOwners(seed: ControlNetworkSeed): Promise<OwnerDialCounts> {
855
+ const node = this.libp2pNode;
856
+ if (!node) {
857
+ log('Seed service shut down before its owner dials; none attempted');
858
+ return { ownerDialsAttempted: 0, ownerDialsFailed: 0 };
859
+ }
805
860
  // `createSeed` projects every non-revoked CadrePeer row, so an owner applying
806
861
  // a seed minted after it joined finds ITSELF in the owner list. Dialing self always
807
862
  // throws, which would report a healthy owner as "seeded but stranded".
@@ -810,12 +865,7 @@ export class SeedBootstrapService {
810
865
  // Every one of an owner's addresses is a candidate, each on its own time limit
811
866
  // (`dialPeerAddrs`), so an owner whose first address never answers neither
812
867
  // stalls seed application nor goes undialed at its other addresses.
813
- // NOTE: `handleSeedStream` acks only after this loop, so an unreachable owner
814
- // can hold the ack for up to `dialBudget.totalMs` each — 56 s at the default declared link
815
- // round trip, more on a host that declared a slower one (`link-budget.ts`) — past a sender's
816
- // 10 s `seedDeliverTimeoutMs`; the seed is still applied. If senders start
817
- // reporting accepted seeds as timed out, ack before dialing.
818
- const selfPeerId = this.libp2pNode.peerId?.toString();
868
+ const selfPeerId = node.peerId?.toString();
819
869
  let ownerDialsAttempted = 0;
820
870
  let ownerDialsFailed = 0;
821
871
  for (const peer of seed.peers.filter(p => p.isOwner)) {
@@ -827,17 +877,22 @@ export class SeedBootstrapService {
827
877
  const addrs = parseDialAddrs(peer.multiaddrs);
828
878
 
829
879
  log('Dialing owner peer: %s (%d addr(s))', peer.peerId, addrs.length);
830
- await dialPeerAddrs(this.libp2pNode, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
880
+ await dialPeerAddrs(node, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
831
881
  } catch (error) {
882
+ // Counted even when the owner reaches us only through our own relay: this node is still
883
+ // not connected to it, and `ownerDialsFailed` is how the caller learns that.
832
884
  ownerDialsFailed++;
833
- log('Failed to dial peer %s: %o', peer.peerId, error);
885
+ if (error instanceof SelfRelayOnlyError) {
886
+ log('Owner peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
887
+ } else {
888
+ log('Failed to dial peer %s: %o', peer.peerId, error);
889
+ }
834
890
  // Continue - not all peers need to be reachable
835
891
  }
836
892
  }
837
893
 
838
- log('Applied seed: %d peers added, %d/%d owner dial(s) failed',
839
- peersAdded, ownerDialsFailed, ownerDialsAttempted);
840
- return { success: true, peersAdded, ownerDialsAttempted, ownerDialsFailed };
894
+ log('Dialed seed owners: %d/%d owner dial(s) failed', ownerDialsFailed, ownerDialsAttempted);
895
+ return { ownerDialsAttempted, ownerDialsFailed };
841
896
  }
842
897
 
843
898
  /**
@@ -1069,10 +1124,8 @@ export class SeedBootstrapService {
1069
1124
  * {@link handleSeedStream} — extracted as a method so it has a unit-test seam
1070
1125
  * (mirroring wake's `handleStream`) the inline closure never had.
1071
1126
  */
1072
- private registerProtocolHandler(): void {
1073
- if (!this.libp2pNode) return;
1074
-
1075
- void this.libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
1127
+ private async registerProtocolHandler(libp2pNode: Libp2p): Promise<void> {
1128
+ await libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
1076
1129
  const remotePeerId = (rawConnection as Connection).remotePeer.toString();
1077
1130
  await this.handleSeedStream(rawStream as ControlStream, remotePeerId);
1078
1131
  });
@@ -1081,92 +1134,81 @@ export class SeedBootstrapService {
1081
1134
  }
1082
1135
 
1083
1136
  /**
1084
- * Read one inbound seed frame, apply it, and write the ack.
1137
+ * Read one inbound seed frame, verify and merge it, write the ack and close the
1138
+ * stream, then dial the seed's owners.
1139
+ *
1140
+ * The ack and the close come BEFORE the owner dials: an unreachable owner can hold
1141
+ * a dial for `dialBudget.totalMs` (64 s at the default declared link), which is not
1142
+ * link time and does not belong inside the sender's delivery deadline. The close is
1143
+ * what releases the sender, which reads the ack to end-of-stream. The stream stays
1144
+ * counted in {@link activeStreams} through the dials, so {@link maxConcurrentSeeds}
1145
+ * still bounds concurrent owner-dial phases.
1085
1146
  *
1086
1147
  * Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
1087
1148
  * {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
1088
1149
  * that never half-closes is aborted inside `readStreamToEnd`), and the existing
1089
1150
  * malformed/oversized-frame guard — all reported as a non-accepting
1090
1151
  * {@link SeedAckMessage} rather than a dropped/hung stream.
1152
+ *
1153
+ * NOTE: a receiver configured with an interactive trust-on-first-use policy asks a
1154
+ * human before acking, and that wait sits inside the sender's delivery deadline. If
1155
+ * TOFU is ever used on this wire path, ack "pending" or move the confirmation out of
1156
+ * the exchange.
1091
1157
  */
1092
1158
  private async handleSeedStream(stream: ControlStream, remotePeerId: string): Promise<void> {
1093
1159
  log('Incoming seed delivery from: %s', remotePeerId);
1094
1160
 
1095
1161
  if (this.activeStreams >= this.maxConcurrentSeeds) {
1096
1162
  log('Rejecting seed from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrentSeeds);
1097
- const ack: SeedAckMessage = { accepted: false, reason: 'Too many concurrent seed deliveries' };
1098
- try {
1099
- writeFrame(stream, ack);
1100
- } catch {
1101
- // Ignore send errors on the reject path.
1102
- }
1103
- try {
1104
- await stream.close();
1105
- } catch {
1106
- // Ignore close errors.
1107
- }
1163
+ await replyAndClose(stream, { accepted: false, reason: 'Too many concurrent seed deliveries' } satisfies SeedAckMessage, 'Seed');
1108
1164
  return;
1109
1165
  }
1110
1166
 
1111
1167
  this.activeStreams++;
1168
+ let acked = false;
1112
1169
  try {
1113
- // Read the seed frame to EOF (bounded + size-capped), then decode it.
1114
- const data = await readStreamToEnd(stream, {
1115
- maxBytes: MAX_SEED_SIZE,
1116
- timeoutMs: this.seedReadTimeoutMs,
1117
- label: 'Seed',
1118
- });
1119
-
1120
- const messageBody = decodeLengthPrefixedFrame(data);
1121
- const messageJson = new TextDecoder().decode(messageBody);
1122
- const message = JSON.parse(messageJson) as SeedMessage;
1123
-
1124
- // Emit seed received event
1125
- this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
1126
-
1127
- // Convert to seed and apply
1128
- const seed: ControlNetworkSeed = {
1129
- partyId: message.partyId,
1130
- peers: message.peers,
1131
- signature: message.signature,
1132
- signerKey: message.signerKey,
1133
- };
1170
+ const seed = await this.readSeedFrame(stream);
1171
+ this.eventCallbacks.onSeedReceived?.(seed.partyId, remotePeerId);
1134
1172
 
1135
- const result = await this.applySeed(seed);
1173
+ const merged = await this.verifyAndMergeSeed(seed);
1174
+ acked = true;
1175
+ await replyAndClose(stream, { accepted: merged.success, reason: merged.error } satisfies SeedAckMessage, 'Seed');
1136
1176
 
1137
- // Emit appropriate event based on result
1138
- if (result.success) {
1139
- this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded, seed);
1177
+ if (merged.success) {
1178
+ await this.dialSeedOwners(seed);
1179
+ this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
1140
1180
  } else {
1141
- this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
1181
+ this.eventCallbacks.onSeedError?.(seed.partyId, merged.error ?? 'Unknown error');
1142
1182
  }
1143
-
1144
- const ack: SeedAckMessage = { accepted: result.success, reason: result.error };
1145
- writeFrame(stream, ack);
1146
1183
  } catch (error) {
1147
1184
  log('Error handling seed delivery: %o', error);
1148
1185
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1149
-
1150
- // Emit error event
1151
1186
  this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
1152
-
1153
- // Send error acknowledgment (best-effort on the still-open write side).
1154
- const ack: SeedAckMessage = { accepted: false, reason: errorMessage };
1155
- try {
1156
- writeFrame(stream, ack);
1157
- } catch {
1158
- // Ignore send errors
1187
+ // Once acked the stream is closed, so a later failure has no reply to make.
1188
+ if (!acked) {
1189
+ await replyAndClose(stream, { accepted: false, reason: errorMessage } satisfies SeedAckMessage, 'Seed');
1159
1190
  }
1160
1191
  } finally {
1161
1192
  this.activeStreams--;
1162
- try {
1163
- await stream.close();
1164
- } catch {
1165
- // Ignore close errors.
1166
- }
1167
1193
  }
1168
1194
  }
1169
1195
 
1196
+ /** Read the inbound seed frame to EOF (bounded and size-capped) and decode it. */
1197
+ private async readSeedFrame(stream: ControlStream): Promise<ControlNetworkSeed> {
1198
+ const data = await readStreamToEnd(stream, {
1199
+ maxBytes: MAX_SEED_SIZE,
1200
+ timeoutMs: this.seedReadTimeoutMs,
1201
+ label: 'Seed',
1202
+ });
1203
+ const message = JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data))) as SeedMessage;
1204
+ return {
1205
+ partyId: message.partyId,
1206
+ peers: message.peers,
1207
+ signature: message.signature,
1208
+ signerKey: message.signerKey,
1209
+ };
1210
+ }
1211
+
1170
1212
  /**
1171
1213
  * Shutdown the service.
1172
1214
  */
@@ -18,18 +18,26 @@
18
18
  * its shape, and the React Native reference app lets a user type one into Settings.
19
19
  * Scope keys reach real namespaces unescaped: cadre-cli builds `${config.path}/${scope}`,
20
20
  * React Native a LevelDB filename, the browser an IndexedDB database name. A party id
21
- * containing `/` or `..` would escape the CLI's storage directory. Base64url keeps every
22
- * key inside `[A-Za-z0-9._-]` — the charset embedders already rely on, and the same
23
- * encoding the React Native app's `secureStoreKeySegment` uses on a party id for the
24
- * same reason.
21
+ * containing `/` or `..` would escape the CLI's storage directory. Lowercase hex keeps
22
+ * every key inside `[a-z0-9._-]`, the charset embedders rely on.
23
+ *
24
+ * WHY THE CHARSET HAS NO UPPERCASE. Windows (NTFS) and macOS (APFS/HFS+ by default)
25
+ * compare file names without regard to case, while everything above the filesystem
26
+ * compares with it. Two keys differing only in case — `control-YWFA` and `control-YWFa`,
27
+ * the base64url keys of party ids `aa@` and `aaZ`, or strand ids `strand-ABC` and
28
+ * `strand-abc` — would be two stores in memory and one folder on disk. With no uppercase
29
+ * letter in the charset, two different keys are two different names on every
30
+ * filesystem.
25
31
  *
26
32
  * THE STRAND ARM OF THAT CHARSET RULE IS ENFORCED HERE. A strand's scope key is
27
33
  * `StrandRow.Id`, and a strand row replicated into the control database by another
28
34
  * node in the party carries whatever id THAT node wrote. `assertStrandScopeKey`
29
35
  * (below) is the check that makes the charset true of strand keys too; it runs
30
36
  * unconditionally at the top of `StrandInstanceManager.startStrand`, so no strand id
31
- * reaches an embedder's provider — or a libp2p protocol prefix — unvalidated. Ids
32
- * cadre-core mints come from `strand-id.ts`, which is built on the same predicate.
37
+ * reaches an embedder's provider — or a libp2p protocol prefix — unvalidated. A
38
+ * mixed-case id is refused, not lowercased: lowercasing would silently merge two
39
+ * distinct strands into one store. Ids cadre-core mints come from `strand-id.ts`, which
40
+ * is built on the same predicate.
33
41
  */
34
42
 
35
43
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
@@ -45,23 +53,29 @@ const CONTROL_SCOPE_PREFIX = 'control-';
45
53
  /**
46
54
  * The storage scope key for a party's control database.
47
55
  *
48
- * Returns `control-` followed by the base64url encoding of `partyId`'s UTF-8 bytes,
49
- * so the whole key stays within `[A-Za-z0-9._-]` and is safe to use directly as a
50
- * file name, directory name or database name. See the module comment for why the
51
- * encoding is load-bearing rather than decorative.
56
+ * Returns `control-` followed by the lowercase hexadecimal encoding of `partyId`'s
57
+ * UTF-8 bytes, so the whole key stays within `[a-z0-9._-]` and is safe to use directly
58
+ * as a file name, directory name or database name — and two party ids give two names
59
+ * even on a filesystem that ignores case. See the module comment for why the encoding
60
+ * is load-bearing rather than decorative.
52
61
  *
53
62
  * To read a party id back off a device — from, say, a LevelDB file named
54
- * `sereus-control-MTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1` — in a browser or
55
- * Node console, with no dependency on this package:
63
+ * `sereus-control-7061727479` — in a browser or Node console, with no dependency on
64
+ * this package:
56
65
  * ```js
57
- * const b64 = key.slice('control-'.length).replace(/-/g, '+').replace(/_/g, '/');
58
- * new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)))
66
+ * const hex = key.slice(key.indexOf('control-') + 'control-'.length);
67
+ * new TextDecoder().decode(Uint8Array.from(hex.match(/../g) ?? [], (h) => parseInt(h, 16)))
59
68
  * ```
60
- * The `TextDecoder` step is not optional: `atob` alone yields one character per BYTE,
61
- * so a party id with any non-ASCII character in it decodes to mojibake.
69
+ * The `TextDecoder` step is not optional: each hex pair is one BYTE, so a party id with
70
+ * any non-ASCII character in it decodes to mojibake without it.
71
+ *
72
+ * NOTE: the key is twice the party id's UTF-8 byte length plus 8, so a party id over
73
+ * ~120 bytes overflows the 255-byte limit filesystems put on one path component. Party
74
+ * ids today are UUIDs (an 80-character key); if long party ids ever appear, hash the
75
+ * party id rather than encoding it.
62
76
  */
63
77
  export function controlStorageScope(partyId: string): string {
64
- return CONTROL_SCOPE_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base64url');
78
+ return CONTROL_SCOPE_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base16');
65
79
  }
66
80
 
67
81
  /**
@@ -86,8 +100,11 @@ export function isControlStorageScope(scope: string): boolean {
86
100
  */
87
101
  const MAX_STRAND_SCOPE_KEY_LENGTH = 128;
88
102
 
89
- /** The charset every scope key stays within — see the module comment. */
90
- const SCOPE_KEY_CHARSET = /^[A-Za-z0-9._-]+$/;
103
+ /**
104
+ * The charset every scope key stays within — see the module comment. Lowercase only,
105
+ * so keys that differ as strings also differ as names on a filesystem that ignores case.
106
+ */
107
+ const SCOPE_KEY_CHARSET = /^[a-z0-9._-]+$/;
91
108
 
92
109
  /**
93
110
  * Whether a strand id is usable as a storage scope key and as a libp2p network name.
@@ -100,7 +117,9 @@ const SCOPE_KEY_CHARSET = /^[A-Za-z0-9._-]+$/;
100
117
  *
101
118
  * Valid means all of:
102
119
  * - non-empty, and at most {@link MAX_STRAND_SCOPE_KEY_LENGTH} characters;
103
- * - within `[A-Za-z0-9._-]`, so no separator, drive letter, NUL or non-ASCII text;
120
+ * - within `[a-z0-9._-]`, so no separator, drive letter, NUL or non-ASCII text, and no
121
+ * uppercase letter, so no two valid ids name one folder on a filesystem that ignores
122
+ * case;
104
123
  * - not `.` or `..`, which the charset admits but every filesystem reads as a
105
124
  * directory rather than a name;
106
125
  * - not `control-`-prefixed, which would let a strand's store masquerade as a
@@ -128,7 +147,8 @@ export class InvalidStrandIdError extends Error {
128
147
  constructor(strandId: string) {
129
148
  super(
130
149
  `Strand id ${JSON.stringify(strandId)} cannot be used as a storage scope key: ` +
131
- `a strand id must be 1-${MAX_STRAND_SCOPE_KEY_LENGTH} characters within [A-Za-z0-9._-], ` +
150
+ `a strand id must be 1-${MAX_STRAND_SCOPE_KEY_LENGTH} characters within [a-z0-9._-] ` +
151
+ '(lowercase only, because Windows and macOS file names ignore case), ' +
132
152
  "must not be '.' or '..', and must not begin 'control-'. " +
133
153
  'This strand was created by a node that does not mint conforming ids; it cannot be ' +
134
154
  'started here, because the id becomes a file, directory or database name.'
@@ -156,8 +176,9 @@ export function assertStrandScopeKey(strandId: string): void {
156
176
  export function assertScopeKeyCharset(scope: string): void {
157
177
  if (!SCOPE_KEY_CHARSET.test(scope)) {
158
178
  throw new Error(
159
- `Storage scope key ${JSON.stringify(scope)} leaves the [A-Za-z0-9._-] charset ` +
160
- 'embedders rely on to use it directly as a file, directory or database name.'
179
+ `Storage scope key ${JSON.stringify(scope)} leaves the [a-z0-9._-] charset ` +
180
+ 'embedders rely on to use it directly as a file, directory or database name ' +
181
+ '(lowercase only, so two keys never share one name on a filesystem that ignores case).'
161
182
  );
162
183
  }
163
184
  }