@serfab/cadre-core 0.9.0 → 0.10.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 (162) hide show
  1. package/README.md +4 -2
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +918 -53
  9. package/dist/cadre-node.js +1843 -190
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-database.d.ts +990 -50
  15. package/dist/control-database.js +1459 -108
  16. package/dist/control-database.js.map +1 -1
  17. package/dist/control-formation-recorder.d.ts +89 -10
  18. package/dist/control-formation-recorder.js +129 -14
  19. package/dist/control-formation-recorder.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +774 -210
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +38 -4
  24. package/dist/control-stream.js +70 -4
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +151 -0
  27. package/dist/control-write-retry.js +411 -0
  28. package/dist/control-write-retry.js.map +1 -0
  29. package/dist/delegate-admission.d.ts +134 -0
  30. package/dist/delegate-admission.js +244 -0
  31. package/dist/delegate-admission.js.map +1 -0
  32. package/dist/device-token.d.ts +11 -11
  33. package/dist/device-token.js +14 -13
  34. package/dist/device-token.js.map +1 -1
  35. package/dist/ed25519-key.d.ts +26 -0
  36. package/dist/ed25519-key.js +56 -1
  37. package/dist/ed25519-key.js.map +1 -1
  38. package/dist/file-durable-slot.d.ts +20 -0
  39. package/dist/file-durable-slot.js +53 -0
  40. package/dist/file-durable-slot.js.map +1 -0
  41. package/dist/formation-approval.d.ts +157 -0
  42. package/dist/formation-approval.js +375 -0
  43. package/dist/formation-approval.js.map +1 -0
  44. package/dist/fs-atomic.d.ts +38 -0
  45. package/dist/fs-atomic.js +97 -0
  46. package/dist/fs-atomic.js.map +1 -0
  47. package/dist/identity-key.d.ts +49 -0
  48. package/dist/identity-key.js +81 -0
  49. package/dist/identity-key.js.map +1 -0
  50. package/dist/index.d.ts +16 -6
  51. package/dist/index.js +39 -5
  52. package/dist/index.js.map +1 -1
  53. package/dist/key-store-file.d.ts +2 -24
  54. package/dist/key-store-file.js +10 -77
  55. package/dist/key-store-file.js.map +1 -1
  56. package/dist/membership-connection-gater.d.ts +146 -0
  57. package/dist/membership-connection-gater.js +180 -0
  58. package/dist/membership-connection-gater.js.map +1 -0
  59. package/dist/node-local-snapshot.d.ts +109 -0
  60. package/dist/node-local-snapshot.js +182 -0
  61. package/dist/node-local-snapshot.js.map +1 -0
  62. package/dist/peer-authorization.d.ts +135 -18
  63. package/dist/peer-authorization.js +152 -21
  64. package/dist/peer-authorization.js.map +1 -1
  65. package/dist/peer-record.d.ts +21 -8
  66. package/dist/peer-record.js +26 -10
  67. package/dist/peer-record.js.map +1 -1
  68. package/dist/relay-addrs.d.ts +45 -0
  69. package/dist/relay-addrs.js +103 -0
  70. package/dist/relay-addrs.js.map +1 -0
  71. package/dist/relay-reservation.d.ts +266 -0
  72. package/dist/relay-reservation.js +613 -0
  73. package/dist/relay-reservation.js.map +1 -0
  74. package/dist/seed-bootstrap.d.ts +221 -56
  75. package/dist/seed-bootstrap.js +328 -188
  76. package/dist/seed-bootstrap.js.map +1 -1
  77. package/dist/seed-trust-policy.d.ts +47 -21
  78. package/dist/seed-trust-policy.js +24 -15
  79. package/dist/seed-trust-policy.js.map +1 -1
  80. package/dist/strand-addr-protocol.d.ts +42 -13
  81. package/dist/strand-addr-protocol.js +58 -52
  82. package/dist/strand-addr-protocol.js.map +1 -1
  83. package/dist/strand-backfill.d.ts +109 -0
  84. package/dist/strand-backfill.js +279 -0
  85. package/dist/strand-backfill.js.map +1 -0
  86. package/dist/strand-formation-manager.d.ts +38 -9
  87. package/dist/strand-formation-manager.js +120 -17
  88. package/dist/strand-formation-manager.js.map +1 -1
  89. package/dist/strand-formation-protocol.d.ts +103 -4
  90. package/dist/strand-formation-protocol.js +232 -4
  91. package/dist/strand-formation-protocol.js.map +1 -1
  92. package/dist/strand-instance-manager.d.ts +39 -1
  93. package/dist/strand-instance-manager.js +92 -14
  94. package/dist/strand-instance-manager.js.map +1 -1
  95. package/dist/strand-member-registry.d.ts +5 -5
  96. package/dist/strand-member-registry.js +18 -10
  97. package/dist/strand-member-registry.js.map +1 -1
  98. package/dist/strand-membership-writer.d.ts +482 -88
  99. package/dist/strand-membership-writer.js +757 -136
  100. package/dist/strand-membership-writer.js.map +1 -1
  101. package/dist/strand-solicitation.d.ts +99 -33
  102. package/dist/strand-solicitation.js +100 -52
  103. package/dist/strand-solicitation.js.map +1 -1
  104. package/dist/strand-transport-key.d.ts +40 -0
  105. package/dist/strand-transport-key.js +56 -0
  106. package/dist/strand-transport-key.js.map +1 -0
  107. package/dist/strand-wake-protocol.d.ts +9 -7
  108. package/dist/strand-wake-protocol.js +22 -46
  109. package/dist/strand-wake-protocol.js.map +1 -1
  110. package/dist/strand-watcher.d.ts +17 -2
  111. package/dist/strand-watcher.js +47 -3
  112. package/dist/strand-watcher.js.map +1 -1
  113. package/dist/trusted-owner-store-file.d.ts +12 -0
  114. package/dist/trusted-owner-store-file.js +39 -0
  115. package/dist/trusted-owner-store-file.js.map +1 -0
  116. package/dist/trusted-owner-store.d.ts +64 -0
  117. package/dist/trusted-owner-store.js +126 -0
  118. package/dist/trusted-owner-store.js.map +1 -0
  119. package/dist/types.d.ts +275 -20
  120. package/dist/types.js +13 -0
  121. package/dist/types.js.map +1 -1
  122. package/package.json +19 -8
  123. package/src/bootstrap-peer-store-file.ts +44 -0
  124. package/src/bootstrap-peer-store.ts +172 -0
  125. package/src/cadre-node.ts +1992 -202
  126. package/src/control-authorization.ts +114 -0
  127. package/src/control-database.ts +1704 -146
  128. package/src/control-formation-recorder.ts +172 -19
  129. package/src/control-schema.ts +786 -222
  130. package/src/control-stream.ts +81 -4
  131. package/src/control-write-retry.ts +473 -0
  132. package/src/delegate-admission.ts +302 -0
  133. package/src/device-token.ts +18 -13
  134. package/src/ed25519-key.ts +134 -69
  135. package/src/file-durable-slot.ts +61 -0
  136. package/src/formation-approval.ts +604 -0
  137. package/src/fs-atomic.ts +107 -0
  138. package/src/identity-key.ts +109 -0
  139. package/src/index.ts +136 -5
  140. package/src/key-store-file.ts +10 -81
  141. package/src/membership-connection-gater.ts +204 -0
  142. package/src/node-local-snapshot.ts +260 -0
  143. package/src/peer-authorization.ts +199 -21
  144. package/src/peer-record.ts +28 -10
  145. package/src/relay-addrs.ts +111 -0
  146. package/src/relay-reservation.ts +823 -0
  147. package/src/seed-bootstrap.ts +397 -197
  148. package/src/seed-trust-policy.ts +58 -25
  149. package/src/strand-addr-protocol.ts +84 -53
  150. package/src/strand-backfill.ts +366 -0
  151. package/src/strand-formation-manager.ts +145 -19
  152. package/src/strand-formation-protocol.ts +309 -8
  153. package/src/strand-instance-manager.ts +111 -14
  154. package/src/strand-member-registry.ts +18 -13
  155. package/src/strand-membership-writer.ts +1430 -640
  156. package/src/strand-solicitation.ts +155 -90
  157. package/src/strand-transport-key.ts +58 -0
  158. package/src/strand-wake-protocol.ts +29 -46
  159. package/src/strand-watcher.ts +66 -3
  160. package/src/trusted-owner-store-file.ts +41 -0
  161. package/src/trusted-owner-store.ts +171 -0
  162. package/src/types.ts +292 -21
@@ -23,12 +23,27 @@ import { multiaddr } from '@multiformats/multiaddr';
23
23
  import type { Libp2p } from '@libp2p/interface';
24
24
  import type { StrandFormationDisclosure } from './types.js';
25
25
  import { type ControlStream, writeFrame, withTimeout } from './control-stream.js';
26
+ import { canonicalJson } from './canonical-json.js';
27
+ import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
28
+ import { verifyFormationConsent } from './peer-authorization.js';
29
+ // seed-bootstrap imports neither this protocol nor the manager/solicitation layers,
30
+ // so this import introduces no cycle.
31
+ import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
26
32
 
27
33
  const log = debug('sereus:cadre:formation-proto');
28
34
 
29
35
  /** Protocol id for the native formation transport (parallel to `/sereus/seed/1.0.0`). */
30
36
  export const FORMATION_PROTOCOL = '/sereus/formation/1.0.0';
31
37
 
38
+ /**
39
+ * Rejection reason for an invitation that cannot be redeemed at all — unknown, expired, or
40
+ * fully spent. Shared with `StrandFormationManager`, which reports an
41
+ * `InvitationExhaustedError` (an invite spent out from under a redemption already in flight)
42
+ * with this SAME wording on purpose: a joiner that lost the race must be indistinguishable
43
+ * from a non-racing latecomer, so the two sites must never drift apart.
44
+ */
45
+ export const INVALID_TOKEN_REASON = 'Invalid token';
46
+
32
47
  /** Maximum formation message size (1MB). */
33
48
  const MAX_FORMATION_MSG_SIZE = 1024 * 1024;
34
49
 
@@ -36,7 +51,63 @@ const MAX_FORMATION_MSG_SIZE = 1024 * 1024;
36
51
  const DEFAULT_SESSION_TIMEOUT_MS = 30_000;
37
52
  /** Default per-step (single read/write) timeout (ms). */
38
53
  const DEFAULT_STEP_TIMEOUT_MS = 5_000;
39
- /** Default cap on concurrent inbound formation sessions. */
54
+ /**
55
+ * Default provisioning budget (ms) — distinct from {@link DEFAULT_STEP_TIMEOUT_MS} because
56
+ * `provisionStrand` is real work (DB writes, and an outbound approval-hook HTTP call when
57
+ * the invite carries a `ValidationUrl`), not a bare wire read/write.
58
+ *
59
+ * Ordering is deliberate — each layer must be able to fail and report before the layer above
60
+ * it gives up:
61
+ *
62
+ * approval hook (10 s, `formation-approval.ts`)
63
+ * < responder provisioning (12 s)
64
+ * < initiator await-response (15 s)
65
+ * < session (30 s)
66
+ *
67
+ * The 3 s margin between responder provisioning and initiator await-response is the wire
68
+ * latency budget for the result frame to travel back.
69
+ *
70
+ * The last {@link PROVISION_SETTLE_GRACE_MS} of this budget is a settle grace, so the default
71
+ * WORK budget (12 s − 2 s = 10 s) EQUALS the hook's own 10 s: a dead hook races 'Formation
72
+ * approval unavailable, retry' against 'Formation provisioning timed out'. Both are retryable
73
+ * and both leave the invite unspent, so the race is benign.
74
+ */
75
+ const DEFAULT_PROVISION_TIMEOUT_MS = 12_000;
76
+ /**
77
+ * Settle grace (ms), carved OUT of `provisionTimeoutMs` — never added on top, so the budget
78
+ * ladder above is untouched. The listener aborts the provisioning hook when the work budget
79
+ * ({@link splitProvisionBudget}) expires, then waits up to this grace for the work to settle
80
+ * anyway: once the `FormationUsage` insert has been issued nothing can un-spend it (the table
81
+ * is append-only), so a provisioning that lands late is ADOPTED and reported as a real
82
+ * approval rather than lying "timed out" over a spent invite. A hook that observes the
83
+ * abort before writing leaves the invite unspent.
84
+ */
85
+ const PROVISION_SETTLE_GRACE_MS = 2_000;
86
+ /**
87
+ * Wire-latency margin (ms) added to the responder's provisioning budget to get the
88
+ * initiator's await-response budget — the travel time for the result frame to come back
89
+ * after the responder finishes (see {@link DEFAULT_PROVISION_TIMEOUT_MS}).
90
+ * `StrandFormationManager` reuses this constant so a CONFIGURED budget preserves the same
91
+ * margin the defaults do, instead of applying the same number to both sides and racing the
92
+ * initiator's own timeout against the responder's clean rejection reply. The responder's
93
+ * clamp ({@link provisionCeilingMs}) holds the same margin back, so the ordering survives a
94
+ * budget large enough that BOTH roles get clamped.
95
+ *
96
+ * NOTE: 3 s is a fixed assumption about how long the result frame takes to travel back. If
97
+ * formation ever runs over paths where that frame can take longer (a slow circuit relay, a
98
+ * congested mobile link), the margin is too thin and the initiator's timeout races the
99
+ * responder's reply again — raise it, or derive it from an observed round-trip.
100
+ */
101
+ export const PROVISION_RESPONSE_TRAVEL_MARGIN_MS = 3_000;
102
+ /** Default initiator await-response budget (ms); see {@link DEFAULT_PROVISION_TIMEOUT_MS}. */
103
+ const DEFAULT_INITIATOR_PROVISION_TIMEOUT_MS = DEFAULT_PROVISION_TIMEOUT_MS + PROVISION_RESPONSE_TRAVEL_MARGIN_MS;
104
+ /**
105
+ * Default cap on concurrent inbound formation sessions.
106
+ * NOTE: a longer provisioning budget holds a session's slot longer, so a slow hook can
107
+ * exhaust this cap and make the listener reject fresh joiners with 'Too many concurrent
108
+ * formation sessions'. Fine at the current default; revisit if `provisionTimeoutMs` is
109
+ * raised well above its default or this cap is lowered.
110
+ */
40
111
  const DEFAULT_MAX_CONCURRENT_SESSIONS = 100;
41
112
 
42
113
  // ── Roles ────────────────────────────────────────────────────────────────────
@@ -85,8 +156,20 @@ export type ResponderProvisionOutcome =
85
156
  export interface FormationContactMessage {
86
157
  /** The real invitation token. */
87
158
  token: string;
88
- /** Initiator's member key (peer id). */
159
+ /** Initiator's member key (peer id) — human-facing identity in logs and `FormStrandResult.memberKey`. */
89
160
  partyId: string;
161
+ /**
162
+ * Initiator's base64url ed25519 public key — the key behind `partyId`, written to
163
+ * `FormationUsage.PeerKey` and covered by BOTH signed digests.
164
+ */
165
+ peerKey: string;
166
+ /** Joiner-minted single-use nonce for THIS redemption; both signed digests cover it. */
167
+ usageStampId: string;
168
+ /**
169
+ * The joiner's signature over the `'consent'` digest (see `formationConsentMessage`
170
+ * in control-database.ts) — proof the named peer agreed to this redemption.
171
+ */
172
+ peerSignature: string;
90
173
  /** The real disclosure, carried verbatim. */
91
174
  disclosure: StrandFormationDisclosure;
92
175
  /** Initiator's real multiaddrs. */
@@ -193,6 +276,62 @@ export function isValidResponderCreatesResult(response: FormationResultMessage):
193
276
  return true;
194
277
  }
195
278
 
279
+ /**
280
+ * Ceiling for a resolved provisioning budget.
281
+ *
282
+ * The session budget also has to cover the wire step that PRECEDES provisioning (the
283
+ * responder's contact read, the initiator's dial-connect), so a whole step is held back
284
+ * rather than triggering only on a literal overrun.
285
+ *
286
+ * `reserveMs` is the EXTRA room only the responder holds back, so its own budget still
287
+ * lands strictly before the initiator's larger await-response budget even when both are
288
+ * clamped — without it, any configured budget at or above `sessionTimeoutMs -
289
+ * stepTimeoutMs` clamps both roles onto the same number and the responder's clean
290
+ * rejection races the initiator's timeout again. Capped at half the remaining room (like
291
+ * {@link splitProvisionBudget}) so a small session config still spends most of it working.
292
+ */
293
+ function provisionCeilingMs(sessionTimeoutMs: number, stepTimeoutMs: number, reserveMs: number): number {
294
+ const roomMs = sessionTimeoutMs - stepTimeoutMs;
295
+ return Math.max(1, roomMs - Math.min(reserveMs, Math.floor(roomMs / 2)));
296
+ }
297
+
298
+ /**
299
+ * Resolve a caller-supplied provisioning budget: `0`/negative means "unset" (use
300
+ * `defaultMs`). If the result would let provisioning outlive the session — no result
301
+ * frame is ever sent, exactly the failure this budget exists to prevent — clamp it to
302
+ * {@link provisionCeilingMs} and log a warning.
303
+ */
304
+ function resolveProvisionTimeoutMs(
305
+ configured: number | undefined,
306
+ defaultMs: number,
307
+ sessionTimeoutMs: number,
308
+ stepTimeoutMs: number,
309
+ role: string,
310
+ reserveMs = 0
311
+ ): number {
312
+ const requested = configured && configured > 0 ? configured : defaultMs;
313
+ const ceilingMs = provisionCeilingMs(sessionTimeoutMs, stepTimeoutMs, reserveMs);
314
+ if (requested >= ceilingMs) {
315
+ log(
316
+ '%s provisionTimeoutMs %dms leaves no room under sessionTimeoutMs %dms (step %dms, reserve %dms); clamping to %dms',
317
+ role, requested, sessionTimeoutMs, stepTimeoutMs, reserveMs, ceilingMs
318
+ );
319
+ return ceilingMs;
320
+ }
321
+ return requested;
322
+ }
323
+
324
+ /**
325
+ * Split a resolved provisioning budget into the WORK budget and the trailing settle grace.
326
+ *
327
+ * The grace is carved OUT of the budget (see {@link PROVISION_SETTLE_GRACE_MS}), and capped at
328
+ * half of it so a small configured budget still spends at least half its time doing work.
329
+ */
330
+ function splitProvisionBudget(provisionTimeoutMs: number): { workMs: number; graceMs: number } {
331
+ const graceMs = Math.min(PROVISION_SETTLE_GRACE_MS, Math.floor(provisionTimeoutMs / 2));
332
+ return { workMs: provisionTimeoutMs - graceMs, graceMs };
333
+ }
334
+
196
335
  // ── Responder (listener) ─────────────────────────────────────────────────────
197
336
 
198
337
  export interface FormationListenerOptions {
@@ -202,18 +341,31 @@ export interface FormationListenerOptions {
202
341
  validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
203
342
  /**
204
343
  * Provision (or, for provision-then-record, resolve + record consent against) the
205
- * strand (`responderCreates`) for the given initiator. The REAL token is threaded
206
- * in so the hook can map it to the bound host strand and its membership key.
344
+ * strand (`responderCreates`) for the given initiator. The WHOLE contact message is
345
+ * threaded in: the hook needs the REAL token to map the bound host strand, and the
346
+ * joiner's `peerKey`/`usageStampId`/`peerSignature` to write the consent columns.
207
347
  *
208
348
  * Returns a {@link ResponderProvisionOutcome}: the hook may REJECT post-validation
209
349
  * (e.g. an unconverged host strand) and the listener turns that into a clean,
210
350
  * non-disclosing `approved: false` reply rather than dropping the result frame.
351
+ *
352
+ * `signal` is aborted when the listener's work budget expires: a hook that observes it
353
+ * BEFORE writing abandons the redemption and leaves the invite unspent. Optional so
354
+ * signal-unaware hooks (tests, mocks) stay assignable.
211
355
  */
212
- provisionStrand(token: string, initiatorPartyId: string, disclosure: StrandFormationDisclosure): Promise<ResponderProvisionOutcome>;
356
+ provisionStrand(contact: FormationContactMessage, signal?: AbortSignal): Promise<ResponderProvisionOutcome>;
213
357
  /** Responder identity, disclosed only AFTER token + disclosure validation passes. */
214
358
  getResponderIdentity(): { partyId: string; cadrePeerAddrs: string[] };
215
359
  sessionTimeoutMs?: number;
216
360
  stepTimeoutMs?: number;
361
+ /**
362
+ * Budget for the `provisionStrand` hook call — distinct from `stepTimeoutMs` because
363
+ * provisioning is real work, not a bare wire read/write. Default 12 s; `0`/unset uses
364
+ * the default; a value that would outlive the session is clamped ({@link
365
+ * provisionCeilingMs}). See {@link DEFAULT_PROVISION_TIMEOUT_MS} for the full ordering
366
+ * rationale.
367
+ */
368
+ provisionTimeoutMs?: number;
217
369
  maxConcurrentSessions?: number;
218
370
  }
219
371
 
@@ -227,6 +379,8 @@ export class FormationListener {
227
379
  private readonly options: FormationListenerOptions;
228
380
  private readonly sessionTimeoutMs: number;
229
381
  private readonly stepTimeoutMs: number;
382
+ private readonly provisionWorkMs: number;
383
+ private readonly provisionGraceMs: number;
230
384
  private readonly maxConcurrentSessions: number;
231
385
  private readonly registered = new Set<Libp2p>();
232
386
  private activeSessions = 0;
@@ -236,6 +390,13 @@ export class FormationListener {
236
390
  this.options = options;
237
391
  this.sessionTimeoutMs = options.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
238
392
  this.stepTimeoutMs = options.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
393
+ const split = splitProvisionBudget(resolveProvisionTimeoutMs(
394
+ options.provisionTimeoutMs, DEFAULT_PROVISION_TIMEOUT_MS,
395
+ this.sessionTimeoutMs, this.stepTimeoutMs, 'FormationListener',
396
+ PROVISION_RESPONSE_TRAVEL_MARGIN_MS
397
+ ));
398
+ this.provisionWorkMs = split.workMs;
399
+ this.provisionGraceMs = split.graceMs;
239
400
  this.maxConcurrentSessions = options.maxConcurrentSessions ?? DEFAULT_MAX_CONCURRENT_SESSIONS;
240
401
  }
241
402
 
@@ -282,6 +443,125 @@ export class FormationListener {
282
443
  }
283
444
  }
284
445
 
446
+ /**
447
+ * Run the provisioning hook under its WORK budget — the configured provisioning budget minus
448
+ * the trailing settle grace ({@link splitProvisionBudget}, {@link PROVISION_SETTLE_GRACE_MS}).
449
+ *
450
+ * When the work budget expires the hook's signal is aborted, then
451
+ * {@link settleWithinGrace} waits out the grace for the aborted work to settle anyway.
452
+ * Returns `undefined` for "timed out" — which the caller turns into a retryable rejection
453
+ * frame — and rethrows every other failure so it reaches the internal-error path.
454
+ *
455
+ * Deliberately not `withDeadline`: that exposes neither the timed-out flag nor the signal
456
+ * outside its `op`, and both are needed here.
457
+ */
458
+ private async provision(id: number, contact: FormationContactMessage): Promise<ResponderProvisionOutcome | undefined> {
459
+ const controller = new AbortController();
460
+ let timedOut = false;
461
+ const pending = this.options.provisionStrand(contact, controller.signal);
462
+ try {
463
+ return await withTimeout(
464
+ this.provisionWorkMs,
465
+ `Formation provisioning#${id}`,
466
+ () => pending,
467
+ () => { timedOut = true; controller.abort(); }
468
+ );
469
+ } catch (err) {
470
+ if (!timedOut) throw err;
471
+ log(
472
+ 'formation session #%d provisioning work budget expired after %dms; aborted, settling up to %dms',
473
+ id, this.provisionWorkMs, this.provisionGraceMs
474
+ );
475
+ return await this.settleWithinGrace(id, pending);
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Wait out the settle grace for an ABORTED provisioning, adopting a late success.
481
+ *
482
+ * A hook that observed the abort before issuing its `FormationUsage` insert rejects (with
483
+ * `FormationAbortedError`) and leaves the invite unspent → reported as a timeout. A hook
484
+ * that had already written cannot be un-written (the table is append-only), so if it lands
485
+ * inside the grace its outcome is ADOPTED — the joiner is told the truth (approved, or the
486
+ * hook's own rejection reason) rather than "timed out" over a spent invite.
487
+ *
488
+ * NOTE: one window stays open — a work budget that expires after the `FormationUsage`
489
+ * insert was issued AND a commit that outlasts the grace. The joiner is told 'timed out'
490
+ * while its one-time invite is in fact spent, and since every retry mints a fresh keypair
491
+ * no recovery path can match it. Nothing can un-spend an append-only row, so the late-settle
492
+ * logging below is the observability for it; widen the grace only if it is seen in the wild.
493
+ */
494
+ private async settleWithinGrace(
495
+ id: number,
496
+ pending: Promise<ResponderProvisionOutcome>
497
+ ): Promise<ResponderProvisionOutcome | undefined> {
498
+ let stillPending = false;
499
+ try {
500
+ const outcome = await withTimeout(
501
+ this.provisionGraceMs,
502
+ `Formation settle#${id}`,
503
+ () => pending,
504
+ () => { stillPending = true; }
505
+ );
506
+ log('formation session #%d provisioning settled within its grace: approved=%s — adopting', id, outcome.approved);
507
+ return outcome;
508
+ } catch (err) {
509
+ if (!stillPending) {
510
+ log('formation session #%d provisioning failed after its abort: %o', id, err);
511
+ return undefined;
512
+ }
513
+ log('formation session #%d provisioning still pending after its %dms grace', id, this.provisionGraceMs);
514
+ // Log how it eventually settled so a late failure is not swallowed by the two
515
+ // abandoned `withTimeout` calls.
516
+ void pending.then(
517
+ (late) => log('formation session #%d provisioning settled after its grace: approved=%s', id, late.approved),
518
+ (err2) => log('formation session #%d provisioning failed after its grace: %o', id, err2)
519
+ );
520
+ return undefined;
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Joiner-consent pre-check — the legibility twin of the schema's
526
+ * `FormationUsage.PeerConsented` CHECK, exactly as `verifyFormationApproval` is for the
527
+ * approver's vouch. The database constraint stays the authority; this turns a bad consent
528
+ * into a clean 'Invalid joiner consent' rejection instead of an opaque CHECK failure at
529
+ * commit. Runs BEFORE token/disclosure validation: it is the cheapest check (pure local
530
+ * crypto, no DB read) and a rejection discloses nothing.
531
+ *
532
+ * Also pins `partyId` to `peerKey`: an ed25519 peer id is an identity multihash of the
533
+ * very key, and this is the ONLY layer that can check the match — SQL cannot unwrap a
534
+ * multihash, and `partyId` itself is never stored.
535
+ */
536
+ private isJoinerConsentValid(id: number, contact: FormationContactMessage): boolean {
537
+ try {
538
+ requireEd25519PublicKeyB64(contact.peerKey, 'joiner peer key');
539
+ } catch {
540
+ // Deliberately not rethrown or echoed: the helper's error message embeds the whole
541
+ // rejected value, and this one arrived from a remote peer (the cap-the-echo case its
542
+ // own NOTE warns about). Log without the raw value instead.
543
+ log('formation session #%d: joiner peer key is not a base64url ed25519 public key', id);
544
+ return false;
545
+ }
546
+ const embedded = ed25519PublicKeyB64FromPeerId(contact.partyId);
547
+ if (!embedded || embedded !== contact.peerKey) {
548
+ log('formation session #%d: partyId does not embed the presented peer key', id);
549
+ return false;
550
+ }
551
+ const consentOk = verifyFormationConsent({
552
+ token: contact.token,
553
+ usageStampId: contact.usageStampId,
554
+ peerKey: contact.peerKey,
555
+ disclosure: canonicalJson(contact.disclosure),
556
+ peerSig: contact.peerSignature
557
+ });
558
+ if (!consentOk) {
559
+ log('formation session #%d: joiner consent signature failed verification', id);
560
+ return false;
561
+ }
562
+ return true;
563
+ }
564
+
285
565
  private async runSession(id: number, stream: ControlStream): Promise<void> {
286
566
  // Track whether ANY frame has been written so the catch below can convert an
287
567
  // unexpected internal error into a non-disclosing rejection ONLY when nothing has
@@ -297,9 +577,14 @@ export class FormationListener {
297
577
  const contact = await withTimeout(this.stepTimeoutMs, `Formation await-contact#${id}`, () => reader.read<FormationContactMessage>());
298
578
  log('formation session #%d contact: token=%s party=%s', id, contact.token, contact.partyId);
299
579
 
580
+ if (!this.isJoinerConsentValid(id, contact)) {
581
+ send({ approved: false, reason: 'Invalid joiner consent' });
582
+ return;
583
+ }
584
+
300
585
  const tokenResult = await this.options.validateToken(contact.token);
301
586
  if (!tokenResult.valid) {
302
- send({ approved: false, reason: 'Invalid token' });
587
+ send({ approved: false, reason: INVALID_TOKEN_REASON });
303
588
  return;
304
589
  }
305
590
 
@@ -309,7 +594,13 @@ export class FormationListener {
309
594
  return;
310
595
  }
311
596
 
312
- const outcome = await this.options.provisionStrand(contact.token, contact.partyId, contact.disclosure);
597
+ const outcome = await this.provision(id, contact);
598
+ if (!outcome) {
599
+ // Reported as its own retryable reason rather than falling through to the generic
600
+ // catch below, which would report the misleading 'Internal formation error'.
601
+ send({ approved: false, reason: 'Formation provisioning timed out' });
602
+ return;
603
+ }
313
604
  if (!outcome.approved) {
314
605
  // A post-validation rejection still discloses NEITHER identity NOR cadre,
315
606
  // exactly like the token/disclosure rejections above.
@@ -351,6 +642,12 @@ export interface FormationDialOptions {
351
642
  validateResponse(response: FormationResultMessage): Promise<boolean>;
352
643
  sessionTimeoutMs?: number;
353
644
  stepTimeoutMs?: number;
645
+ /**
646
+ * Budget for the `await-response` read only (the responder's provisioning + reply travel
647
+ * time) — `dial-connect` keeps using `stepTimeoutMs`. Default 15 s; `0`/unset uses the
648
+ * default. See {@link DEFAULT_PROVISION_TIMEOUT_MS} for the full ordering rationale.
649
+ */
650
+ provisionTimeoutMs?: number;
354
651
  protocolId?: string;
355
652
  }
356
653
 
@@ -366,6 +663,10 @@ export async function dialFormation(node: Libp2p, options: FormationDialOptions)
366
663
  const protocolId = options.protocolId ?? FORMATION_PROTOCOL;
367
664
  const sessionTimeoutMs = options.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
368
665
  const stepTimeoutMs = options.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
666
+ const provisionTimeoutMs = resolveProvisionTimeoutMs(
667
+ options.provisionTimeoutMs, DEFAULT_INITIATOR_PROVISION_TIMEOUT_MS,
668
+ sessionTimeoutMs, stepTimeoutMs, 'dialFormation'
669
+ );
369
670
  const addr = multiaddr(options.responderAddrs[0]);
370
671
 
371
672
  return withTimeout(sessionTimeoutMs, 'Formation dial', async () => {
@@ -375,7 +676,7 @@ export async function dialFormation(node: Libp2p, options: FormationDialOptions)
375
676
  const reader = new FrameReader(stream);
376
677
  writeFrame(stream, options.contact);
377
678
 
378
- const response = await withTimeout(stepTimeoutMs, 'Formation await-response', () => reader.read<FormationResultMessage>());
679
+ const response = await withTimeout(provisionTimeoutMs, 'Formation await-response', () => reader.read<FormationResultMessage>());
379
680
  if (!response.approved) {
380
681
  throw new Error(`Formation rejected: ${response.reason ?? 'no reason provided'}`);
381
682
  }
@@ -2,6 +2,7 @@ import debug from 'debug';
2
2
  import type { PrivateKey } from '@libp2p/interface';
3
3
  import { createLibp2pNode, type IRawStorage } from '@optimystic/db-p2p';
4
4
  import { StrandDatabase } from './strand-database.js';
5
+ import { StrandBackfill, type StrandBackfillConfig } from './strand-backfill.js';
5
6
  import { assertSchemaSignature } from './schema-verification.js';
6
7
  import type {
7
8
  StrandInstance,
@@ -16,6 +17,8 @@ import type {
16
17
  StrandMode,
17
18
  Libp2pNodeWithRepo
18
19
  } from './types.js';
20
+ import { resolveStrandClusterSize, STRAND_CLUSTER_POLICY } from './types.js';
21
+ import { resolveListenAddrs } from './relay-addrs.js';
19
22
 
20
23
  const log = debug('sereus:cadre:strand-manager');
21
24
  const timing = debug('sereus:cadre:timing');
@@ -53,6 +56,25 @@ export interface StartStrandConfig {
53
56
  * {@link StrandConfig.founder}.
54
57
  */
55
58
  founder?: boolean;
59
+ /**
60
+ * Number of nodes Optimystic is told this strand's replication cluster should
61
+ * have. Same rule as {@link CadreNodeConfig.strandClusterSize}, which CadreNode
62
+ * forwards here: every node on the strand should use the same value, and it is
63
+ * frozen when the strand's libp2p node is created. Defaults to
64
+ * `DEFAULT_STRAND_CLUSTER_SIZE` (4); values below `MIN_CLUSTER_SIZE` (2) are
65
+ * rejected by `resolveStrandClusterSize`. Strand-only — the
66
+ * control network's breadth is the fixed `CONTROL_REPLICATION_BREADTH`.
67
+ */
68
+ clusterSize?: number;
69
+ /**
70
+ * Tuning for the strand peer-join block catch-up ({@link StrandBackfill}),
71
+ * forwarded from {@link CadreNodeConfig.strandBackfill}. When the strand's
72
+ * libp2p node connects to a peer this runtime has not yet caught up, every
73
+ * block in the strand's own raw store is pushed to it. Runs only on
74
+ * `networked` strands with per-strand storage; `{ enabled: false }` restores
75
+ * the pre-existing no-backfill behaviour.
76
+ */
77
+ backfill?: StrandBackfillConfig;
56
78
  }
57
79
 
58
80
  /**
@@ -132,6 +154,15 @@ export class StrandInstanceManager {
132
154
  * without the caller re-threading storage/network/profile/key/sApp config.
133
155
  */
134
156
  private launchConfigs: Map<string, StartStrandConfig> = new Map();
157
+ /**
158
+ * The per-strand peer-join block catch-up, keyed by strand id. Private (not on
159
+ * the public {@link StrandInstance}) because it is runtime plumbing with the
160
+ * same lifetime as the strand's libp2p node: created in `buildStrandRuntime`,
161
+ * stopped and dropped in `releaseRuntime` — so quiesce → resume rebuilds it
162
+ * with a fresh caught-up-peer memo, which is intended (a resumed node may have
163
+ * missed writes).
164
+ */
165
+ private backfills: Map<string, StrandBackfill> = new Map();
135
166
  private stopping = false;
136
167
 
137
168
  constructor() {
@@ -160,7 +191,16 @@ export class StrandInstanceManager {
160
191
  }
161
192
 
162
193
  /**
163
- * Start a new strand instance
194
+ * Start a new strand instance.
195
+ *
196
+ * A failed launch leaves NOTHING tracked: the instance and its retained launch
197
+ * config are both dropped before the error is rethrown, so the strand id is
198
+ * free for a genuine retry. This matches the pre-registration failure path
199
+ * (a rejected schema signature, which throws before anything is recorded) —
200
+ * both failure modes of this call leave the same residue: none. Callers learn
201
+ * of the failure from the rejected promise (and, on the control-discovered
202
+ * path, from CadreNode's `strand:error` event), not from an error record left
203
+ * behind in `instances`.
164
204
  */
165
205
  async startStrand(config: StartStrandConfig): Promise<StrandInstance> {
166
206
  const { strandRow, sAppConfig } = config;
@@ -201,7 +241,10 @@ export class StrandInstanceManager {
201
241
  memberPrivateKey: strandRow.MemberPrivateKey ?? undefined,
202
242
  connectedPeers: 0,
203
243
  lastActivity: new Date(),
204
- latencyHint
244
+ latencyHint,
245
+ // Seed only — buildStrandRuntime overwrites this with the same resolution,
246
+ // so an instance that errors mid-build still reads sensibly.
247
+ mode: config.mode ?? 'networked'
205
248
  };
206
249
 
207
250
  this.instances.set(strandId, instance);
@@ -213,9 +256,15 @@ export class StrandInstanceManager {
213
256
  log('Strand %s started successfully with sApp %s', strandId, sAppConfig.id);
214
257
  return instance;
215
258
  } catch (error) {
259
+ // Status/error first — the (now discarded) record is still what `log` reports on.
216
260
  instance.status = 'error';
217
261
  instance.error = error instanceof Error ? error.message : String(error);
218
262
  log('Failed to start strand %s: %s', strandId, instance.error);
263
+ // Drop the dead record so this strand id can be launched again. Keep the
264
+ // `launchConfigs` has an entry iff `instances` does invariant — resumeStrand
265
+ // reads both, and a config without an instance would strand the config.
266
+ this.instances.delete(strandId);
267
+ this.launchConfigs.delete(strandId);
219
268
  throw error;
220
269
  }
221
270
  }
@@ -231,6 +280,7 @@ export class StrandInstanceManager {
231
280
  private async buildStrandRuntime(instance: StrandInstance, config: StartStrandConfig): Promise<void> {
232
281
  const strandId = instance.strandId;
233
282
  const { sAppConfig } = config;
283
+ const mode = config.mode ?? 'networked';
234
284
 
235
285
  // Resolve storage for this strand. If a factory function is provided, it is
236
286
  // called with the strandId to create strand-specific storage (e.g.,
@@ -240,43 +290,60 @@ export class StrandInstanceManager {
240
290
  log('Strand %s using provided storage provider', strandId);
241
291
  }
242
292
 
243
- // Note: protocolPrefix would be used by libp2p services, but createLibp2pNode
244
- // may not support it yet - tracked for future implementation.
245
- const _protocolPrefix = `/sereus/strand/${strandId}`;
293
+ // db-p2p namespaces every one of the node's protocol ids by network name
294
+ // (`/optimystic/<networkName>/...`), so anything dialing this node's own
295
+ // services must derive its prefix from the SAME string the node was built
296
+ // with — hence one binding for both, not two literals that can drift.
297
+ const networkName = `strand-${strandId}`;
298
+ const protocolPrefix = `/optimystic/${networkName}`;
246
299
 
247
300
  // Determine relay mode: if explicitly set in config, use that;
248
301
  // otherwise default to true for storage profile nodes.
249
302
  const enableRelay = config.network?.enableRelay ?? (config.profile === 'storage');
303
+ const listenAddrs = resolveListenAddrs(config.network);
250
304
 
251
305
  try {
252
306
  let t0 = performance.now();
253
307
  const node = await createLibp2pNode({
254
308
  port: 0, // Random port
255
309
  bootstrapNodes: config.bootstrapNodes ?? [],
256
- networkName: `strand-${strandId}`,
310
+ networkName,
257
311
  storage: strandStorage,
258
312
  fretProfile: config.profile === 'storage' ? 'core' : 'edge',
259
313
  relay: enableRelay,
260
- clusterSize: 3,
261
- clusterPolicy: {
262
- allowDownsize: true,
263
- sizeTolerance: 0.5
264
- },
314
+ clusterSize: resolveStrandClusterSize(config.clusterSize),
315
+ // Deliberately NOT CONTROL_CLUSTER_POLICY: a strand is application data with its own
316
+ // breadth reasoning, and the shape match with the control policy is a coincidence.
317
+ clusterPolicy: STRAND_CLUSTER_POLICY,
265
318
  arachnode: {
266
319
  enableRingZulu: config.profile === 'storage'
267
320
  },
268
321
  ...(config.privateKey && { privateKey: config.privateKey }),
269
322
  ...(config.network?.transports && { transports: config.network.transports }),
270
- ...(config.network?.listenAddrs && { listenAddrs: config.network.listenAddrs }),
323
+ // NOTE: strand nodes receive the same RESOLVED listen addrs as the
324
+ // control node. A fixed-port listen addr (e.g. cadre-cli's example
325
+ // `/ip4/0.0.0.0/tcp/4001`) would have control + strand nodes racing to
326
+ // bind one port — EADDRINUSE. Unverified; if a deployment configures a
327
+ // fixed port and strands fail to start, rewrite the port per node here.
328
+ // An inherited `/p2p-circuit` addr, by contrast, is deliberate — it is
329
+ // what gives a NAT'd strand node a reachable relay slot — and works
330
+ // because the launch path announces this strand's derived peerId to
331
+ // the relay first (delegate admission; see cadre-node.ts). Those circuit
332
+ // entries come either from a hand-written `network.listenAddrs` or from
333
+ // `network.relayAddrs`, which `resolveListenAddrs` folds into the same list.
334
+ ...(listenAddrs && { listenAddrs }),
271
335
  ...(config.network?.connectionGater && { connectionGater: config.network.connectionGater })
272
336
  }) as Libp2pNodeWithRepo;
273
337
  timing('[buildStrandRuntime:%s] createLibp2pNode: %dms', strandId, Math.round(performance.now() - t0));
274
338
 
275
339
  instance.libp2pNode = node;
340
+ // Assigned on every runtime (re)build — resume re-enters here with
341
+ // resumeConfig, so a bootstrap → networked shift refreshes the field.
342
+ instance.mode = mode;
276
343
 
277
344
  // Create and initialize the StrandDatabase. In bootstrap mode the same
278
345
  // strandStorage instance also backs the optimystic local transactor so
279
- // DML lands on the host's persistent storage (e.g. MMKV on RN). Sharing
346
+ // DML lands on the host's persistent storage (e.g. LevelDB on RN). Sharing
280
347
  // the instance — not creating a second one over the same id+prefix —
281
348
  // avoids cache divergence between the libp2p and database paths.
282
349
  //
@@ -288,7 +355,7 @@ export class StrandInstanceManager {
288
355
  sAppConfig,
289
356
  libp2pNode: node,
290
357
  coordinatedRepo: node.coordinatedRepo,
291
- mode: config.mode ?? 'networked',
358
+ mode,
292
359
  rawStorage: strandStorage,
293
360
  // Founder bootstrap inputs: the strand's type drives which membership rows
294
361
  // are written, and the closed-strand MemberPrivateKey derives the founding
@@ -301,6 +368,28 @@ export class StrandInstanceManager {
301
368
  await strandDb.initialize();
302
369
  timing('[buildStrandRuntime:%s] strandDatabase.initialize: %dms', strandId, Math.round(performance.now() - t0));
303
370
 
371
+ // Peer-join block catch-up: push this strand's own blocks to each newly
372
+ // connected peer, so a machine that joined after blocks were committed
373
+ // still ends up physically holding them (a bootstrap strand has no mesh
374
+ // to catch up, and without per-strand storage there is nothing to copy).
375
+ if (mode === 'networked' && strandStorage && config.backfill?.enabled !== false) {
376
+ if (node.keyNetwork) {
377
+ const backfill = new StrandBackfill({
378
+ strandId,
379
+ libp2p: node,
380
+ peerNetwork: node.keyNetwork,
381
+ storage: strandStorage,
382
+ // The same prefix the receiver registered its block-transfer handler
383
+ // under — derived from networkName above, never re-spelled here.
384
+ protocolPrefix
385
+ }, config.backfill);
386
+ backfill.start();
387
+ this.backfills.set(strandId, backfill);
388
+ } else {
389
+ log('Strand %s: libp2p node exposes no keyNetwork; peer-join block catch-up is inert', strandId);
390
+ }
391
+ }
392
+
304
393
  instance.status = 'active';
305
394
  instance.lastActivity = new Date();
306
395
  } catch (error) {
@@ -323,6 +412,14 @@ export class StrandInstanceManager {
323
412
  * Shared by `quiesceStrand`, `stopStrand`, and that rollback path.
324
413
  */
325
414
  private async releaseRuntime(instance: StrandInstance): Promise<void> {
415
+ // Backfill first — before the database closes and the libp2p node stops — so
416
+ // no NEW catch-up push is issued against a torn-down transport. A push already
417
+ // in flight is not awaited; it fails into the module's own per-chunk catch.
418
+ const backfill = this.backfills.get(instance.strandId);
419
+ if (backfill) {
420
+ backfill.stop();
421
+ this.backfills.delete(instance.strandId);
422
+ }
326
423
  if (instance.database) {
327
424
  await instance.database.close();
328
425
  instance.database = undefined;