@serfab/cadre-core 0.10.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 (105) hide show
  1. package/README.md +255 -255
  2. package/dist/cadre-node.d.ts +203 -28
  3. package/dist/cadre-node.js +445 -84
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-cohort.d.ts +26 -0
  6. package/dist/control-cohort.js +26 -0
  7. package/dist/control-cohort.js.map +1 -1
  8. package/dist/control-database.d.ts +82 -117
  9. package/dist/control-database.js +217 -295
  10. package/dist/control-database.js.map +1 -1
  11. package/dist/control-formation-recorder.d.ts +8 -7
  12. package/dist/control-formation-recorder.js +8 -7
  13. package/dist/control-formation-recorder.js.map +1 -1
  14. package/dist/control-schema.d.ts +1 -1
  15. package/dist/control-schema.js +789 -774
  16. package/dist/control-schema.js.map +1 -1
  17. package/dist/control-write-retry.d.ts +15 -5
  18. package/dist/control-write-retry.js +19 -18
  19. package/dist/control-write-retry.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/peer-addr-book.d.ts +92 -0
  24. package/dist/peer-addr-book.js +220 -0
  25. package/dist/peer-addr-book.js.map +1 -0
  26. package/dist/seed-bootstrap.d.ts +7 -0
  27. package/dist/seed-bootstrap.js +15 -13
  28. package/dist/seed-bootstrap.js.map +1 -1
  29. package/dist/strand-cohort.d.ts +4 -37
  30. package/dist/strand-cohort.js +4 -15
  31. package/dist/strand-cohort.js.map +1 -1
  32. package/dist/strand-database.d.ts +11 -16
  33. package/dist/strand-database.js +19 -7
  34. package/dist/strand-database.js.map +1 -1
  35. package/dist/strand-formation-manager.d.ts +8 -8
  36. package/dist/strand-formation-manager.js +15 -18
  37. package/dist/strand-formation-manager.js.map +1 -1
  38. package/dist/strand-instance-manager.d.ts +52 -21
  39. package/dist/strand-instance-manager.js +100 -34
  40. package/dist/strand-instance-manager.js.map +1 -1
  41. package/dist/strand-membership-writer.d.ts +6 -4
  42. package/dist/strand-membership-writer.js +38 -36
  43. package/dist/strand-membership-writer.js.map +1 -1
  44. package/dist/types.d.ts +39 -31
  45. package/dist/types.js.map +1 -1
  46. package/package.json +8 -7
  47. package/src/arachnode-stub.ts +159 -159
  48. package/src/bootstrap-peer-store-file.ts +44 -44
  49. package/src/bootstrap-peer-store.ts +172 -172
  50. package/src/cadre-node.ts +5158 -4771
  51. package/src/canonical-json.ts +30 -30
  52. package/src/control-authorization.ts +114 -114
  53. package/src/control-cohort.ts +130 -103
  54. package/src/control-database.ts +2406 -2506
  55. package/src/control-formation-recorder.ts +305 -304
  56. package/src/control-schema.ts +801 -786
  57. package/src/control-stream.ts +205 -205
  58. package/src/control-write-retry.ts +483 -473
  59. package/src/delegate-admission.ts +302 -302
  60. package/src/device-token.ts +94 -94
  61. package/src/diagnostics/connection-path.ts +277 -277
  62. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  63. package/src/ed25519-key.ts +134 -134
  64. package/src/enrollment.ts +199 -199
  65. package/src/file-durable-slot.ts +61 -61
  66. package/src/formation-approval.ts +604 -604
  67. package/src/fs-atomic.ts +107 -107
  68. package/src/hibernation-manager.ts +437 -437
  69. package/src/identity-key.ts +109 -109
  70. package/src/index.ts +1 -0
  71. package/src/key-store-file.ts +132 -132
  72. package/src/key-store.ts +99 -99
  73. package/src/membership-connection-gater.ts +204 -204
  74. package/src/node-local-snapshot.ts +260 -260
  75. package/src/peer-addr-book.ts +250 -0
  76. package/src/peer-authorization.ts +259 -259
  77. package/src/peer-record.ts +164 -164
  78. package/src/push-credentials.ts +84 -84
  79. package/src/push-node.ts +64 -64
  80. package/src/push-notifier-apns.ts +247 -247
  81. package/src/push-notifier-fcm.ts +220 -220
  82. package/src/push-notifier-shared.ts +28 -28
  83. package/src/push-notifier.ts +52 -52
  84. package/src/relay-addrs.ts +111 -111
  85. package/src/relay-reservation.ts +823 -823
  86. package/src/seed-bootstrap.ts +15 -15
  87. package/src/seed-trust-policy.ts +140 -140
  88. package/src/strand-addr-protocol.ts +441 -441
  89. package/src/strand-backfill.ts +366 -366
  90. package/src/strand-cohort.ts +38 -77
  91. package/src/strand-database.ts +202 -204
  92. package/src/strand-formation-manager.ts +491 -494
  93. package/src/strand-formation-protocol.ts +692 -692
  94. package/src/strand-instance-manager.ts +108 -50
  95. package/src/strand-member-key.ts +39 -39
  96. package/src/strand-member-registry.ts +182 -182
  97. package/src/strand-membership-writer.ts +1432 -1430
  98. package/src/strand-solicitation.ts +472 -472
  99. package/src/strand-transport-key.ts +58 -58
  100. package/src/strand-wake-payload.ts +32 -32
  101. package/src/strand-wake-protocol.ts +314 -314
  102. package/src/strand-watcher.ts +314 -314
  103. package/src/trusted-owner-store-file.ts +41 -41
  104. package/src/trusted-owner-store.ts +171 -171
  105. package/src/types.ts +1309 -1303
@@ -1,472 +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 { 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
-
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
+