@serfab/cadre-core 0.9.0 → 0.11.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 (193) hide show
  1. package/README.md +255 -253
  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 +1121 -81
  9. package/dist/cadre-node.js +2276 -262
  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-cohort.d.ts +26 -0
  15. package/dist/control-cohort.js +26 -0
  16. package/dist/control-cohort.js.map +1 -1
  17. package/dist/control-database.d.ts +959 -54
  18. package/dist/control-database.js +1421 -148
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +90 -10
  21. package/dist/control-formation-recorder.js +130 -14
  22. package/dist/control-formation-recorder.js.map +1 -1
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +651 -72
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-stream.d.ts +38 -4
  27. package/dist/control-stream.js +70 -4
  28. package/dist/control-stream.js.map +1 -1
  29. package/dist/control-write-retry.d.ts +161 -0
  30. package/dist/control-write-retry.js +412 -0
  31. package/dist/control-write-retry.js.map +1 -0
  32. package/dist/delegate-admission.d.ts +134 -0
  33. package/dist/delegate-admission.js +244 -0
  34. package/dist/delegate-admission.js.map +1 -0
  35. package/dist/device-token.d.ts +11 -11
  36. package/dist/device-token.js +14 -13
  37. package/dist/device-token.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +26 -0
  39. package/dist/ed25519-key.js +56 -1
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/file-durable-slot.d.ts +20 -0
  42. package/dist/file-durable-slot.js +53 -0
  43. package/dist/file-durable-slot.js.map +1 -0
  44. package/dist/formation-approval.d.ts +157 -0
  45. package/dist/formation-approval.js +375 -0
  46. package/dist/formation-approval.js.map +1 -0
  47. package/dist/fs-atomic.d.ts +38 -0
  48. package/dist/fs-atomic.js +97 -0
  49. package/dist/fs-atomic.js.map +1 -0
  50. package/dist/identity-key.d.ts +49 -0
  51. package/dist/identity-key.js +81 -0
  52. package/dist/identity-key.js.map +1 -0
  53. package/dist/index.d.ts +17 -7
  54. package/dist/index.js +40 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/key-store-file.d.ts +2 -24
  57. package/dist/key-store-file.js +10 -77
  58. package/dist/key-store-file.js.map +1 -1
  59. package/dist/membership-connection-gater.d.ts +146 -0
  60. package/dist/membership-connection-gater.js +180 -0
  61. package/dist/membership-connection-gater.js.map +1 -0
  62. package/dist/node-local-snapshot.d.ts +109 -0
  63. package/dist/node-local-snapshot.js +182 -0
  64. package/dist/node-local-snapshot.js.map +1 -0
  65. package/dist/peer-addr-book.d.ts +92 -0
  66. package/dist/peer-addr-book.js +220 -0
  67. package/dist/peer-addr-book.js.map +1 -0
  68. package/dist/peer-authorization.d.ts +135 -18
  69. package/dist/peer-authorization.js +152 -21
  70. package/dist/peer-authorization.js.map +1 -1
  71. package/dist/peer-record.d.ts +21 -8
  72. package/dist/peer-record.js +26 -10
  73. package/dist/peer-record.js.map +1 -1
  74. package/dist/relay-addrs.d.ts +45 -0
  75. package/dist/relay-addrs.js +103 -0
  76. package/dist/relay-addrs.js.map +1 -0
  77. package/dist/relay-reservation.d.ts +266 -0
  78. package/dist/relay-reservation.js +613 -0
  79. package/dist/relay-reservation.js.map +1 -0
  80. package/dist/seed-bootstrap.d.ts +228 -56
  81. package/dist/seed-bootstrap.js +340 -198
  82. package/dist/seed-bootstrap.js.map +1 -1
  83. package/dist/seed-trust-policy.d.ts +47 -21
  84. package/dist/seed-trust-policy.js +24 -15
  85. package/dist/seed-trust-policy.js.map +1 -1
  86. package/dist/strand-addr-protocol.d.ts +42 -13
  87. package/dist/strand-addr-protocol.js +58 -52
  88. package/dist/strand-addr-protocol.js.map +1 -1
  89. package/dist/strand-backfill.d.ts +109 -0
  90. package/dist/strand-backfill.js +279 -0
  91. package/dist/strand-backfill.js.map +1 -0
  92. package/dist/strand-cohort.d.ts +4 -37
  93. package/dist/strand-cohort.js +4 -15
  94. package/dist/strand-cohort.js.map +1 -1
  95. package/dist/strand-database.d.ts +11 -16
  96. package/dist/strand-database.js +19 -7
  97. package/dist/strand-database.js.map +1 -1
  98. package/dist/strand-formation-manager.d.ts +36 -7
  99. package/dist/strand-formation-manager.js +115 -15
  100. package/dist/strand-formation-manager.js.map +1 -1
  101. package/dist/strand-formation-protocol.d.ts +103 -4
  102. package/dist/strand-formation-protocol.js +232 -4
  103. package/dist/strand-formation-protocol.js.map +1 -1
  104. package/dist/strand-instance-manager.d.ts +88 -19
  105. package/dist/strand-instance-manager.js +178 -34
  106. package/dist/strand-instance-manager.js.map +1 -1
  107. package/dist/strand-member-registry.d.ts +5 -5
  108. package/dist/strand-member-registry.js +18 -10
  109. package/dist/strand-member-registry.js.map +1 -1
  110. package/dist/strand-membership-writer.d.ts +484 -88
  111. package/dist/strand-membership-writer.js +751 -128
  112. package/dist/strand-membership-writer.js.map +1 -1
  113. package/dist/strand-solicitation.d.ts +99 -33
  114. package/dist/strand-solicitation.js +100 -52
  115. package/dist/strand-solicitation.js.map +1 -1
  116. package/dist/strand-transport-key.d.ts +40 -0
  117. package/dist/strand-transport-key.js +56 -0
  118. package/dist/strand-transport-key.js.map +1 -0
  119. package/dist/strand-wake-protocol.d.ts +9 -7
  120. package/dist/strand-wake-protocol.js +22 -46
  121. package/dist/strand-wake-protocol.js.map +1 -1
  122. package/dist/strand-watcher.d.ts +17 -2
  123. package/dist/strand-watcher.js +47 -3
  124. package/dist/strand-watcher.js.map +1 -1
  125. package/dist/trusted-owner-store-file.d.ts +12 -0
  126. package/dist/trusted-owner-store-file.js +39 -0
  127. package/dist/trusted-owner-store-file.js.map +1 -0
  128. package/dist/trusted-owner-store.d.ts +64 -0
  129. package/dist/trusted-owner-store.js +126 -0
  130. package/dist/trusted-owner-store.js.map +1 -0
  131. package/dist/types.d.ts +307 -44
  132. package/dist/types.js +13 -0
  133. package/dist/types.js.map +1 -1
  134. package/package.json +20 -8
  135. package/src/arachnode-stub.ts +159 -159
  136. package/src/bootstrap-peer-store-file.ts +44 -0
  137. package/src/bootstrap-peer-store.ts +172 -0
  138. package/src/cadre-node.ts +5158 -2981
  139. package/src/canonical-json.ts +30 -30
  140. package/src/control-authorization.ts +114 -0
  141. package/src/control-cohort.ts +130 -103
  142. package/src/control-database.ts +2406 -948
  143. package/src/control-formation-recorder.ts +305 -151
  144. package/src/control-schema.ts +651 -72
  145. package/src/control-stream.ts +205 -128
  146. package/src/control-write-retry.ts +483 -0
  147. package/src/delegate-admission.ts +302 -0
  148. package/src/device-token.ts +94 -89
  149. package/src/diagnostics/connection-path.ts +277 -277
  150. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  151. package/src/ed25519-key.ts +66 -1
  152. package/src/enrollment.ts +199 -199
  153. package/src/file-durable-slot.ts +61 -0
  154. package/src/formation-approval.ts +604 -0
  155. package/src/fs-atomic.ts +107 -0
  156. package/src/hibernation-manager.ts +437 -437
  157. package/src/identity-key.ts +109 -0
  158. package/src/index.ts +137 -5
  159. package/src/key-store-file.ts +132 -203
  160. package/src/key-store.ts +99 -99
  161. package/src/membership-connection-gater.ts +204 -0
  162. package/src/node-local-snapshot.ts +260 -0
  163. package/src/peer-addr-book.ts +250 -0
  164. package/src/peer-authorization.ts +259 -81
  165. package/src/peer-record.ts +164 -146
  166. package/src/push-credentials.ts +84 -84
  167. package/src/push-node.ts +64 -64
  168. package/src/push-notifier-apns.ts +247 -247
  169. package/src/push-notifier-fcm.ts +220 -220
  170. package/src/push-notifier-shared.ts +28 -28
  171. package/src/push-notifier.ts +52 -52
  172. package/src/relay-addrs.ts +111 -0
  173. package/src/relay-reservation.ts +823 -0
  174. package/src/seed-bootstrap.ts +409 -209
  175. package/src/seed-trust-policy.ts +140 -107
  176. package/src/strand-addr-protocol.ts +441 -410
  177. package/src/strand-backfill.ts +366 -0
  178. package/src/strand-cohort.ts +38 -77
  179. package/src/strand-database.ts +202 -204
  180. package/src/strand-formation-manager.ts +491 -368
  181. package/src/strand-formation-protocol.ts +692 -391
  182. package/src/strand-instance-manager.ts +201 -46
  183. package/src/strand-member-key.ts +39 -39
  184. package/src/strand-member-registry.ts +182 -177
  185. package/src/strand-membership-writer.ts +938 -146
  186. package/src/strand-solicitation.ts +472 -407
  187. package/src/strand-transport-key.ts +58 -0
  188. package/src/strand-wake-payload.ts +32 -32
  189. package/src/strand-wake-protocol.ts +314 -331
  190. package/src/strand-watcher.ts +314 -251
  191. package/src/trusted-owner-store-file.ts +41 -0
  192. package/src/trusted-owner-store.ts +171 -0
  193. package/src/types.ts +1309 -1032
@@ -1,407 +1,472 @@
1
- import debug from 'debug';
2
- import { toString as uint8ArrayToString } from 'uint8arrays';
3
- import { generateKeyPair, privateKeyToProtobuf } from '@libp2p/crypto/keys';
4
- import { peerIdFromPrivateKey } from '@libp2p/peer-id';
5
- import type { Libp2p } from '@libp2p/interface';
6
- import type {
7
- OpenInvitation,
8
- FormStrandResult,
9
- ValidateFormationResult,
10
- StrandFormationDisclosure
11
- } from './types.js';
12
- import {
13
- StrandFormationManager,
14
- type StrandFormationManagerConfig
15
- } from './strand-formation-manager.js';
16
- import {
17
- isValidResponderCreatesResult,
18
- type FormationResultMessage
19
- } from './strand-formation-protocol.js';
20
-
21
- const log = debug('sereus:cadre:solicitation');
22
-
23
- /**
24
- * Interface for validating formation disclosures
25
- */
26
- export interface DisclosureValidator {
27
- /**
28
- * Validate the disclosure provided by an initiator
29
- * @param token The invitation token
30
- * @param disclosure The disclosure object from the initiator
31
- * @returns Whether the disclosure is acceptable
32
- */
33
- validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
34
- }
35
-
36
- /**
37
- * Discriminated result of resolving the host strand an invite binds to (responder side):
38
- *
39
- * - `unbound`: the invite carries no `StrandId` → the responder-provisions path (provision
40
- * a NEW strand; legacy/open).
41
- * - `bound`: the invite names a host strand AND that strand row is present on this responder
42
- * → provision-then-record (record consent against it, return it + its membership key).
43
- * - `missing`: the invite names a host strand but the row is ABSENT on this responder (e.g.
44
- * not yet converged) → the manager rejects cleanly rather than recording usage against a
45
- * non-existent strand, which would fail the deferred `FormationUsage.StrandExists` CHECK at
46
- * commit and drop the result frame.
47
- *
48
- * Replaces the older `{ strandId, memberPrivateKey } | null` shape, which conflated `bound`
49
- * and `missing` (both produced a non-null result whenever the invite had a `StrandId`).
50
- */
51
- export type ResolvedHostStrand =
52
- | { kind: 'unbound' }
53
- | { kind: 'bound'; strandId: string; memberPrivateKey: string | null }
54
- | { kind: 'missing'; strandId: string };
55
-
56
- /**
57
- * Interface for recording formation usage
58
- */
59
- export interface FormationUsageRecorder {
60
- /**
61
- * Record that a formation invite was used
62
- */
63
- recordUsage(token: string, initiatorKey: string, strandId: string): Promise<void>;
64
-
65
- /**
66
- * Check if a token has already been used (for single-use invites)
67
- */
68
- isTokenUsed(token: string): Promise<boolean>;
69
-
70
- /**
71
- * Check if a token is valid and not expired
72
- */
73
- isTokenValid(token: string): Promise<{ valid: boolean; invitation?: OpenInvitation }>;
74
-
75
- /**
76
- * Resolve the host strand an invite binds to, classifying it as unbound / bound /
77
- * missing (see {@link ResolvedHostStrand}). Optional: a recorder that does not bind
78
- * strands omits it, and {@link StrandFormationManager} treats every invite as `unbound`
79
- * and falls back to its provisioner. Keeping resolution behind this interface keeps the
80
- * manager DB-agnostic and unit-testable with an in-memory fake.
81
- */
82
- resolveStrand?(token: string): Promise<ResolvedHostStrand>;
83
-
84
- /**
85
- * Provision a NEW strand for an UNBOUND invite and record consent against it
86
- * ATOMICALLY (single `FormationUsage` row → single-use enforced on the next redemption).
87
- * Optional: a recorder that only supports provision-then-record omits it, and the
88
- * 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
90
- * minted strand id plus its membership key (null for an open responder-provisioned strand).
91
- */
92
- provisionAndRecord?(
93
- token: string,
94
- initiatorKey: string,
95
- sAppId: string
96
- ): Promise<{ strandId: string; memberPrivateKey: string | null }>;
97
- }
98
-
99
- /**
100
- * Interface for strand provisioning during formation
101
- */
102
- export interface StrandProvisioner {
103
- /**
104
- * Provision a new strand after formation is validated
105
- */
106
- provisionStrand(
107
- sAppId: string,
108
- initiatorKey: string,
109
- responderKey: string
110
- ): Promise<{ strandId: string }>;
111
- }
112
-
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
- /**
127
- * Interface for validating the responder's formation result on the initiator side.
128
- *
129
- * Symmetric to {@link DisclosureValidator} (which runs responder→initiator): it lets
130
- * the initiator verify the responder's disclosed identity, cadre, and provisioned
131
- * strand against the invitation/disclosure it used to form the strand. The manager
132
- * owns one dialer session per `formStrand` call, so the per-session invitation +
133
- * disclosure context is local — no hook-signature gymnastics needed.
134
- */
135
- export interface FormationResponseValidator {
136
- /** Validate the responder's result against the invitation/disclosure used to form the strand. */
137
- validateResponse(ctx: {
138
- invitation: OpenInvitation;
139
- disclosure: StrandFormationDisclosure;
140
- response: FormationResultMessage;
141
- }): Promise<boolean>;
142
- }
143
-
144
- /**
145
- * Built-in structural {@link FormationResponseValidator}.
146
- *
147
- * `validateResponse` rejects when the responder did not approve, omitted a disclosed
148
- * identity, returned no/placeholder cadre addresses, or returned a missing/empty or
149
- * non-responder-created strand (see {@link isValidResponderCreatesResult}).
150
- * Apps can supply a stricter validator via {@link StrandSolicitationServiceOptions}.
151
- */
152
- export function createDefaultFormationResponseValidator(): FormationResponseValidator {
153
- return {
154
- async validateResponse({ response }) {
155
- return isValidResponderCreatesResult(response);
156
- }
157
- };
158
- }
159
-
160
- export interface StrandSolicitationServiceOptions {
161
- disclosureValidator?: DisclosureValidator;
162
- formationUsageRecorder?: FormationUsageRecorder;
163
- strandProvisioner?: StrandProvisioner;
164
- formationSigner?: FormationSigner;
165
- /** Validates the responder's result on the initiator side (defaults to a structural check) */
166
- formationResponseValidator?: FormationResponseValidator;
167
- /** Party ID for this node (used in protocol messages) */
168
- partyId?: string;
169
- /** Cadre peer addresses for this node */
170
- cadrePeerAddrs?: string[];
171
- /** Configuration for the formation manager */
172
- formationConfig?: StrandFormationManagerConfig;
173
- }
174
-
175
- /**
176
- * Strand Solicitation API for forming strands via open invitations.
177
- *
178
- * This service handles the high-level API defined in api.md:
179
- * - formStrand(invitation, disclosure, node) - called by initiator
180
- * - validateStrandFormation(token, disclosure) - called by responder
181
- *
182
- * When a libp2p node is provided, the underlying protocol is handled by the
183
- * native cadre-core formation transport via StrandFormationManager.
184
- */
185
- export class StrandSolicitationService {
186
- private readonly disclosureValidator?: DisclosureValidator;
187
- private readonly formationUsageRecorder?: FormationUsageRecorder;
188
- private readonly strandProvisioner?: StrandProvisioner;
189
- private readonly formationSigner?: FormationSigner;
190
- private readonly formationResponseValidator?: FormationResponseValidator;
191
- private readonly partyId: string;
192
- private readonly cadrePeerAddrs: string[];
193
- private formationManager?: StrandFormationManager;
194
- private readonly formationConfig?: StrandFormationManagerConfig;
195
-
196
- constructor(options?: StrandSolicitationServiceOptions) {
197
- this.disclosureValidator = options?.disclosureValidator;
198
- this.formationUsageRecorder = options?.formationUsageRecorder;
199
- this.strandProvisioner = options?.strandProvisioner;
200
- this.formationSigner = options?.formationSigner;
201
- this.formationResponseValidator = options?.formationResponseValidator;
202
- this.partyId = options?.partyId ?? `party-${Date.now()}`;
203
- this.cadrePeerAddrs = options?.cadrePeerAddrs ?? [];
204
- this.formationConfig = options?.formationConfig;
205
- log('StrandSolicitationService created for party: %s', this.partyId);
206
- }
207
-
208
- /**
209
- * Get or create the StrandFormationManager.
210
- * Lazily initialized to allow configuration after construction.
211
- */
212
- private getFormationManager(): StrandFormationManager {
213
- if (!this.formationManager) {
214
- this.formationManager = new StrandFormationManager({
215
- disclosureValidator: this.disclosureValidator,
216
- formationUsageRecorder: this.formationUsageRecorder,
217
- strandProvisioner: this.strandProvisioner,
218
- formationResponseValidator: this.formationResponseValidator,
219
- partyId: this.partyId,
220
- cadrePeerAddrs: this.cadrePeerAddrs,
221
- config: this.formationConfig
222
- });
223
- }
224
- return this.formationManager;
225
- }
226
-
227
- /**
228
- * Register as a responder on a libp2p node.
229
- * This enables the node to handle incoming strand formation requests.
230
- */
231
- registerResponder(node: Libp2p): void {
232
- this.getFormationManager().registerResponder(node);
233
- log('Registered as responder');
234
- }
235
-
236
- /**
237
- * Unregister as a responder from a libp2p node.
238
- */
239
- unregisterResponder(node: Libp2p): void {
240
- this.getFormationManager().unregisterResponder(node);
241
- log('Unregistered as responder');
242
- }
243
-
244
- /**
245
- * Form a strand with a responder via an open invitation.
246
- *
247
- * Called by the initiator (the party who received an out-of-band invitation).
248
- * This generates a member key, contacts the responder's cadre, and negotiates
249
- * strand formation.
250
- *
251
- * @param invitation The open invitation (or just token for legacy API)
252
- * @param disclosure Identity/context information to share with the responder
253
- * @param node Optional libp2p node for real protocol handling
254
- * @returns The member key and strand info if successful
255
- */
256
- async formStrand(
257
- invitation: OpenInvitation | string,
258
- disclosure: StrandFormationDisclosure,
259
- node?: Libp2p
260
- ): Promise<FormStrandResult> {
261
- // Handle legacy API where just token was passed
262
- const token = typeof invitation === 'string' ? invitation : invitation.token;
263
- log('Forming strand with token: %s', token);
264
-
265
- // Generate a new keypair for this strand membership
266
- const privateKey = await generateKeyPair('Ed25519');
267
- const peerId = peerIdFromPrivateKey(privateKey);
268
- const privateKeyBytes = privateKeyToProtobuf(privateKey);
269
-
270
- const memberKey = peerId.toString();
271
- const invitePrivateKey = uint8ArrayToString(privateKeyBytes, 'base64');
272
-
273
- log('Generated member key: %s', memberKey);
274
-
275
- // If we have a full invitation and a node, use the real protocol
276
- if (typeof invitation !== 'string' && node) {
277
- log('Using native cadre-core formation transport');
278
- const result = await this.getFormationManager().formStrand(
279
- invitation,
280
- { ...disclosure, partyId: memberKey },
281
- node
282
- );
283
- return {
284
- memberKey,
285
- invitePrivateKey,
286
- strandId: result.strandId,
287
- // Carry the host strand's membership key delivered over the protocol (closed-strand
288
- // provision-then-record). invitePrivateKey stays the initiator's generated signing key.
289
- memberPrivateKey: result.memberPrivateKey
290
- };
291
- }
292
-
293
- // Fallback: placeholder strandId (for testing without network)
294
- log('No node provided, using placeholder strandId');
295
- const strandId = `strand-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
296
-
297
- return {
298
- memberKey,
299
- invitePrivateKey,
300
- strandId
301
- };
302
- }
303
-
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
- /**
366
- * Create an open invitation for others to form strands with this party.
367
- *
368
- * @param sAppId The sApp to use for formed strands
369
- * @param expirationMs How long the invitation is valid (ms from now)
370
- * @param bootstrap Bootstrap addresses for contacting this party's cadre
371
- * @returns The open invitation to share out-of-band
372
- */
373
- async createOpenInvitation(
374
- sAppId: string,
375
- expirationMs: number,
376
- bootstrap: string[]
377
- ): Promise<OpenInvitation> {
378
- // Generate a unique token
379
- const token = `invite-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
380
- const expiration = new Date(Date.now() + expirationMs);
381
-
382
- log('Created open invitation: %s (expires %s)', token, expiration.toISOString());
383
-
384
- return {
385
- token,
386
- sAppId,
387
- expiration,
388
- bootstrap
389
- };
390
- }
391
-
392
- /**
393
- * Record that a formation was completed successfully.
394
- * Called after strand provisioning to track usage.
395
- */
396
- async recordFormationComplete(
397
- token: string,
398
- initiatorKey: string,
399
- strandId: string
400
- ): Promise<void> {
401
- if (this.formationUsageRecorder) {
402
- await this.formationUsageRecorder.recordUsage(token, initiatorKey, strandId);
403
- log('Recorded formation usage: token=%s strand=%s', token, strandId);
404
- }
405
- }
406
- }
407
-
1
+ import debug from 'debug';
2
+ import { toString as uint8ArrayToString } from 'uint8arrays';
3
+ import { generateKeyPair, privateKeyToProtobuf } from '@libp2p/crypto/keys';
4
+ import { peerIdFromPrivateKey } from '@libp2p/peer-id';
5
+ import type { Libp2p } from '@libp2p/interface';
6
+ import { sign } from '@optimystic/quereus-plugin-crypto';
7
+ import type {
8
+ OpenInvitation,
9
+ FormStrandResult,
10
+ StrandFormationDisclosure
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';
16
+ import {
17
+ StrandFormationManager,
18
+ type StrandFormationManagerConfig
19
+ } from './strand-formation-manager.js';
20
+ import {
21
+ isValidResponderCreatesResult,
22
+ type FormationResultMessage
23
+ } from './strand-formation-protocol.js';
24
+
25
+ const log = debug('sereus:cadre:solicitation');
26
+
27
+ /**
28
+ * Interface for validating formation disclosures
29
+ */
30
+ export interface DisclosureValidator {
31
+ /**
32
+ * Validate the disclosure provided by an initiator
33
+ * @param token The invitation token
34
+ * @param disclosure The disclosure object from the initiator
35
+ * @returns Whether the disclosure is acceptable
36
+ */
37
+ validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
38
+ }
39
+
40
+ /**
41
+ * Discriminated result of resolving the host strand an invite binds to (responder side):
42
+ *
43
+ * - `unbound`: the invite carries no `StrandId` → the responder-provisions path (provision
44
+ * a NEW strand; legacy/open).
45
+ * - `bound`: the invite names a host strand AND that strand row is present on this responder
46
+ * → provision-then-record (record consent against it, return it + its membership key).
47
+ * - `missing`: the invite names a host strand but the row is ABSENT on this responder (e.g.
48
+ * not yet converged) → the manager rejects cleanly rather than recording usage against a
49
+ * non-existent strand, which would fail the deferred `FormationUsage.StrandExists` CHECK at
50
+ * commit and drop the result frame.
51
+ *
52
+ * Replaces the older `{ strandId, memberPrivateKey } | null` shape, which conflated `bound`
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.
58
+ */
59
+ export type ResolvedHostStrand =
60
+ | { kind: 'unbound' }
61
+ | { kind: 'bound'; strandId: string; memberPrivateKey: string | null }
62
+ | { kind: 'missing'; strandId: string };
63
+
64
+ /**
65
+ * Interface for recording formation usage
66
+ */
67
+ export interface FormationUsageRecorder {
68
+ /**
69
+ * Record that a formation invite was used
70
+ */
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>;
85
+
86
+ /**
87
+ * Check if a token has already been used (for single-use invites)
88
+ */
89
+ isTokenUsed(token: string): Promise<boolean>;
90
+
91
+ /**
92
+ * Check if a token is valid and not expired
93
+ */
94
+ isTokenValid(token: string): Promise<{ valid: boolean; invitation?: OpenInvitation }>;
95
+
96
+ /**
97
+ * Resolve the host strand an invite binds to, classifying it as unbound / bound /
98
+ * missing (see {@link ResolvedHostStrand}). Optional: a recorder that does not bind
99
+ * strands omits it, and {@link StrandFormationManager} treats every invite as `unbound`
100
+ * and falls back to its provisioner. Keeping resolution behind this interface keeps the
101
+ * manager DB-agnostic and unit-testable with an in-memory fake.
102
+ */
103
+ resolveStrand?(token: string): Promise<ResolvedHostStrand>;
104
+
105
+ /**
106
+ * Provision a NEW strand for an UNBOUND invite and record consent against it
107
+ * ATOMICALLY (single `FormationUsage` row → single-use enforced on the next redemption).
108
+ * Optional: a recorder that only supports provision-then-record omits it, and the
109
+ * manager falls back to its {@link StrandProvisioner}. Mirrors the create-strand-by-consent
110
+ * path the schema's `Strand.AuthorizedInsert` `FormationUsage` branch authorizes. Returns the
111
+ * minted strand id plus its membership key (null for an open responder-provisioned strand).
112
+ */
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>;
136
+ }
137
+
138
+ /**
139
+ * Interface for strand provisioning during formation
140
+ */
141
+ export interface StrandProvisioner {
142
+ /**
143
+ * Provision a new strand after formation is validated
144
+ */
145
+ provisionStrand(
146
+ sAppId: string,
147
+ initiatorKey: string,
148
+ responderKey: string
149
+ ): Promise<{ strandId: string }>;
150
+ }
151
+
152
+ /**
153
+ * Interface for validating the responder's formation result on the initiator side.
154
+ *
155
+ * Symmetric to {@link DisclosureValidator} (which runs responder→initiator): it lets
156
+ * the initiator verify the responder's disclosed identity, cadre, and provisioned
157
+ * strand against the invitation/disclosure it used to form the strand. The manager
158
+ * owns one dialer session per `formStrand` call, so the per-session invitation +
159
+ * disclosure context is local — no hook-signature gymnastics needed.
160
+ */
161
+ export interface FormationResponseValidator {
162
+ /** Validate the responder's result against the invitation/disclosure used to form the strand. */
163
+ validateResponse(ctx: {
164
+ invitation: OpenInvitation;
165
+ disclosure: StrandFormationDisclosure;
166
+ response: FormationResultMessage;
167
+ }): Promise<boolean>;
168
+ }
169
+
170
+ /**
171
+ * Built-in structural {@link FormationResponseValidator}.
172
+ *
173
+ * `validateResponse` rejects when the responder did not approve, omitted a disclosed
174
+ * identity, returned no/placeholder cadre addresses, or returned a missing/empty or
175
+ * non-responder-created strand (see {@link isValidResponderCreatesResult}).
176
+ * Apps can supply a stricter validator via {@link StrandSolicitationServiceOptions}.
177
+ */
178
+ export function createDefaultFormationResponseValidator(): FormationResponseValidator {
179
+ return {
180
+ async validateResponse({ response }) {
181
+ return isValidResponderCreatesResult(response);
182
+ }
183
+ };
184
+ }
185
+
186
+ export interface StrandSolicitationServiceOptions {
187
+ disclosureValidator?: DisclosureValidator;
188
+ formationUsageRecorder?: FormationUsageRecorder;
189
+ strandProvisioner?: StrandProvisioner;
190
+ /** Validates the responder's result on the initiator side (defaults to a structural check) */
191
+ formationResponseValidator?: FormationResponseValidator;
192
+ /** Party ID for this node (used in protocol messages) */
193
+ partyId?: string;
194
+ /** Cadre peer addresses for this node */
195
+ cadrePeerAddrs?: string[];
196
+ /** Configuration for the formation manager */
197
+ formationConfig?: StrandFormationManagerConfig;
198
+ }
199
+
200
+ /**
201
+ * Strand Solicitation API for forming strands via open invitations.
202
+ *
203
+ * This service handles the high-level API defined in api.md:
204
+ * - formStrand(invitation, disclosure, node) - called by initiator
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`.
209
+ *
210
+ * When a libp2p node is provided, the underlying protocol is handled by the
211
+ * native cadre-core formation transport via StrandFormationManager.
212
+ */
213
+ export class StrandSolicitationService {
214
+ private readonly disclosureValidator?: DisclosureValidator;
215
+ private readonly formationUsageRecorder?: FormationUsageRecorder;
216
+ private readonly strandProvisioner?: StrandProvisioner;
217
+ private readonly formationResponseValidator?: FormationResponseValidator;
218
+ private readonly partyId: string;
219
+ private readonly cadrePeerAddrs: string[];
220
+ private formationManager?: StrandFormationManager;
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>();
234
+
235
+ constructor(options?: StrandSolicitationServiceOptions) {
236
+ this.disclosureValidator = options?.disclosureValidator;
237
+ this.formationUsageRecorder = options?.formationUsageRecorder;
238
+ this.strandProvisioner = options?.strandProvisioner;
239
+ this.formationResponseValidator = options?.formationResponseValidator;
240
+ this.partyId = options?.partyId ?? `party-${Date.now()}`;
241
+ this.cadrePeerAddrs = options?.cadrePeerAddrs ?? [];
242
+ this.formationConfig = options?.formationConfig;
243
+ log('StrandSolicitationService created for party: %s', this.partyId);
244
+ }
245
+
246
+ /**
247
+ * Get or create the StrandFormationManager.
248
+ * Lazily initialized to allow configuration after construction.
249
+ */
250
+ private getFormationManager(): StrandFormationManager {
251
+ if (!this.formationManager) {
252
+ this.formationManager = new StrandFormationManager({
253
+ disclosureValidator: this.disclosureValidator,
254
+ formationUsageRecorder: this.formationUsageRecorder,
255
+ strandProvisioner: this.strandProvisioner,
256
+ formationResponseValidator: this.formationResponseValidator,
257
+ partyId: this.partyId,
258
+ cadrePeerAddrs: this.cadrePeerAddrs,
259
+ config: this.formationConfig
260
+ });
261
+ }
262
+ return this.formationManager;
263
+ }
264
+
265
+ /**
266
+ * Register as a responder on a libp2p node.
267
+ * This enables the node to handle incoming strand formation requests.
268
+ */
269
+ registerResponder(node: Libp2p): void {
270
+ this.getFormationManager().registerResponder(node);
271
+ log('Registered as responder');
272
+ }
273
+
274
+ /**
275
+ * Unregister as a responder from a libp2p node.
276
+ */
277
+ unregisterResponder(node: Libp2p): void {
278
+ this.getFormationManager().unregisterResponder(node);
279
+ log('Unregistered as responder');
280
+ }
281
+
282
+ /**
283
+ * Form a strand with a responder via an open invitation.
284
+ *
285
+ * Called by the initiator (the party who received an out-of-band invitation).
286
+ * This generates a member key, contacts the responder's cadre, and negotiates
287
+ * strand formation.
288
+ *
289
+ * @param invitation The open invitation (or just token for legacy API)
290
+ * @param disclosure Identity/context information to share with the responder
291
+ * @param node Optional libp2p node for real protocol handling
292
+ * @returns The member key and strand info if successful
293
+ */
294
+ async formStrand(
295
+ invitation: OpenInvitation | string,
296
+ disclosure: StrandFormationDisclosure,
297
+ node?: Libp2p
298
+ ): Promise<FormStrandResult> {
299
+ // Handle legacy API where just token was passed
300
+ const token = typeof invitation === 'string' ? invitation : invitation.token;
301
+ log('Forming strand with token: %s', token);
302
+
303
+ // Generate a new keypair for this strand membership
304
+ const privateKey = await generateKeyPair('Ed25519');
305
+ const peerId = peerIdFromPrivateKey(privateKey);
306
+ const privateKeyBytes = privateKeyToProtobuf(privateKey);
307
+
308
+ const memberKey = peerId.toString();
309
+ const invitePrivateKey = uint8ArrayToString(privateKeyBytes, 'base64');
310
+
311
+ log('Generated member key: %s', memberKey);
312
+
313
+ // If we have a full invitation and a node, use the real protocol
314
+ if (typeof invitation !== 'string' && node) {
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;
335
+ const result = await this.getFormationManager().formStrand(
336
+ invitation,
337
+ fullDisclosure,
338
+ { peerKey: publicKeyB64, usageStampId, peerSignature },
339
+ node
340
+ );
341
+ return {
342
+ memberKey,
343
+ invitePrivateKey,
344
+ strandId: result.strandId,
345
+ // Carry the host strand's membership key delivered over the protocol (closed-strand
346
+ // provision-then-record). invitePrivateKey stays the initiator's generated signing key.
347
+ memberPrivateKey: result.memberPrivateKey
348
+ };
349
+ }
350
+
351
+ // Fallback: placeholder strandId (for testing without network)
352
+ log('No node provided, using placeholder strandId');
353
+ const strandId = `strand-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
354
+
355
+ return {
356
+ memberKey,
357
+ invitePrivateKey,
358
+ strandId
359
+ };
360
+ }
361
+
362
+ /**
363
+ * Create an open invitation for others to form strands with this party.
364
+ *
365
+ * @param sAppId The sApp to use for formed strands
366
+ * @param expirationMs How long the invitation is valid (ms from now)
367
+ * @param bootstrap Bootstrap addresses for contacting this party's cadre
368
+ * @returns The open invitation to share out-of-band
369
+ */
370
+ async createOpenInvitation(
371
+ sAppId: string,
372
+ expirationMs: number,
373
+ bootstrap: string[]
374
+ ): Promise<OpenInvitation> {
375
+ // Generate a unique token
376
+ const token = `invite-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
377
+ const expiration = new Date(Date.now() + expirationMs);
378
+ this.registerMintedInvitation(token, expiration.getTime());
379
+
380
+ log('Created open invitation: %s (expires %s)', token, expiration.toISOString());
381
+
382
+ return {
383
+ token,
384
+ sAppId,
385
+ expiration,
386
+ bootstrap
387
+ };
388
+ }
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
+
439
+ /**
440
+ * Record that a formation was completed successfully.
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).
451
+ */
452
+ async recordFormationComplete(
453
+ token: string,
454
+ peerKey: string,
455
+ strandId: string,
456
+ consent: { usageStampId: string; peerSignature: string },
457
+ disclosure = ''
458
+ ): Promise<void> {
459
+ if (this.formationUsageRecorder) {
460
+ await this.formationUsageRecorder.recordUsage({
461
+ token,
462
+ peerKey,
463
+ peerSignature: consent.peerSignature,
464
+ usageStampId: consent.usageStampId,
465
+ strandId,
466
+ disclosure
467
+ });
468
+ log('Recorded formation usage: token=%s strand=%s', token, strandId);
469
+ }
470
+ }
471
+ }
472
+