@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
@@ -3,12 +3,16 @@ import { toString as uint8ArrayToString } from 'uint8arrays';
3
3
  import { generateKeyPair, privateKeyToProtobuf } from '@libp2p/crypto/keys';
4
4
  import { peerIdFromPrivateKey } from '@libp2p/peer-id';
5
5
  import type { Libp2p } from '@libp2p/interface';
6
+ import { sign } from '@optimystic/quereus-plugin-crypto';
6
7
  import type {
7
8
  OpenInvitation,
8
9
  FormStrandResult,
9
- ValidateFormationResult,
10
10
  StrandFormationDisclosure
11
11
  } from './types.js';
12
+ // control-database does not import this service, so these imports introduce no cycle.
13
+ import { generateStampId, formationConsentMessage } from './control-database.js';
14
+ import { canonicalJson } from './canonical-json.js';
15
+ import { ed25519KeyPairFromLibp2p } from './ed25519-key.js';
12
16
  import {
13
17
  StrandFormationManager,
14
18
  type StrandFormationManagerConfig
@@ -47,6 +51,10 @@ export interface DisclosureValidator {
47
51
  *
48
52
  * Replaces the older `{ strandId, memberPrivateKey } | null` shape, which conflated `bound`
49
53
  * and `missing` (both produced a non-null result whenever the invite had a `StrandId`).
54
+ *
55
+ * The invite's `ValidationUrl` (the outside approval hook) is deliberately NOT surfaced here:
56
+ * the recorder that performs the write reads it itself, so the choice of which hook to trust
57
+ * never travels through a caller.
50
58
  */
51
59
  export type ResolvedHostStrand =
52
60
  | { kind: 'unbound' }
@@ -60,7 +68,20 @@ export interface FormationUsageRecorder {
60
68
  /**
61
69
  * Record that a formation invite was used
62
70
  */
63
- recordUsage(token: string, initiatorKey: string, strandId: string): Promise<void>;
71
+ recordUsage(params: {
72
+ token: string;
73
+ /** The joining peer's own ed25519 public key — written to `FormationUsage.PeerKey` and inside BOTH signed digests. */
74
+ peerKey: string;
75
+ /** The joiner's signature over the `'consent'` digest — written to `FormationUsage.PeerSig`. */
76
+ peerSignature: string;
77
+ /** Joiner-minted single-use nonce; both signed digests cover it. */
78
+ usageStampId: string;
79
+ strandId: string;
80
+ /** Exact text to write to `FormationUsage.Disclosure`; joiner and approver both sign these bytes. */
81
+ disclosure: string;
82
+ /** Aborted when the caller has given up; observed BEFORE any write so the invite stays unspent. */
83
+ signal?: AbortSignal;
84
+ }): Promise<void>;
64
85
 
65
86
  /**
66
87
  * Check if a token has already been used (for single-use invites)
@@ -86,14 +107,32 @@ export interface FormationUsageRecorder {
86
107
  * ATOMICALLY (single `FormationUsage` row → single-use enforced on the next redemption).
87
108
  * Optional: a recorder that only supports provision-then-record omits it, and the
88
109
  * manager falls back to its {@link StrandProvisioner}. Mirrors the create-strand-by-consent
89
- * path the schema's `Strand.Authorized` `FormationUsage` branch authorizes. Returns the
110
+ * path the schema's `Strand.AuthorizedInsert` `FormationUsage` branch authorizes. Returns the
90
111
  * minted strand id plus its membership key (null for an open responder-provisioned strand).
91
112
  */
92
- provisionAndRecord?(
93
- token: string,
94
- initiatorKey: string,
95
- sAppId: string
96
- ): Promise<{ strandId: string; memberPrivateKey: string | null }>;
113
+ provisionAndRecord?(params: {
114
+ token: string;
115
+ /** The joining peer's own ed25519 public key — written to `FormationUsage.PeerKey` and inside BOTH signed digests. */
116
+ peerKey: string;
117
+ /** The joiner's signature over the `'consent'` digest — written to `FormationUsage.PeerSig`. */
118
+ peerSignature: string;
119
+ /** Joiner-minted single-use nonce; both signed digests cover it. */
120
+ usageStampId: string;
121
+ sAppId: string;
122
+ /** Exact text to write to `FormationUsage.Disclosure`; joiner and approver both sign these bytes. */
123
+ disclosure: string;
124
+ /** Aborted when the caller has given up; observed BEFORE any write so the invite stays unspent. */
125
+ signal?: AbortSignal;
126
+ }): Promise<{ strandId: string; memberPrivateKey: string | null }>;
127
+
128
+ /**
129
+ * Is ANY invitation this recorder knows about unexpired and not fully
130
+ * consumed? Optional: a recorder that cannot enumerate invitations omits it,
131
+ * and only locally-minted invitations hold the connection gate's formation
132
+ * exemption open. Distinct from the per-token {@link isTokenValid} /
133
+ * {@link isTokenUsed} pair — the connection gate has no token to ask about.
134
+ */
135
+ hasOutstandingInvitation?(): Promise<boolean>;
97
136
  }
98
137
 
99
138
  /**
@@ -110,19 +149,6 @@ export interface StrandProvisioner {
110
149
  ): Promise<{ strandId: string }>;
111
150
  }
112
151
 
113
- /**
114
- * Interface for signing formation approvals
115
- */
116
- export interface FormationSigner {
117
- /**
118
- * Sign a formation approval
119
- */
120
- signFormation(token: string, disclosure: StrandFormationDisclosure): Promise<{
121
- validationKey: string;
122
- validationSignature: string;
123
- }>;
124
- }
125
-
126
152
  /**
127
153
  * Interface for validating the responder's formation result on the initiator side.
128
154
  *
@@ -161,7 +187,6 @@ export interface StrandSolicitationServiceOptions {
161
187
  disclosureValidator?: DisclosureValidator;
162
188
  formationUsageRecorder?: FormationUsageRecorder;
163
189
  strandProvisioner?: StrandProvisioner;
164
- formationSigner?: FormationSigner;
165
190
  /** Validates the responder's result on the initiator side (defaults to a structural check) */
166
191
  formationResponseValidator?: FormationResponseValidator;
167
192
  /** Party ID for this node (used in protocol messages) */
@@ -177,7 +202,10 @@ export interface StrandSolicitationServiceOptions {
177
202
  *
178
203
  * This service handles the high-level API defined in api.md:
179
204
  * - formStrand(invitation, disclosure, node) - called by initiator
180
- * - validateStrandFormation(token, disclosure) - called by responder
205
+ *
206
+ * Outside approval of a redemption (an invite's `ValidationUrl`) is NOT here: it belongs to
207
+ * the redeeming node, which alone mints the nonce the approval is bound to. See
208
+ * `formation-approval.ts`.
181
209
  *
182
210
  * When a libp2p node is provided, the underlying protocol is handled by the
183
211
  * native cadre-core formation transport via StrandFormationManager.
@@ -186,18 +214,28 @@ export class StrandSolicitationService {
186
214
  private readonly disclosureValidator?: DisclosureValidator;
187
215
  private readonly formationUsageRecorder?: FormationUsageRecorder;
188
216
  private readonly strandProvisioner?: StrandProvisioner;
189
- private readonly formationSigner?: FormationSigner;
190
217
  private readonly formationResponseValidator?: FormationResponseValidator;
191
218
  private readonly partyId: string;
192
219
  private readonly cadrePeerAddrs: string[];
193
220
  private formationManager?: StrandFormationManager;
194
221
  private readonly formationConfig?: StrandFormationManagerConfig;
222
+ /**
223
+ * Tokens this process has minted (or published), mapped to their expiry
224
+ * epoch-ms — the in-memory half of {@link hasOutstandingInvitation}. Dies with
225
+ * the process, exactly like `CadreNode`'s enrollment window; a token that also
226
+ * reached the durable `FormationInvite` table survives via the recorder.
227
+ *
228
+ * NOTE: pruned only while {@link hasOutstandingInvitation} runs, so a node that
229
+ * mints steadily and never receives an inbound stranger retains every entry for
230
+ * the process's life. Bounded by mint rate today; if a host ever mints at scale,
231
+ * prune on a timer (or cap the map) instead of only on read.
232
+ */
233
+ private readonly mintedInvitations = new Map<string, number>();
195
234
 
196
235
  constructor(options?: StrandSolicitationServiceOptions) {
197
236
  this.disclosureValidator = options?.disclosureValidator;
198
237
  this.formationUsageRecorder = options?.formationUsageRecorder;
199
238
  this.strandProvisioner = options?.strandProvisioner;
200
- this.formationSigner = options?.formationSigner;
201
239
  this.formationResponseValidator = options?.formationResponseValidator;
202
240
  this.partyId = options?.partyId ?? `party-${Date.now()}`;
203
241
  this.cadrePeerAddrs = options?.cadrePeerAddrs ?? [];
@@ -275,9 +313,29 @@ export class StrandSolicitationService {
275
313
  // If we have a full invitation and a node, use the real protocol
276
314
  if (typeof invitation !== 'string' && node) {
277
315
  log('Using native cadre-core formation transport');
316
+ const { privateKeyB64, publicKeyB64 } = ed25519KeyPairFromLibp2p(privateKey);
317
+ const usageStampId = generateStampId(memberKey);
318
+ // Built ONCE, AFTER the partyId override: this exact object travels to the
319
+ // responder, and its canonical serialization is the disclosure text the consent
320
+ // signature covers and the responder writes to `FormationUsage.Disclosure`.
321
+ const fullDisclosure = { ...disclosure, partyId: memberKey };
322
+ const peerSignature = sign(
323
+ formationConsentMessage({
324
+ token,
325
+ usageStampId,
326
+ peerKey: publicKeyB64,
327
+ disclosure: canonicalJson(fullDisclosure)
328
+ }),
329
+ privateKeyB64,
330
+ 'ed25519',
331
+ 'bytes',
332
+ 'base64url',
333
+ 'base64url'
334
+ ) as string;
278
335
  const result = await this.getFormationManager().formStrand(
279
336
  invitation,
280
- { ...disclosure, partyId: memberKey },
337
+ fullDisclosure,
338
+ { peerKey: publicKeyB64, usageStampId, peerSignature },
281
339
  node
282
340
  );
283
341
  return {
@@ -301,67 +359,6 @@ export class StrandSolicitationService {
301
359
  };
302
360
  }
303
361
 
304
- /**
305
- * Validate a strand formation request.
306
- *
307
- * Called by the responder (the party who created the open invitation) when
308
- * an initiator contacts them to form a strand. Validates the disclosure
309
- * and returns a signed approval.
310
- *
311
- * @param token The invitation token being used
312
- * @param disclosure The disclosure from the initiator
313
- * @returns Validation key and signature if approved
314
- * @throws If validation fails or token is invalid
315
- */
316
- async validateStrandFormation(
317
- token: string,
318
- disclosure: StrandFormationDisclosure
319
- ): Promise<ValidateFormationResult> {
320
- log('Validating strand formation for token: %s', token);
321
-
322
- // Check token validity
323
- if (this.formationUsageRecorder) {
324
- const tokenCheck = await this.formationUsageRecorder.isTokenValid(token);
325
- if (!tokenCheck.valid) {
326
- log('Token invalid or expired: %s', token);
327
- throw new Error('Invalid or expired token');
328
- }
329
-
330
- // Check if already used (for single-use tokens)
331
- const isUsed = await this.formationUsageRecorder.isTokenUsed(token);
332
- if (isUsed) {
333
- log('Token already used: %s', token);
334
- throw new Error('Token has already been used');
335
- }
336
- }
337
-
338
- // Validate the disclosure
339
- if (this.disclosureValidator) {
340
- const isValid = await this.disclosureValidator.validateDisclosure(token, disclosure);
341
- if (!isValid) {
342
- log('Disclosure validation failed for token: %s', token);
343
- throw new Error('Disclosure validation failed');
344
- }
345
- }
346
-
347
- // Sign the formation approval
348
- if (!this.formationSigner) {
349
- throw new Error('FormationSigner not configured');
350
- }
351
-
352
- const { validationKey, validationSignature } = await this.formationSigner.signFormation(
353
- token,
354
- disclosure
355
- );
356
-
357
- log('Formation validated, key: %s', validationKey);
358
-
359
- return {
360
- validationKey,
361
- validationSignature
362
- };
363
- }
364
-
365
362
  /**
366
363
  * Create an open invitation for others to form strands with this party.
367
364
  *
@@ -378,6 +375,7 @@ export class StrandSolicitationService {
378
375
  // Generate a unique token
379
376
  const token = `invite-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
380
377
  const expiration = new Date(Date.now() + expirationMs);
378
+ this.registerMintedInvitation(token, expiration.getTime());
381
379
 
382
380
  log('Created open invitation: %s (expires %s)', token, expiration.toISOString());
383
381
 
@@ -389,17 +387,84 @@ export class StrandSolicitationService {
389
387
  };
390
388
  }
391
389
 
390
+ /**
391
+ * Remember a token this node minted (or published) so
392
+ * {@link hasOutstandingInvitation} can answer "yes" before the durable
393
+ * `FormationInvite` row is readable — or when there is no durable store at
394
+ * all. `expiresAtMs` may be `Number.POSITIVE_INFINITY` for an invitation that
395
+ * never expires.
396
+ */
397
+ registerMintedInvitation(token: string, expiresAtMs: number): void {
398
+ this.mintedInvitations.set(token, expiresAtMs);
399
+ log('Registered minted invitation %s (expires %d)', token, expiresAtMs);
400
+ }
401
+
402
+ /**
403
+ * Does this node currently expect a stranger — i.e. is at least one open
404
+ * invitation unexpired and not yet fully consumed? Sole input to the
405
+ * control-network connection gate's formation exemption.
406
+ *
407
+ * Two sources, in order:
408
+ *
409
+ * 1. The local mint registry ({@link registerMintedInvitation}), which is
410
+ * in-memory and dies with the process. Expired entries are dropped. With
411
+ * no {@link FormationUsageRecorder} configured there is no consumption
412
+ * oracle, so an unexpired minted token counts as outstanding by
413
+ * construction; with one, a token it reports used is deleted (consumption
414
+ * is permanent, so the registry never reconsiders it).
415
+ * 2. The recorder's optional {@link FormationUsageRecorder.hasOutstandingInvitation},
416
+ * which survives restarts and sees invitations replicated in from siblings.
417
+ *
418
+ * Re-entrant: concurrent inbound connections each call this independently and
419
+ * the only mutation is deleting expired/consumed entries, which is monotonic.
420
+ */
421
+ async hasOutstandingInvitation(): Promise<boolean> {
422
+ const now = Date.now();
423
+ for (const [token, expiresAtMs] of this.mintedInvitations) {
424
+ if (expiresAtMs <= now) {
425
+ this.mintedInvitations.delete(token);
426
+ continue;
427
+ }
428
+ if (!this.formationUsageRecorder) {
429
+ return true;
430
+ }
431
+ if (!(await this.formationUsageRecorder.isTokenUsed(token))) {
432
+ return true;
433
+ }
434
+ this.mintedInvitations.delete(token);
435
+ }
436
+ return (await this.formationUsageRecorder?.hasOutstandingInvitation?.()) ?? false;
437
+ }
438
+
392
439
  /**
393
440
  * Record that a formation was completed successfully.
394
441
  * Called after strand provisioning to track usage.
442
+ *
443
+ * `consent` carries the joiner-signed fields `recordUsage` now requires
444
+ * (see {@link FormationUsageRecorder.recordUsage}); this helper only passes them through.
445
+ *
446
+ * NOTE: this reaches `recordUsage` without passing through
447
+ * `StrandFormationManager.provisionAsResponder`, so `disclosure` skips that path's
448
+ * `MAX_DISCLOSURE_BYTES` cap. Harmless today — its only callers are integration-test mocks
449
+ * that all default to `''` — but if a production caller ever passes a real disclosure here,
450
+ * cap it too (the approver signs those bytes verbatim).
395
451
  */
396
452
  async recordFormationComplete(
397
453
  token: string,
398
- initiatorKey: string,
399
- strandId: string
454
+ peerKey: string,
455
+ strandId: string,
456
+ consent: { usageStampId: string; peerSignature: string },
457
+ disclosure = ''
400
458
  ): Promise<void> {
401
459
  if (this.formationUsageRecorder) {
402
- await this.formationUsageRecorder.recordUsage(token, initiatorKey, strandId);
460
+ await this.formationUsageRecorder.recordUsage({
461
+ token,
462
+ peerKey,
463
+ peerSignature: consent.peerSignature,
464
+ usageStampId: consent.usageStampId,
465
+ strandId,
466
+ disclosure
467
+ });
403
468
  log('Recorded formation usage: token=%s strand=%s', token, strandId);
404
469
  }
405
470
  }
@@ -0,0 +1,58 @@
1
+ import { generateKeyPairFromSeed } from '@libp2p/crypto/keys';
2
+ import type { PrivateKey } from '@libp2p/interface';
3
+ import { digest } from '@optimystic/quereus-plugin-crypto';
4
+ import { ed25519KeyPairFromLibp2p } from './ed25519-key.js';
5
+
6
+ /**
7
+ * Domain-separation tag for the strand-transport-key derivation. Versioned so a
8
+ * future change to the derivation (different KDF, different inputs) can bump to
9
+ * `.v2` and be recognized as a deliberate identity migration rather than silent
10
+ * peerId churn — a strand node's transport peerId must stay stable across
11
+ * process restarts, or any peer-store entry or `MemberPeer` row that names it
12
+ * goes stale on every boot.
13
+ */
14
+ const STRAND_TRANSPORT_KEY_DOMAIN = 'sereus.strand-transport-key.v1';
15
+
16
+ /**
17
+ * Derive a strand node's libp2p transport key from the cadre identity key and
18
+ * the strand id.
19
+ *
20
+ * A `CadreNode` runs its control node and each strand node as separate libp2p
21
+ * instances. Handing them all the same private key gives them all the same
22
+ * peerId, and `@libp2p/circuit-relay-v2` keys reservations and hop-connects by
23
+ * peerId — so a control node and a strand node reserving through one shared
24
+ * relay collide, and relayed streams for one land on the other
25
+ * (https://github.com/gotchoices/sereus/issues/1). Each strand node therefore
26
+ * gets its own transport identity. Cadre *authority* is untouched: the control
27
+ * node keeps the identity key, and every peerId→authority derivation
28
+ * (`ed25519PublicKeyB64FromPeerId`) is a control-network path.
29
+ *
30
+ * The derivation is deterministic — `sha256(domain, identity seed, strandId)`
31
+ * as an Ed25519 seed — rather than a fresh random key per launch, so the
32
+ * strand's transport peerId survives restarts. The identity *private* seed is
33
+ * an input (not the public key) so that no third party can enumerate a member's
34
+ * strand peerIds from public data; only the key holder can compute them.
35
+ *
36
+ * NOTE: the derived peerId *is* attested at runtime: before a strand node
37
+ * starts, its control node announces it over the strand-addr RPC, and a
38
+ * membership-gated relay (a party control node running the relay server) holds
39
+ * a short-lived delegate admission grant for it (`delegate-admission.ts`) —
40
+ * without that grant the relay denies the strand node's reservation and
41
+ * `libp2p.start()` fails. The strand *mesh* itself still does not gate
42
+ * admission by peerId (strand peers are legitimately cross-party); if
43
+ * strand-mesh admission control is ever added, the durable binding it needs is
44
+ * a `MemberPeer(MemberKey, PeerId)` row: the table and its self-signed
45
+ * `Authorized` constraint already exist in `schemas/strand.qsql`; production
46
+ * code simply never writes one.
47
+ *
48
+ * @param identityKey - The cadre's Ed25519 identity key (the control node's key).
49
+ * @param strandId - The strand whose transport key to derive.
50
+ * @returns A stable, per-strand Ed25519 libp2p private key, distinct from the
51
+ * identity key and from every other strand's.
52
+ * @throws If the identity key is not Ed25519.
53
+ */
54
+ export async function strandTransportKey(identityKey: PrivateKey, strandId: string): Promise<PrivateKey> {
55
+ const { privateKeyB64 } = ed25519KeyPairFromLibp2p(identityKey);
56
+ const seed = digest([STRAND_TRANSPORT_KEY_DOMAIN, privateKeyB64, strandId], 'sha256', 'bytes') as Uint8Array;
57
+ return generateKeyPairFromSeed('Ed25519', seed);
58
+ }
@@ -14,13 +14,15 @@
14
14
  * stream (like seed delivery), so each side reads to EOF (under a read timeout)
15
15
  * and decodes one frame via the shared {@link decodeLengthPrefixedFrame} guard.
16
16
  *
17
- * **Authorization (v1):** the control network already restricts membership —
18
- * only this party's cadre peers connect (schema-gated `CadrePeer`). A wake is
19
- * low-risk: it only causes the receiver to spend resources coming online for a
20
- * strand it already participates in. So the receiver verifies the remote peer is
21
- * a `CadrePeer` member (via the injected `isMember` gate) and requires no
22
- * signature beyond control-network membership. A richer per-request signature is
23
- * deliberately out of scope for v1.
17
+ * **Authorization (v1):** a wake is low-risk — it only causes the receiver to
18
+ * spend resources coming online for a strand it already participates in — so the
19
+ * receiver carries no per-request signature and instead defers entirely to the
20
+ * injected `isMember` predicate. `CadreNode` injects its AUTHORIZED-membership
21
+ * predicate there (`isAuthorizedMember`: the sender's `CadrePeer` row must carry
22
+ * a voucher that verifies against an owner key in the receiver's node-local
23
+ * trusted-owner anchor), so a peer that merely published rows into the replicated
24
+ * control DB is refused. This module stays agnostic: it enforces whatever
25
+ * predicate it is given.
24
26
  */
25
27
 
26
28
  import debug from 'debug';
@@ -28,7 +30,7 @@ import type { Libp2p, Connection } from '@libp2p/interface';
28
30
  import type { Multiaddr } from '@multiformats/multiaddr';
29
31
  import type { StrandInstance, WakeRequest, WakeAck } from './types.js';
30
32
  import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
31
- import { type ControlStream, writeFrame, withTimeout, readStreamToEnd } from './control-stream.js';
33
+ import { type ControlStream, writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
32
34
 
33
35
  const log = debug('sereus:cadre:strand-wake');
34
36
 
@@ -205,8 +207,8 @@ export class StrandWakeService {
205
207
  * - Already-live strand → no-op, `accepted` with current status.
206
208
  */
207
209
  async processWakeRequest(request: WakeRequest, remotePeerId: string): Promise<WakeAck> {
208
- // Control-network membership is the v1 authorization: only this party's
209
- // cadre peers may ask us to wake.
210
+ // The injected membership predicate is the whole v1 authorization (CadreNode
211
+ // supplies the voucher-anchored one); only a peer it admits may ask us to wake.
210
212
  if (!(await this.options.isMember(remotePeerId))) {
211
213
  log('Rejecting wake from non-member %s', remotePeerId);
212
214
  return { accepted: false, reason: 'Sender is not a cadre member' };
@@ -260,16 +262,13 @@ export async function dialWake(
260
262
 
261
263
  let lastError: Error | null = null;
262
264
  for (const addr of addrs) {
263
- // One controller per attempt: on timeout, `withTimeout`'s `onTimeout` aborts
264
- // it, which aborts the in-flight dialProtocol (via the dial `signal`) and the
265
- // live stream — so neither the connect nor the unbounded ack-read leaks.
266
- const controller = new AbortController();
267
265
  try {
268
- return await withTimeout(
266
+ // One deadline per attempt: its signal aborts the in-flight dialProtocol and
267
+ // resets the live stream, so neither the connect nor the ack-read leaks.
268
+ return await withDeadline(
269
269
  timeoutMs,
270
270
  `Wake dial ${addr.toString()}`,
271
- () => sendWake(node, addr, protocolId, request, timeoutMs, controller.signal),
272
- () => controller.abort(),
271
+ (signal) => sendWake(node, addr, protocolId, request, timeoutMs, signal),
273
272
  );
274
273
  } catch (err) {
275
274
  lastError = err instanceof Error ? err : new Error(String(err));
@@ -282,12 +281,10 @@ export async function dialWake(
282
281
  /**
283
282
  * Open one stream, send the request, half-close, and read the ack.
284
283
  *
285
- * `signal` is the per-attempt abort signal from {@link dialWake}: it is passed to
286
- * `dialProtocol` so a timeout during connect aborts the dial, and once the stream
287
- * is open an abort listener resets the live stream — releasing the otherwise
288
- * unbounded ack-read. `readFrame` still applies `timeoutMs` as a backstop for the
289
- * case where an abort does not propagate (e.g. a stream double whose `abort()` is
290
- * a no-op).
284
+ * `signal` is the per-attempt deadline from {@link dialWake}: it goes to
285
+ * `dialProtocol` so a timeout during connect aborts the dial, and into
286
+ * {@link exchangeFrame} so a timeout after the stream is open resets it —
287
+ * releasing the otherwise unbounded ack-read.
291
288
  */
292
289
  async function sendWake(
293
290
  node: Libp2p,
@@ -303,29 +300,15 @@ async function sendWake(
303
300
  // relay's data/duration cap, so opening it on the limited connection is safe
304
301
  // and is the whole point of dialing the relay address.
305
302
  const rawStream = await node.dialProtocol(addr, protocolId, { runOnLimitedConnection: true, signal });
306
- const stream = rawStream as unknown as ControlStream;
307
303
 
308
- const abortErr = new Error('Wake dial aborted by timeout');
309
- const onAbort = (): void => stream.abort(abortErr);
310
- // If the timeout already fired during connect, release the freshly-opened stream now.
311
- if (signal.aborted) {
312
- onAbort();
313
- throw abortErr;
314
- }
315
- signal.addEventListener('abort', onAbort, { once: true });
304
+ const ack = await exchangeFrame(
305
+ rawStream as unknown as ControlStream,
306
+ signal,
307
+ request,
308
+ (stream) => readFrame<WakeAck>(stream, timeoutMs),
309
+ 'Wake dial aborted by timeout',
310
+ );
316
311
 
317
- try {
318
- writeFrame(stream, request);
319
- // close() half-closes the write end (EOF) while the read end stays open for
320
- // the ack — the same libp2p 3.x pattern as seed delivery.
321
- await stream.close();
322
- const ack = await readFrame<WakeAck>(stream, timeoutMs);
323
- log('Wake ack from %s: accepted=%s status=%s', addr.toString(), ack.accepted, ack.status ?? '-');
324
- return ack;
325
- } catch (err) {
326
- stream.abort(err instanceof Error ? err : new Error(String(err)));
327
- throw err;
328
- } finally {
329
- signal.removeEventListener('abort', onAbort);
330
- }
312
+ log('Wake ack from %s: accepted=%s status=%s', addr.toString(), ack.accepted, ack.status ?? '-');
313
+ return ack;
331
314
  }
@@ -11,6 +11,22 @@ const log = debug('sereus:cadre:strand-watcher');
11
11
  */
12
12
  type FilterDecision = 'pass' | 'reject' | 'defer';
13
13
 
14
+ /**
15
+ * Ceiling on the retry delay for a strand whose launch keeps failing. Attempts
16
+ * are never abandoned — the failure this backoff exists for (a transient network
17
+ * or storage fault) can last a long time — but a permanently-unlaunchable strand
18
+ * must not re-attempt on every poll and storm the host app with `strand:error`.
19
+ */
20
+ const MAX_RETRY_BACKOFF_MS = 5 * 60 * 1000;
21
+
22
+ /** Per-strand consecutive-failure state driving the launch retry backoff. */
23
+ interface StrandFailureState {
24
+ /** Consecutive `onStrandAdded` failures since the last success. */
25
+ failures: number;
26
+ /** Earliest wall-clock time (ms) at which the next attempt may run. */
27
+ nextAttemptAt: number;
28
+ }
29
+
14
30
  /**
15
31
  * Extended strand row that includes the sAppId for filtering purposes.
16
32
  * The sAppId is provided by the hosting application, not from the control network.
@@ -55,10 +71,20 @@ export class StrandWatcher {
55
71
  private readonly callbacks: StrandWatcherCallbacks;
56
72
  private readonly queryable: StrandQueryable;
57
73
  private readonly sAppIdLookup?: SAppIdLookup;
74
+ /**
75
+ * Wall-clock source for the retry backoff. Read fresh at each use rather than
76
+ * snapshotted per poll: a poll awaits `onStrandAdded`, and a launch that fails
77
+ * slowly (e.g. a dial timeout) would otherwise be scheduled off the clock as it
78
+ * read *before* the attempt, putting `nextAttemptAt` in the past. Injectable so
79
+ * tests can advance time deterministically without fake timers.
80
+ */
81
+ private readonly now: () => number;
58
82
 
59
83
  private knownStrands: Map<string, StrandRow> = new Map();
60
84
  /** Ids admitted under a `defer` decision; re-evaluated each poll until they resolve. */
61
85
  private provisional: Set<string> = new Set();
86
+ /** Ids whose last launch attempt threw, with the backoff gating their retry. */
87
+ private failureStates: Map<string, StrandFailureState> = new Map();
62
88
  private pollTimer: ReturnType<typeof setInterval> | null = null;
63
89
  private initialPollTimer: ReturnType<typeof setTimeout> | null = null;
64
90
  private running = false;
@@ -68,13 +94,15 @@ export class StrandWatcher {
68
94
  callbacks: StrandWatcherCallbacks,
69
95
  filter: StrandFilter = { mode: 'all' },
70
96
  pollInterval: number = 5000,
71
- sAppIdLookup?: SAppIdLookup
97
+ sAppIdLookup?: SAppIdLookup,
98
+ now: () => number = Date.now
72
99
  ) {
73
100
  this.queryable = queryable;
74
101
  this.callbacks = callbacks;
75
102
  this.filter = filter;
76
103
  this.pollInterval = pollInterval;
77
104
  this.sAppIdLookup = sAppIdLookup;
105
+ this.now = now;
78
106
  log('StrandWatcher created with filter: %o, interval: %dms', filter, pollInterval);
79
107
  }
80
108
 
@@ -113,7 +141,18 @@ export class StrandWatcher {
113
141
  }
114
142
 
115
143
  /**
116
- * Poll for strand changes
144
+ * Record a failed launch attempt and schedule when the strand may be retried:
145
+ * `pollInterval * 2^(failures-1)`, capped at {@link MAX_RETRY_BACKOFF_MS}.
146
+ */
147
+ private recordFailure(strandId: string): void {
148
+ const failures = (this.failureStates.get(strandId)?.failures ?? 0) + 1;
149
+ const delay = Math.min(this.pollInterval * 2 ** (failures - 1), MAX_RETRY_BACKOFF_MS);
150
+ this.failureStates.set(strandId, { failures, nextAttemptAt: this.now() + delay });
151
+ log('Strand %s launch failed (%d consecutive) - next attempt in %dms', strandId, failures, delay);
152
+ }
153
+
154
+ /**
155
+ * Poll for strand changes.
117
156
  */
118
157
  private async poll(): Promise<void> {
119
158
  if (!this.running) return;
@@ -125,9 +164,15 @@ export class StrandWatcher {
125
164
  // Find added strands
126
165
  for (const strand of currentStrands) {
127
166
  if (this.knownStrands.has(strand.Id)) continue;
167
+ const failure = this.failureStates.get(strand.Id);
168
+ // Clock read per candidate, not once per poll: an earlier strand's launch
169
+ // may have taken a long time, and this one's gate must reflect that.
170
+ if (failure && this.now() < failure.nextAttemptAt) continue; // backing off after a failed launch
128
171
  const decision = this.evaluateFilter(strand);
129
172
  if (decision === 'reject') continue;
130
173
  log('Strand added: %s', strand.Id);
174
+ // Set BEFORE the await: this doubles as the in-flight guard that stops the
175
+ // interval timer from starting a second concurrent launch for the same strand.
131
176
  this.knownStrands.set(strand.Id, strand);
132
177
  if (decision === 'defer') {
133
178
  // Provisional admission: sAppId unknown, re-check on later polls.
@@ -135,8 +180,15 @@ export class StrandWatcher {
135
180
  }
136
181
  try {
137
182
  await this.callbacks.onStrandAdded(strand);
183
+ this.failureStates.delete(strand.Id);
138
184
  } catch (error) {
139
185
  log('Error handling strand add for %s: %o', strand.Id, error);
186
+ // A failed launch leaves nothing running (StrandInstanceManager drops the
187
+ // record), so forget the strand and let a later poll retry it — gated by
188
+ // the backoff recorded here.
189
+ this.knownStrands.delete(strand.Id);
190
+ this.provisional.delete(strand.Id);
191
+ this.recordFailure(strand.Id);
140
192
  }
141
193
  }
142
194
 
@@ -163,7 +215,9 @@ export class StrandWatcher {
163
215
  // decision === 'defer': still unknown, leave provisional for next poll.
164
216
  }
165
217
 
166
- // Find removed strands
218
+ // Find removed strands. A strand whose launch failed is no longer in
219
+ // knownStrands, so it correctly never fires onStrandRemoved — nothing was
220
+ // ever started for it.
167
221
  for (const [strandId] of this.knownStrands) {
168
222
  if (!currentMap.has(strandId)) {
169
223
  log('Strand removed: %s', strandId);
@@ -176,6 +230,14 @@ export class StrandWatcher {
176
230
  }
177
231
  }
178
232
  }
233
+
234
+ // Drop backoff state for strands whose control-network row is gone; a row
235
+ // that reappears is a fresh strand and gets a fresh first attempt.
236
+ for (const strandId of [...this.failureStates.keys()]) {
237
+ if (!currentMap.has(strandId)) {
238
+ this.failureStates.delete(strandId);
239
+ }
240
+ }
179
241
  } catch (error) {
180
242
  log('Error polling strands: %o', error);
181
243
  }
@@ -231,6 +293,7 @@ export class StrandWatcher {
231
293
 
232
294
  this.knownStrands.clear();
233
295
  this.provisional.clear();
296
+ this.failureStates.clear();
234
297
  log('StrandWatcher stopped');
235
298
  }
236
299