@serfab/cadre-core 1.10.0 → 1.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.
- package/dist/cadre-node.d.ts +0 -27
- package/dist/cadre-node.js +2 -94
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-schema.js +1066 -1066
- package/dist/diagnostics/connection-path.d.ts +1 -18
- package/dist/diagnostics/connection-path.js +1 -20
- package/dist/diagnostics/connection-path.js.map +1 -1
- package/dist/identity-key.d.ts +0 -30
- package/dist/identity-key.js +7 -40
- package/dist/identity-key.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/relay-stun.d.ts +24 -0
- package/dist/relay-stun.js +51 -0
- package/dist/relay-stun.js.map +1 -0
- package/dist/seed-bootstrap.js +4 -4
- package/dist/strand-membership-writer.js +32 -32
- package/package.json +2 -2
- package/src/cadre-node.ts +8654 -8753
- package/src/control-authorization.ts +117 -117
- package/src/control-cohort.ts +128 -128
- package/src/control-formation-recorder.ts +353 -353
- package/src/control-read-retry.ts +216 -216
- package/src/control-schema.ts +1078 -1078
- package/src/control-write-retry.ts +567 -567
- package/src/diagnostics/connection-path.ts +1 -29
- package/src/formation-approval.ts +610 -610
- package/src/hibernation-manager.ts +638 -638
- package/src/identity-key.ts +7 -62
- package/src/index.ts +644 -647
- package/src/invitation-bootstrap.ts +40 -40
- package/src/joined-strand-store.ts +498 -498
- package/src/link-budget.ts +684 -684
- package/src/membership-connection-gater.ts +570 -570
- package/src/node-local-snapshot.ts +298 -298
- package/src/peer-dial.ts +315 -315
- package/src/peer-join-backfill.ts +710 -710
- package/src/pending-join-runner.ts +735 -735
- package/src/relay-reservation.ts +1121 -1121
- package/src/relay-server.ts +98 -98
- package/src/relay-stun.ts +58 -0
- package/src/seed-bootstrap.ts +1448 -1448
- package/src/strand-addr-protocol.ts +547 -547
- package/src/strand-database.ts +287 -287
- package/src/strand-first-sync-gate.ts +391 -391
- package/src/strand-formation-deadlines.ts +227 -227
- package/src/strand-formation-manager.ts +725 -725
- package/src/strand-fret-addrs.ts +145 -145
- package/src/strand-instance-manager.ts +1789 -1789
- package/src/strand-membership-writer.ts +1974 -1974
- package/src/strand-network-state-file.ts +40 -40
- package/src/strand-network-state.ts +243 -243
- package/src/strand-revocation-enforcer.ts +654 -654
- package/src/strand-solicitation.ts +532 -532
- package/src/strand-wake-protocol.ts +401 -401
- package/src/types.ts +2222 -2222
- package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
- package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
- package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
- package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
|
@@ -1,532 +1,532 @@
|
|
|
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 { mintPlaceholderStrandId } from './strand-id.js';
|
|
17
|
-
import {
|
|
18
|
-
StrandFormationManager,
|
|
19
|
-
type CadrePeerAddrsSource,
|
|
20
|
-
type MembershipInviteIssuer,
|
|
21
|
-
type StrandFormationManagerConfig
|
|
22
|
-
} from './strand-formation-manager.js';
|
|
23
|
-
import {
|
|
24
|
-
isValidResponderCreatesResult,
|
|
25
|
-
type FormationResultMessage
|
|
26
|
-
} from './strand-formation-protocol.js';
|
|
27
|
-
|
|
28
|
-
const log = debug('sereus:cadre:solicitation');
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Interface for validating formation disclosures
|
|
32
|
-
*/
|
|
33
|
-
export interface DisclosureValidator {
|
|
34
|
-
/**
|
|
35
|
-
* Validate the disclosure provided by an initiator
|
|
36
|
-
* @param token The invitation token
|
|
37
|
-
* @param disclosure The disclosure object from the initiator
|
|
38
|
-
* @returns Whether the disclosure is acceptable
|
|
39
|
-
*/
|
|
40
|
-
validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Discriminated result of resolving the host strand an invite binds to (responder side):
|
|
45
|
-
*
|
|
46
|
-
* - `unbound`: the invite carries no `StrandId` → the responder-provisions path (provision
|
|
47
|
-
* a NEW strand; unbound/open).
|
|
48
|
-
* - `bound`: the invite names a host strand AND that strand row is present on this responder
|
|
49
|
-
* → provision-then-record (record consent against it, return it + its membership key).
|
|
50
|
-
* - `missing`: the invite names a host strand but the row is ABSENT on this responder (e.g.
|
|
51
|
-
* not yet converged) → the manager rejects cleanly rather than recording usage against a
|
|
52
|
-
* non-existent strand, which would fail the deferred `FormationUsage.StrandExists` CHECK at
|
|
53
|
-
* commit and drop the result frame.
|
|
54
|
-
*
|
|
55
|
-
* Replaces the older `{ strandId, memberPrivateKey } | null` shape, which conflated `bound`
|
|
56
|
-
* and `missing` (both produced a non-null result whenever the invite had a `StrandId`).
|
|
57
|
-
*
|
|
58
|
-
* The invite's `ValidationUrl` (the outside approval hook) is deliberately NOT surfaced here:
|
|
59
|
-
* the recorder that performs the write reads it itself, so the choice of which hook to trust
|
|
60
|
-
* never travels through a caller.
|
|
61
|
-
*/
|
|
62
|
-
export type ResolvedHostStrand =
|
|
63
|
-
| { kind: 'unbound' }
|
|
64
|
-
| { kind: 'bound'; strandId: string; memberPrivateKey: string | null }
|
|
65
|
-
| { kind: 'missing'; strandId: string };
|
|
66
|
-
|
|
67
|
-
/** One redemption of an invite against an already-existing host strand (the record-only consent write). */
|
|
68
|
-
export interface FormationUsageParams {
|
|
69
|
-
token: string;
|
|
70
|
-
/** The joining peer's own ed25519 public key — written to `FormationUsage.PeerKey` and inside BOTH signed digests. */
|
|
71
|
-
peerKey: string;
|
|
72
|
-
/** The joiner's signature over the `'consent'` digest — written to `FormationUsage.PeerSig`. */
|
|
73
|
-
peerSignature: string;
|
|
74
|
-
/** Joiner-minted single-use nonce; both signed digests cover it. */
|
|
75
|
-
usageStampId: string;
|
|
76
|
-
strandId: string;
|
|
77
|
-
/** Exact text to write to `FormationUsage.Disclosure`; joiner and approver both sign these bytes. */
|
|
78
|
-
disclosure: string;
|
|
79
|
-
/** Aborted when the caller has given up; observed BEFORE any write so the invite stays unspent. */
|
|
80
|
-
signal?: AbortSignal;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** A redemption the recorder has authorized (approval obtained, a seat still free) but not yet written. */
|
|
84
|
-
export interface AuthorizedFormationUsage {
|
|
85
|
-
/** Write the consent row. Abort and seat budget are re-checked inside the write lock. */
|
|
86
|
-
record(): Promise<void>;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Interface for recording formation usage
|
|
91
|
-
*/
|
|
92
|
-
export interface FormationUsageRecorder {
|
|
93
|
-
/**
|
|
94
|
-
* Record that a formation invite was used. Where {@link authorizeUsage} is implemented, this
|
|
95
|
-
* is the same as authorizing and then calling `record()` straight away.
|
|
96
|
-
*/
|
|
97
|
-
recordUsage(params: FormationUsageParams): Promise<void>;
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Everything {@link recordUsage} does short of the write: obtain the outside approval (when
|
|
101
|
-
* the invite demands one) and pre-check the seat budget, returning a handle whose `record()`
|
|
102
|
-
* writes the consent row with exactly the fields that were approved. Lets the manager ask
|
|
103
|
-
* before it writes anything into the host strand, and write consent only after.
|
|
104
|
-
* Optional: a recorder without it is treated as having nothing to ask up front.
|
|
105
|
-
*/
|
|
106
|
-
authorizeUsage?(params: FormationUsageParams): Promise<AuthorizedFormationUsage>;
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Check if a token has already been used (for single-use invites)
|
|
110
|
-
*/
|
|
111
|
-
isTokenUsed(token: string): Promise<boolean>;
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Check if a token is valid and not expired. An invalid answer says why: `'unknown'` (no
|
|
115
|
-
* invitation row here — it may not have replicated yet) or `'expired'`. An answer without
|
|
116
|
-
* a reason is treated as `'unknown'`, the retryable one.
|
|
117
|
-
*/
|
|
118
|
-
isTokenValid(token: string): Promise<{ valid: boolean; reason?: 'unknown' | 'expired'; invitation?: OpenInvitation }>;
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Resolve the host strand an invite binds to, classifying it as unbound / bound /
|
|
122
|
-
* missing (see {@link ResolvedHostStrand}). Optional: a recorder that does not bind
|
|
123
|
-
* strands omits it, and {@link StrandFormationManager} treats every invite as `unbound`
|
|
124
|
-
* and falls back to its provisioner. Keeping resolution behind this interface keeps the
|
|
125
|
-
* manager DB-agnostic and unit-testable with an in-memory fake.
|
|
126
|
-
*/
|
|
127
|
-
resolveStrand?(token: string): Promise<ResolvedHostStrand>;
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Provision a NEW strand for an UNBOUND invite and record consent against it
|
|
131
|
-
* ATOMICALLY (single `FormationUsage` row → single-use enforced on the next redemption).
|
|
132
|
-
* Optional: a recorder that only supports provision-then-record omits it, and the
|
|
133
|
-
* manager falls back to its {@link StrandProvisioner}. Mirrors the create-strand-by-consent
|
|
134
|
-
* path the schema's `Strand.AuthorizedInsert` `FormationUsage` branch authorizes. Returns the
|
|
135
|
-
* minted strand id plus its membership key (null for an open responder-provisioned strand).
|
|
136
|
-
*/
|
|
137
|
-
provisionAndRecord?(params: {
|
|
138
|
-
token: string;
|
|
139
|
-
/** The joining peer's own ed25519 public key — written to `FormationUsage.PeerKey` and inside BOTH signed digests. */
|
|
140
|
-
peerKey: string;
|
|
141
|
-
/** The joiner's signature over the `'consent'` digest — written to `FormationUsage.PeerSig`. */
|
|
142
|
-
peerSignature: string;
|
|
143
|
-
/** Joiner-minted single-use nonce; both signed digests cover it. */
|
|
144
|
-
usageStampId: string;
|
|
145
|
-
sAppId: string;
|
|
146
|
-
/** Exact text to write to `FormationUsage.Disclosure`; joiner and approver both sign these bytes. */
|
|
147
|
-
disclosure: string;
|
|
148
|
-
/** Aborted when the caller has given up; observed BEFORE any write so the invite stays unspent. */
|
|
149
|
-
signal?: AbortSignal;
|
|
150
|
-
}): Promise<{ strandId: string; memberPrivateKey: string | null }>;
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Is ANY invitation this recorder knows about unexpired and not fully
|
|
154
|
-
* consumed? Optional: a recorder that cannot enumerate invitations omits it,
|
|
155
|
-
* and only locally-minted invitations hold the connection gate's formation
|
|
156
|
-
* exemption open. Distinct from the per-token {@link isTokenValid} /
|
|
157
|
-
* {@link isTokenUsed} pair — the connection gate has no token to ask about.
|
|
158
|
-
*/
|
|
159
|
-
hasOutstandingInvitation?(): Promise<boolean>;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Interface for strand provisioning during formation
|
|
164
|
-
*/
|
|
165
|
-
export interface StrandProvisioner {
|
|
166
|
-
/**
|
|
167
|
-
* Provision a new strand after formation is validated
|
|
168
|
-
*/
|
|
169
|
-
provisionStrand(
|
|
170
|
-
sAppId: string,
|
|
171
|
-
initiatorKey: string,
|
|
172
|
-
responderKey: string
|
|
173
|
-
): Promise<{ strandId: string }>;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Interface for validating the responder's formation result on the initiator side.
|
|
178
|
-
*
|
|
179
|
-
* Symmetric to {@link DisclosureValidator} (which runs responder→initiator): it lets
|
|
180
|
-
* the initiator verify the responder's disclosed identity, cadre, and provisioned
|
|
181
|
-
* strand against the invitation/disclosure it used to form the strand. The manager
|
|
182
|
-
* owns one dialer session per `formStrand` call, so the per-session invitation +
|
|
183
|
-
* disclosure context is local — no hook-signature gymnastics needed.
|
|
184
|
-
*/
|
|
185
|
-
export interface FormationResponseValidator {
|
|
186
|
-
/** Validate the responder's result against the invitation/disclosure used to form the strand. */
|
|
187
|
-
validateResponse(ctx: {
|
|
188
|
-
invitation: OpenInvitation;
|
|
189
|
-
disclosure: StrandFormationDisclosure;
|
|
190
|
-
response: FormationResultMessage;
|
|
191
|
-
}): Promise<boolean>;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Built-in structural {@link FormationResponseValidator}.
|
|
196
|
-
*
|
|
197
|
-
* `validateResponse` rejects when the responder did not approve, omitted a disclosed
|
|
198
|
-
* identity, returned no/placeholder cadre addresses, or returned a missing/empty or
|
|
199
|
-
* non-responder-created strand (see {@link isValidResponderCreatesResult}).
|
|
200
|
-
* Apps can supply a stricter validator via {@link StrandSolicitationServiceOptions}.
|
|
201
|
-
*/
|
|
202
|
-
export function createDefaultFormationResponseValidator(): FormationResponseValidator {
|
|
203
|
-
return {
|
|
204
|
-
async validateResponse({ response }) {
|
|
205
|
-
return isValidResponderCreatesResult(response);
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export interface StrandSolicitationServiceOptions {
|
|
211
|
-
disclosureValidator?: DisclosureValidator;
|
|
212
|
-
formationUsageRecorder?: FormationUsageRecorder;
|
|
213
|
-
strandProvisioner?: StrandProvisioner;
|
|
214
|
-
/** Validates the responder's result on the initiator side (defaults to a structural check) */
|
|
215
|
-
formationResponseValidator?: FormationResponseValidator;
|
|
216
|
-
/** Party ID for this node (used in protocol messages) */
|
|
217
|
-
partyId?: string;
|
|
218
|
-
/** Cadre peer addresses for this node */
|
|
219
|
-
cadrePeerAddrs?: CadrePeerAddrsSource;
|
|
220
|
-
/**
|
|
221
|
-
* This node's live STRAND-network multiaddrs for a strand it is running (responder
|
|
222
|
-
* side), carried back to a validated joiner as its cross-party discovery seed. Wired
|
|
223
|
-
* by `CadreNode`; unwired, the responder discloses no strand addresses.
|
|
224
|
-
*/
|
|
225
|
-
resolveStrandAddrs?: (strandId: string) => string[];
|
|
226
|
-
/**
|
|
227
|
-
* Issue a single-use strand membership invitation against the live host strand a
|
|
228
|
-
* bound closed-strand redemption resolved to (responder side). Wired by `CadreNode`;
|
|
229
|
-
* see `StrandFormationManagerOptions.issueMembershipInvite` for the full contract
|
|
230
|
-
* (null = open strand, throw = reject the redemption retryably, unwired = mock/test
|
|
231
|
-
* posture: approve with no invitation).
|
|
232
|
-
*/
|
|
233
|
-
issueMembershipInvite?: MembershipInviteIssuer;
|
|
234
|
-
/** Configuration for the formation manager */
|
|
235
|
-
formationConfig?: StrandFormationManagerConfig;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Strand Solicitation API for forming strands via open invitations.
|
|
240
|
-
*
|
|
241
|
-
* This service handles the high-level API defined in api.md:
|
|
242
|
-
* - formStrand(invitation, disclosure, node) - called by initiator
|
|
243
|
-
*
|
|
244
|
-
* Outside approval of a redemption (an invite's `ValidationUrl`) is NOT here: it belongs to
|
|
245
|
-
* the redeeming node, which alone mints the nonce the approval is bound to. See
|
|
246
|
-
* `formation-approval.ts`.
|
|
247
|
-
*
|
|
248
|
-
* When a libp2p node is provided, the underlying protocol is handled by the
|
|
249
|
-
* native cadre-core formation transport via StrandFormationManager.
|
|
250
|
-
*/
|
|
251
|
-
export class StrandSolicitationService {
|
|
252
|
-
private readonly disclosureValidator?: DisclosureValidator;
|
|
253
|
-
private readonly formationUsageRecorder?: FormationUsageRecorder;
|
|
254
|
-
private readonly strandProvisioner?: StrandProvisioner;
|
|
255
|
-
private readonly formationResponseValidator?: FormationResponseValidator;
|
|
256
|
-
private readonly partyId: string;
|
|
257
|
-
private readonly cadrePeerAddrs: CadrePeerAddrsSource;
|
|
258
|
-
private readonly resolveStrandAddrs?: (strandId: string) => string[];
|
|
259
|
-
private readonly issueMembershipInvite?: MembershipInviteIssuer;
|
|
260
|
-
private formationManager?: StrandFormationManager;
|
|
261
|
-
private readonly formationConfig?: StrandFormationManagerConfig;
|
|
262
|
-
/**
|
|
263
|
-
* Tokens this process has minted (or published), mapped to their expiry
|
|
264
|
-
* epoch-ms — the in-memory half of {@link hasOutstandingInvitation}. Dies with
|
|
265
|
-
* the process, exactly like `CadreNode`'s enrollment window; a token that also
|
|
266
|
-
* reached the durable `FormationInvite` table survives via the recorder.
|
|
267
|
-
*
|
|
268
|
-
* NOTE: pruned only while {@link hasOutstandingInvitation} runs, so a node that
|
|
269
|
-
* mints steadily and never receives an inbound stranger retains every entry for
|
|
270
|
-
* the process's life. Bounded by mint rate today; if a host ever mints at scale,
|
|
271
|
-
* prune on a timer (or cap the map) instead of only on read.
|
|
272
|
-
*/
|
|
273
|
-
private readonly mintedInvitations = new Map<string, number>();
|
|
274
|
-
|
|
275
|
-
constructor(options?: StrandSolicitationServiceOptions) {
|
|
276
|
-
this.disclosureValidator = options?.disclosureValidator;
|
|
277
|
-
this.formationUsageRecorder = options?.formationUsageRecorder;
|
|
278
|
-
this.strandProvisioner = options?.strandProvisioner;
|
|
279
|
-
this.formationResponseValidator = options?.formationResponseValidator;
|
|
280
|
-
this.partyId = options?.partyId ?? `party-${Date.now()}`;
|
|
281
|
-
this.cadrePeerAddrs = options?.cadrePeerAddrs ?? [];
|
|
282
|
-
this.resolveStrandAddrs = options?.resolveStrandAddrs;
|
|
283
|
-
this.issueMembershipInvite = options?.issueMembershipInvite;
|
|
284
|
-
this.formationConfig = options?.formationConfig;
|
|
285
|
-
log('StrandSolicitationService created for party: %s', this.partyId);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Get or create the StrandFormationManager.
|
|
290
|
-
* Lazily initialized to allow configuration after construction.
|
|
291
|
-
*/
|
|
292
|
-
private getFormationManager(): StrandFormationManager {
|
|
293
|
-
if (!this.formationManager) {
|
|
294
|
-
this.formationManager = new StrandFormationManager({
|
|
295
|
-
disclosureValidator: this.disclosureValidator,
|
|
296
|
-
formationUsageRecorder: this.formationUsageRecorder,
|
|
297
|
-
strandProvisioner: this.strandProvisioner,
|
|
298
|
-
formationResponseValidator: this.formationResponseValidator,
|
|
299
|
-
partyId: this.partyId,
|
|
300
|
-
cadrePeerAddrs: this.cadrePeerAddrs,
|
|
301
|
-
resolveStrandAddrs: this.resolveStrandAddrs,
|
|
302
|
-
issueMembershipInvite: this.issueMembershipInvite,
|
|
303
|
-
config: this.formationConfig
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
return this.formationManager;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Register as a responder on a libp2p node.
|
|
311
|
-
* This enables the node to handle incoming strand formation requests.
|
|
312
|
-
*/
|
|
313
|
-
async registerResponder(node: Libp2p): Promise<void> {
|
|
314
|
-
await this.getFormationManager().registerResponder(node);
|
|
315
|
-
log('Registered as responder');
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Unregister as a responder from a libp2p node.
|
|
320
|
-
*/
|
|
321
|
-
async unregisterResponder(node: Libp2p): Promise<void> {
|
|
322
|
-
await this.getFormationManager().unregisterResponder(node);
|
|
323
|
-
log('Unregistered as responder');
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Form a strand with a responder via an open invitation.
|
|
328
|
-
*
|
|
329
|
-
* Called by the initiator (the party who received an out-of-band invitation).
|
|
330
|
-
* This generates a member key, contacts the responder's cadre, and negotiates
|
|
331
|
-
* strand formation.
|
|
332
|
-
*
|
|
333
|
-
* @param invitation The open invitation
|
|
334
|
-
* @param disclosure Identity/context information to share with the responder
|
|
335
|
-
* @param node Optional libp2p node for real protocol handling
|
|
336
|
-
* @returns The member key and strand info if successful
|
|
337
|
-
*/
|
|
338
|
-
async formStrand(
|
|
339
|
-
invitation: OpenInvitation,
|
|
340
|
-
disclosure: StrandFormationDisclosure,
|
|
341
|
-
node?: Libp2p
|
|
342
|
-
): Promise<FormStrandResult> {
|
|
343
|
-
const token = invitation.token;
|
|
344
|
-
log('Forming strand with token: %s', token);
|
|
345
|
-
|
|
346
|
-
// Generate a new keypair for this strand membership
|
|
347
|
-
const privateKey = await generateKeyPair('Ed25519');
|
|
348
|
-
const peerId = peerIdFromPrivateKey(privateKey);
|
|
349
|
-
const privateKeyBytes = privateKeyToProtobuf(privateKey);
|
|
350
|
-
|
|
351
|
-
const memberKey = peerId.toString();
|
|
352
|
-
const invitePrivateKey = uint8ArrayToString(privateKeyBytes, 'base64');
|
|
353
|
-
|
|
354
|
-
log('Generated member key: %s', memberKey);
|
|
355
|
-
|
|
356
|
-
// If we have a node, use the real protocol
|
|
357
|
-
if (node) {
|
|
358
|
-
log('Using native cadre-core formation transport');
|
|
359
|
-
const { privateKeyB64, publicKeyB64 } = ed25519KeyPairFromLibp2p(privateKey);
|
|
360
|
-
const usageStampId = generateStampId(memberKey);
|
|
361
|
-
// Built ONCE, AFTER the partyId override: this exact object travels to the
|
|
362
|
-
// responder, and its canonical serialization is the disclosure text the consent
|
|
363
|
-
// signature covers and the responder writes to `FormationUsage.Disclosure`.
|
|
364
|
-
const fullDisclosure = { ...disclosure, partyId: memberKey };
|
|
365
|
-
const peerSignature = sign(
|
|
366
|
-
formationConsentMessage({
|
|
367
|
-
token,
|
|
368
|
-
usageStampId,
|
|
369
|
-
peerKey: publicKeyB64,
|
|
370
|
-
disclosure: canonicalJson(fullDisclosure)
|
|
371
|
-
}),
|
|
372
|
-
privateKeyB64,
|
|
373
|
-
'ed25519',
|
|
374
|
-
'bytes',
|
|
375
|
-
'base64url',
|
|
376
|
-
'base64url'
|
|
377
|
-
) as string;
|
|
378
|
-
const result = await this.getFormationManager().formStrand(
|
|
379
|
-
invitation,
|
|
380
|
-
fullDisclosure,
|
|
381
|
-
{ peerKey: publicKeyB64, usageStampId, peerSignature },
|
|
382
|
-
node
|
|
383
|
-
);
|
|
384
|
-
return {
|
|
385
|
-
memberKey,
|
|
386
|
-
invitePrivateKey,
|
|
387
|
-
strandId: result.strandId,
|
|
388
|
-
// Carry the host strand's membership key delivered over the protocol (closed-strand
|
|
389
|
-
// provision-then-record). invitePrivateKey stays the initiator's generated signing key.
|
|
390
|
-
memberPrivateKey: result.memberPrivateKey,
|
|
391
|
-
// The joiner's own single-use membership invitation into a closed host strand
|
|
392
|
-
// (already shape-checked by the manager); absent for open/unbound.
|
|
393
|
-
membershipInvite: result.membershipInvite,
|
|
394
|
-
// The responder's live strand-network addresses, or `[]` when it had none to give.
|
|
395
|
-
strandAddrs: result.strandAddrs
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// Fallback: placeholder strandId (for testing without network)
|
|
400
|
-
log('No node provided, using placeholder strandId');
|
|
401
|
-
const strandId = mintPlaceholderStrandId();
|
|
402
|
-
|
|
403
|
-
return {
|
|
404
|
-
memberKey,
|
|
405
|
-
invitePrivateKey,
|
|
406
|
-
strandId,
|
|
407
|
-
// No wire, so nothing was disclosed — an empty seed, never absent.
|
|
408
|
-
strandAddrs: []
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Create an open invitation for others to form strands with this party.
|
|
414
|
-
*
|
|
415
|
-
* @param sAppId The sApp to use for formed strands
|
|
416
|
-
* @param expirationMs How long the invitation is valid (ms from now)
|
|
417
|
-
* @param bootstrap Bootstrap addresses for contacting this party's cadre
|
|
418
|
-
* @returns The open invitation to share out-of-band
|
|
419
|
-
*/
|
|
420
|
-
async createOpenInvitation(
|
|
421
|
-
sAppId: string,
|
|
422
|
-
expirationMs: number,
|
|
423
|
-
bootstrap: string[]
|
|
424
|
-
): Promise<OpenInvitation> {
|
|
425
|
-
// Generate a unique token
|
|
426
|
-
const token = `invite-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
427
|
-
const expiration = new Date(Date.now() + expirationMs);
|
|
428
|
-
this.registerMintedInvitation(token, expiration.getTime());
|
|
429
|
-
|
|
430
|
-
log('Created open invitation: %s (expires %s)', token, expiration.toISOString());
|
|
431
|
-
|
|
432
|
-
return {
|
|
433
|
-
token,
|
|
434
|
-
sAppId,
|
|
435
|
-
expiration,
|
|
436
|
-
bootstrap
|
|
437
|
-
};
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* Remember a token this node minted (or published) so
|
|
442
|
-
* {@link hasOutstandingInvitation} can answer "yes" before the durable
|
|
443
|
-
* `FormationInvite` row is readable — or when there is no durable store at
|
|
444
|
-
* all. `expiresAtMs` may be `Number.POSITIVE_INFINITY` for an invitation that
|
|
445
|
-
* never expires.
|
|
446
|
-
*/
|
|
447
|
-
registerMintedInvitation(token: string, expiresAtMs: number): void {
|
|
448
|
-
this.mintedInvitations.set(token, expiresAtMs);
|
|
449
|
-
log('Registered minted invitation %s (expires %d)', token, expiresAtMs);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* Take over every token `previous` minted or published, so a service that replaces it
|
|
454
|
-
* on a node keeps the connection gate open for invitations already handed out.
|
|
455
|
-
*/
|
|
456
|
-
adoptMintedInvitations(previous: StrandSolicitationService): void {
|
|
457
|
-
for (const [token, expiresAtMs] of previous.mintedInvitations) {
|
|
458
|
-
this.mintedInvitations.set(token, expiresAtMs);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Does this node currently expect a stranger — i.e. is at least one open
|
|
464
|
-
* invitation unexpired and not yet fully consumed? Sole input to the
|
|
465
|
-
* control-network connection gate's formation exemption.
|
|
466
|
-
*
|
|
467
|
-
* Two sources, in order:
|
|
468
|
-
*
|
|
469
|
-
* 1. The local mint registry ({@link registerMintedInvitation}), which is
|
|
470
|
-
* in-memory and dies with the process. Expired entries are dropped. With
|
|
471
|
-
* no {@link FormationUsageRecorder} configured there is no consumption
|
|
472
|
-
* oracle, so an unexpired minted token counts as outstanding by
|
|
473
|
-
* construction; with one, a token it reports used is deleted (consumption
|
|
474
|
-
* is permanent, so the registry never reconsiders it).
|
|
475
|
-
* 2. The recorder's optional {@link FormationUsageRecorder.hasOutstandingInvitation},
|
|
476
|
-
* which survives restarts and sees invitations replicated in from siblings.
|
|
477
|
-
*
|
|
478
|
-
* Re-entrant: concurrent inbound connections each call this independently and
|
|
479
|
-
* the only mutation is deleting expired/consumed entries, which is monotonic.
|
|
480
|
-
*/
|
|
481
|
-
async hasOutstandingInvitation(): Promise<boolean> {
|
|
482
|
-
const now = Date.now();
|
|
483
|
-
for (const [token, expiresAtMs] of this.mintedInvitations) {
|
|
484
|
-
if (expiresAtMs <= now) {
|
|
485
|
-
this.mintedInvitations.delete(token);
|
|
486
|
-
continue;
|
|
487
|
-
}
|
|
488
|
-
if (!this.formationUsageRecorder) {
|
|
489
|
-
return true;
|
|
490
|
-
}
|
|
491
|
-
if (!(await this.formationUsageRecorder.isTokenUsed(token))) {
|
|
492
|
-
return true;
|
|
493
|
-
}
|
|
494
|
-
this.mintedInvitations.delete(token);
|
|
495
|
-
}
|
|
496
|
-
return (await this.formationUsageRecorder?.hasOutstandingInvitation?.()) ?? false;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* Record that a formation was completed successfully.
|
|
501
|
-
* Called after strand provisioning to track usage.
|
|
502
|
-
*
|
|
503
|
-
* `consent` carries the joiner-signed fields `recordUsage` now requires
|
|
504
|
-
* (see {@link FormationUsageRecorder.recordUsage}); this helper only passes them through.
|
|
505
|
-
*
|
|
506
|
-
* NOTE: this reaches `recordUsage` without passing through
|
|
507
|
-
* `StrandFormationManager.provisionAsResponder`, so `disclosure` skips that path's
|
|
508
|
-
* `MAX_DISCLOSURE_BYTES` cap. Harmless today — its only callers are integration-test mocks
|
|
509
|
-
* that all default to `''` — but if a production caller ever passes a real disclosure here,
|
|
510
|
-
* cap it too (the approver signs those bytes verbatim).
|
|
511
|
-
*/
|
|
512
|
-
async recordFormationComplete(
|
|
513
|
-
token: string,
|
|
514
|
-
peerKey: string,
|
|
515
|
-
strandId: string,
|
|
516
|
-
consent: { usageStampId: string; peerSignature: string },
|
|
517
|
-
disclosure = ''
|
|
518
|
-
): Promise<void> {
|
|
519
|
-
if (this.formationUsageRecorder) {
|
|
520
|
-
await this.formationUsageRecorder.recordUsage({
|
|
521
|
-
token,
|
|
522
|
-
peerKey,
|
|
523
|
-
peerSignature: consent.peerSignature,
|
|
524
|
-
usageStampId: consent.usageStampId,
|
|
525
|
-
strandId,
|
|
526
|
-
disclosure
|
|
527
|
-
});
|
|
528
|
-
log('Recorded formation usage: token=%s strand=%s', token, strandId);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
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 { mintPlaceholderStrandId } from './strand-id.js';
|
|
17
|
+
import {
|
|
18
|
+
StrandFormationManager,
|
|
19
|
+
type CadrePeerAddrsSource,
|
|
20
|
+
type MembershipInviteIssuer,
|
|
21
|
+
type StrandFormationManagerConfig
|
|
22
|
+
} from './strand-formation-manager.js';
|
|
23
|
+
import {
|
|
24
|
+
isValidResponderCreatesResult,
|
|
25
|
+
type FormationResultMessage
|
|
26
|
+
} from './strand-formation-protocol.js';
|
|
27
|
+
|
|
28
|
+
const log = debug('sereus:cadre:solicitation');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Interface for validating formation disclosures
|
|
32
|
+
*/
|
|
33
|
+
export interface DisclosureValidator {
|
|
34
|
+
/**
|
|
35
|
+
* Validate the disclosure provided by an initiator
|
|
36
|
+
* @param token The invitation token
|
|
37
|
+
* @param disclosure The disclosure object from the initiator
|
|
38
|
+
* @returns Whether the disclosure is acceptable
|
|
39
|
+
*/
|
|
40
|
+
validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Discriminated result of resolving the host strand an invite binds to (responder side):
|
|
45
|
+
*
|
|
46
|
+
* - `unbound`: the invite carries no `StrandId` → the responder-provisions path (provision
|
|
47
|
+
* a NEW strand; unbound/open).
|
|
48
|
+
* - `bound`: the invite names a host strand AND that strand row is present on this responder
|
|
49
|
+
* → provision-then-record (record consent against it, return it + its membership key).
|
|
50
|
+
* - `missing`: the invite names a host strand but the row is ABSENT on this responder (e.g.
|
|
51
|
+
* not yet converged) → the manager rejects cleanly rather than recording usage against a
|
|
52
|
+
* non-existent strand, which would fail the deferred `FormationUsage.StrandExists` CHECK at
|
|
53
|
+
* commit and drop the result frame.
|
|
54
|
+
*
|
|
55
|
+
* Replaces the older `{ strandId, memberPrivateKey } | null` shape, which conflated `bound`
|
|
56
|
+
* and `missing` (both produced a non-null result whenever the invite had a `StrandId`).
|
|
57
|
+
*
|
|
58
|
+
* The invite's `ValidationUrl` (the outside approval hook) is deliberately NOT surfaced here:
|
|
59
|
+
* the recorder that performs the write reads it itself, so the choice of which hook to trust
|
|
60
|
+
* never travels through a caller.
|
|
61
|
+
*/
|
|
62
|
+
export type ResolvedHostStrand =
|
|
63
|
+
| { kind: 'unbound' }
|
|
64
|
+
| { kind: 'bound'; strandId: string; memberPrivateKey: string | null }
|
|
65
|
+
| { kind: 'missing'; strandId: string };
|
|
66
|
+
|
|
67
|
+
/** One redemption of an invite against an already-existing host strand (the record-only consent write). */
|
|
68
|
+
export interface FormationUsageParams {
|
|
69
|
+
token: string;
|
|
70
|
+
/** The joining peer's own ed25519 public key — written to `FormationUsage.PeerKey` and inside BOTH signed digests. */
|
|
71
|
+
peerKey: string;
|
|
72
|
+
/** The joiner's signature over the `'consent'` digest — written to `FormationUsage.PeerSig`. */
|
|
73
|
+
peerSignature: string;
|
|
74
|
+
/** Joiner-minted single-use nonce; both signed digests cover it. */
|
|
75
|
+
usageStampId: string;
|
|
76
|
+
strandId: string;
|
|
77
|
+
/** Exact text to write to `FormationUsage.Disclosure`; joiner and approver both sign these bytes. */
|
|
78
|
+
disclosure: string;
|
|
79
|
+
/** Aborted when the caller has given up; observed BEFORE any write so the invite stays unspent. */
|
|
80
|
+
signal?: AbortSignal;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A redemption the recorder has authorized (approval obtained, a seat still free) but not yet written. */
|
|
84
|
+
export interface AuthorizedFormationUsage {
|
|
85
|
+
/** Write the consent row. Abort and seat budget are re-checked inside the write lock. */
|
|
86
|
+
record(): Promise<void>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Interface for recording formation usage
|
|
91
|
+
*/
|
|
92
|
+
export interface FormationUsageRecorder {
|
|
93
|
+
/**
|
|
94
|
+
* Record that a formation invite was used. Where {@link authorizeUsage} is implemented, this
|
|
95
|
+
* is the same as authorizing and then calling `record()` straight away.
|
|
96
|
+
*/
|
|
97
|
+
recordUsage(params: FormationUsageParams): Promise<void>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Everything {@link recordUsage} does short of the write: obtain the outside approval (when
|
|
101
|
+
* the invite demands one) and pre-check the seat budget, returning a handle whose `record()`
|
|
102
|
+
* writes the consent row with exactly the fields that were approved. Lets the manager ask
|
|
103
|
+
* before it writes anything into the host strand, and write consent only after.
|
|
104
|
+
* Optional: a recorder without it is treated as having nothing to ask up front.
|
|
105
|
+
*/
|
|
106
|
+
authorizeUsage?(params: FormationUsageParams): Promise<AuthorizedFormationUsage>;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check if a token has already been used (for single-use invites)
|
|
110
|
+
*/
|
|
111
|
+
isTokenUsed(token: string): Promise<boolean>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if a token is valid and not expired. An invalid answer says why: `'unknown'` (no
|
|
115
|
+
* invitation row here — it may not have replicated yet) or `'expired'`. An answer without
|
|
116
|
+
* a reason is treated as `'unknown'`, the retryable one.
|
|
117
|
+
*/
|
|
118
|
+
isTokenValid(token: string): Promise<{ valid: boolean; reason?: 'unknown' | 'expired'; invitation?: OpenInvitation }>;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the host strand an invite binds to, classifying it as unbound / bound /
|
|
122
|
+
* missing (see {@link ResolvedHostStrand}). Optional: a recorder that does not bind
|
|
123
|
+
* strands omits it, and {@link StrandFormationManager} treats every invite as `unbound`
|
|
124
|
+
* and falls back to its provisioner. Keeping resolution behind this interface keeps the
|
|
125
|
+
* manager DB-agnostic and unit-testable with an in-memory fake.
|
|
126
|
+
*/
|
|
127
|
+
resolveStrand?(token: string): Promise<ResolvedHostStrand>;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Provision a NEW strand for an UNBOUND invite and record consent against it
|
|
131
|
+
* ATOMICALLY (single `FormationUsage` row → single-use enforced on the next redemption).
|
|
132
|
+
* Optional: a recorder that only supports provision-then-record omits it, and the
|
|
133
|
+
* manager falls back to its {@link StrandProvisioner}. Mirrors the create-strand-by-consent
|
|
134
|
+
* path the schema's `Strand.AuthorizedInsert` `FormationUsage` branch authorizes. Returns the
|
|
135
|
+
* minted strand id plus its membership key (null for an open responder-provisioned strand).
|
|
136
|
+
*/
|
|
137
|
+
provisionAndRecord?(params: {
|
|
138
|
+
token: string;
|
|
139
|
+
/** The joining peer's own ed25519 public key — written to `FormationUsage.PeerKey` and inside BOTH signed digests. */
|
|
140
|
+
peerKey: string;
|
|
141
|
+
/** The joiner's signature over the `'consent'` digest — written to `FormationUsage.PeerSig`. */
|
|
142
|
+
peerSignature: string;
|
|
143
|
+
/** Joiner-minted single-use nonce; both signed digests cover it. */
|
|
144
|
+
usageStampId: string;
|
|
145
|
+
sAppId: string;
|
|
146
|
+
/** Exact text to write to `FormationUsage.Disclosure`; joiner and approver both sign these bytes. */
|
|
147
|
+
disclosure: string;
|
|
148
|
+
/** Aborted when the caller has given up; observed BEFORE any write so the invite stays unspent. */
|
|
149
|
+
signal?: AbortSignal;
|
|
150
|
+
}): Promise<{ strandId: string; memberPrivateKey: string | null }>;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Is ANY invitation this recorder knows about unexpired and not fully
|
|
154
|
+
* consumed? Optional: a recorder that cannot enumerate invitations omits it,
|
|
155
|
+
* and only locally-minted invitations hold the connection gate's formation
|
|
156
|
+
* exemption open. Distinct from the per-token {@link isTokenValid} /
|
|
157
|
+
* {@link isTokenUsed} pair — the connection gate has no token to ask about.
|
|
158
|
+
*/
|
|
159
|
+
hasOutstandingInvitation?(): Promise<boolean>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Interface for strand provisioning during formation
|
|
164
|
+
*/
|
|
165
|
+
export interface StrandProvisioner {
|
|
166
|
+
/**
|
|
167
|
+
* Provision a new strand after formation is validated
|
|
168
|
+
*/
|
|
169
|
+
provisionStrand(
|
|
170
|
+
sAppId: string,
|
|
171
|
+
initiatorKey: string,
|
|
172
|
+
responderKey: string
|
|
173
|
+
): Promise<{ strandId: string }>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Interface for validating the responder's formation result on the initiator side.
|
|
178
|
+
*
|
|
179
|
+
* Symmetric to {@link DisclosureValidator} (which runs responder→initiator): it lets
|
|
180
|
+
* the initiator verify the responder's disclosed identity, cadre, and provisioned
|
|
181
|
+
* strand against the invitation/disclosure it used to form the strand. The manager
|
|
182
|
+
* owns one dialer session per `formStrand` call, so the per-session invitation +
|
|
183
|
+
* disclosure context is local — no hook-signature gymnastics needed.
|
|
184
|
+
*/
|
|
185
|
+
export interface FormationResponseValidator {
|
|
186
|
+
/** Validate the responder's result against the invitation/disclosure used to form the strand. */
|
|
187
|
+
validateResponse(ctx: {
|
|
188
|
+
invitation: OpenInvitation;
|
|
189
|
+
disclosure: StrandFormationDisclosure;
|
|
190
|
+
response: FormationResultMessage;
|
|
191
|
+
}): Promise<boolean>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Built-in structural {@link FormationResponseValidator}.
|
|
196
|
+
*
|
|
197
|
+
* `validateResponse` rejects when the responder did not approve, omitted a disclosed
|
|
198
|
+
* identity, returned no/placeholder cadre addresses, or returned a missing/empty or
|
|
199
|
+
* non-responder-created strand (see {@link isValidResponderCreatesResult}).
|
|
200
|
+
* Apps can supply a stricter validator via {@link StrandSolicitationServiceOptions}.
|
|
201
|
+
*/
|
|
202
|
+
export function createDefaultFormationResponseValidator(): FormationResponseValidator {
|
|
203
|
+
return {
|
|
204
|
+
async validateResponse({ response }) {
|
|
205
|
+
return isValidResponderCreatesResult(response);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface StrandSolicitationServiceOptions {
|
|
211
|
+
disclosureValidator?: DisclosureValidator;
|
|
212
|
+
formationUsageRecorder?: FormationUsageRecorder;
|
|
213
|
+
strandProvisioner?: StrandProvisioner;
|
|
214
|
+
/** Validates the responder's result on the initiator side (defaults to a structural check) */
|
|
215
|
+
formationResponseValidator?: FormationResponseValidator;
|
|
216
|
+
/** Party ID for this node (used in protocol messages) */
|
|
217
|
+
partyId?: string;
|
|
218
|
+
/** Cadre peer addresses for this node */
|
|
219
|
+
cadrePeerAddrs?: CadrePeerAddrsSource;
|
|
220
|
+
/**
|
|
221
|
+
* This node's live STRAND-network multiaddrs for a strand it is running (responder
|
|
222
|
+
* side), carried back to a validated joiner as its cross-party discovery seed. Wired
|
|
223
|
+
* by `CadreNode`; unwired, the responder discloses no strand addresses.
|
|
224
|
+
*/
|
|
225
|
+
resolveStrandAddrs?: (strandId: string) => string[];
|
|
226
|
+
/**
|
|
227
|
+
* Issue a single-use strand membership invitation against the live host strand a
|
|
228
|
+
* bound closed-strand redemption resolved to (responder side). Wired by `CadreNode`;
|
|
229
|
+
* see `StrandFormationManagerOptions.issueMembershipInvite` for the full contract
|
|
230
|
+
* (null = open strand, throw = reject the redemption retryably, unwired = mock/test
|
|
231
|
+
* posture: approve with no invitation).
|
|
232
|
+
*/
|
|
233
|
+
issueMembershipInvite?: MembershipInviteIssuer;
|
|
234
|
+
/** Configuration for the formation manager */
|
|
235
|
+
formationConfig?: StrandFormationManagerConfig;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Strand Solicitation API for forming strands via open invitations.
|
|
240
|
+
*
|
|
241
|
+
* This service handles the high-level API defined in api.md:
|
|
242
|
+
* - formStrand(invitation, disclosure, node) - called by initiator
|
|
243
|
+
*
|
|
244
|
+
* Outside approval of a redemption (an invite's `ValidationUrl`) is NOT here: it belongs to
|
|
245
|
+
* the redeeming node, which alone mints the nonce the approval is bound to. See
|
|
246
|
+
* `formation-approval.ts`.
|
|
247
|
+
*
|
|
248
|
+
* When a libp2p node is provided, the underlying protocol is handled by the
|
|
249
|
+
* native cadre-core formation transport via StrandFormationManager.
|
|
250
|
+
*/
|
|
251
|
+
export class StrandSolicitationService {
|
|
252
|
+
private readonly disclosureValidator?: DisclosureValidator;
|
|
253
|
+
private readonly formationUsageRecorder?: FormationUsageRecorder;
|
|
254
|
+
private readonly strandProvisioner?: StrandProvisioner;
|
|
255
|
+
private readonly formationResponseValidator?: FormationResponseValidator;
|
|
256
|
+
private readonly partyId: string;
|
|
257
|
+
private readonly cadrePeerAddrs: CadrePeerAddrsSource;
|
|
258
|
+
private readonly resolveStrandAddrs?: (strandId: string) => string[];
|
|
259
|
+
private readonly issueMembershipInvite?: MembershipInviteIssuer;
|
|
260
|
+
private formationManager?: StrandFormationManager;
|
|
261
|
+
private readonly formationConfig?: StrandFormationManagerConfig;
|
|
262
|
+
/**
|
|
263
|
+
* Tokens this process has minted (or published), mapped to their expiry
|
|
264
|
+
* epoch-ms — the in-memory half of {@link hasOutstandingInvitation}. Dies with
|
|
265
|
+
* the process, exactly like `CadreNode`'s enrollment window; a token that also
|
|
266
|
+
* reached the durable `FormationInvite` table survives via the recorder.
|
|
267
|
+
*
|
|
268
|
+
* NOTE: pruned only while {@link hasOutstandingInvitation} runs, so a node that
|
|
269
|
+
* mints steadily and never receives an inbound stranger retains every entry for
|
|
270
|
+
* the process's life. Bounded by mint rate today; if a host ever mints at scale,
|
|
271
|
+
* prune on a timer (or cap the map) instead of only on read.
|
|
272
|
+
*/
|
|
273
|
+
private readonly mintedInvitations = new Map<string, number>();
|
|
274
|
+
|
|
275
|
+
constructor(options?: StrandSolicitationServiceOptions) {
|
|
276
|
+
this.disclosureValidator = options?.disclosureValidator;
|
|
277
|
+
this.formationUsageRecorder = options?.formationUsageRecorder;
|
|
278
|
+
this.strandProvisioner = options?.strandProvisioner;
|
|
279
|
+
this.formationResponseValidator = options?.formationResponseValidator;
|
|
280
|
+
this.partyId = options?.partyId ?? `party-${Date.now()}`;
|
|
281
|
+
this.cadrePeerAddrs = options?.cadrePeerAddrs ?? [];
|
|
282
|
+
this.resolveStrandAddrs = options?.resolveStrandAddrs;
|
|
283
|
+
this.issueMembershipInvite = options?.issueMembershipInvite;
|
|
284
|
+
this.formationConfig = options?.formationConfig;
|
|
285
|
+
log('StrandSolicitationService created for party: %s', this.partyId);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Get or create the StrandFormationManager.
|
|
290
|
+
* Lazily initialized to allow configuration after construction.
|
|
291
|
+
*/
|
|
292
|
+
private getFormationManager(): StrandFormationManager {
|
|
293
|
+
if (!this.formationManager) {
|
|
294
|
+
this.formationManager = new StrandFormationManager({
|
|
295
|
+
disclosureValidator: this.disclosureValidator,
|
|
296
|
+
formationUsageRecorder: this.formationUsageRecorder,
|
|
297
|
+
strandProvisioner: this.strandProvisioner,
|
|
298
|
+
formationResponseValidator: this.formationResponseValidator,
|
|
299
|
+
partyId: this.partyId,
|
|
300
|
+
cadrePeerAddrs: this.cadrePeerAddrs,
|
|
301
|
+
resolveStrandAddrs: this.resolveStrandAddrs,
|
|
302
|
+
issueMembershipInvite: this.issueMembershipInvite,
|
|
303
|
+
config: this.formationConfig
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return this.formationManager;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Register as a responder on a libp2p node.
|
|
311
|
+
* This enables the node to handle incoming strand formation requests.
|
|
312
|
+
*/
|
|
313
|
+
async registerResponder(node: Libp2p): Promise<void> {
|
|
314
|
+
await this.getFormationManager().registerResponder(node);
|
|
315
|
+
log('Registered as responder');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Unregister as a responder from a libp2p node.
|
|
320
|
+
*/
|
|
321
|
+
async unregisterResponder(node: Libp2p): Promise<void> {
|
|
322
|
+
await this.getFormationManager().unregisterResponder(node);
|
|
323
|
+
log('Unregistered as responder');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Form a strand with a responder via an open invitation.
|
|
328
|
+
*
|
|
329
|
+
* Called by the initiator (the party who received an out-of-band invitation).
|
|
330
|
+
* This generates a member key, contacts the responder's cadre, and negotiates
|
|
331
|
+
* strand formation.
|
|
332
|
+
*
|
|
333
|
+
* @param invitation The open invitation
|
|
334
|
+
* @param disclosure Identity/context information to share with the responder
|
|
335
|
+
* @param node Optional libp2p node for real protocol handling
|
|
336
|
+
* @returns The member key and strand info if successful
|
|
337
|
+
*/
|
|
338
|
+
async formStrand(
|
|
339
|
+
invitation: OpenInvitation,
|
|
340
|
+
disclosure: StrandFormationDisclosure,
|
|
341
|
+
node?: Libp2p
|
|
342
|
+
): Promise<FormStrandResult> {
|
|
343
|
+
const token = invitation.token;
|
|
344
|
+
log('Forming strand with token: %s', token);
|
|
345
|
+
|
|
346
|
+
// Generate a new keypair for this strand membership
|
|
347
|
+
const privateKey = await generateKeyPair('Ed25519');
|
|
348
|
+
const peerId = peerIdFromPrivateKey(privateKey);
|
|
349
|
+
const privateKeyBytes = privateKeyToProtobuf(privateKey);
|
|
350
|
+
|
|
351
|
+
const memberKey = peerId.toString();
|
|
352
|
+
const invitePrivateKey = uint8ArrayToString(privateKeyBytes, 'base64');
|
|
353
|
+
|
|
354
|
+
log('Generated member key: %s', memberKey);
|
|
355
|
+
|
|
356
|
+
// If we have a node, use the real protocol
|
|
357
|
+
if (node) {
|
|
358
|
+
log('Using native cadre-core formation transport');
|
|
359
|
+
const { privateKeyB64, publicKeyB64 } = ed25519KeyPairFromLibp2p(privateKey);
|
|
360
|
+
const usageStampId = generateStampId(memberKey);
|
|
361
|
+
// Built ONCE, AFTER the partyId override: this exact object travels to the
|
|
362
|
+
// responder, and its canonical serialization is the disclosure text the consent
|
|
363
|
+
// signature covers and the responder writes to `FormationUsage.Disclosure`.
|
|
364
|
+
const fullDisclosure = { ...disclosure, partyId: memberKey };
|
|
365
|
+
const peerSignature = sign(
|
|
366
|
+
formationConsentMessage({
|
|
367
|
+
token,
|
|
368
|
+
usageStampId,
|
|
369
|
+
peerKey: publicKeyB64,
|
|
370
|
+
disclosure: canonicalJson(fullDisclosure)
|
|
371
|
+
}),
|
|
372
|
+
privateKeyB64,
|
|
373
|
+
'ed25519',
|
|
374
|
+
'bytes',
|
|
375
|
+
'base64url',
|
|
376
|
+
'base64url'
|
|
377
|
+
) as string;
|
|
378
|
+
const result = await this.getFormationManager().formStrand(
|
|
379
|
+
invitation,
|
|
380
|
+
fullDisclosure,
|
|
381
|
+
{ peerKey: publicKeyB64, usageStampId, peerSignature },
|
|
382
|
+
node
|
|
383
|
+
);
|
|
384
|
+
return {
|
|
385
|
+
memberKey,
|
|
386
|
+
invitePrivateKey,
|
|
387
|
+
strandId: result.strandId,
|
|
388
|
+
// Carry the host strand's membership key delivered over the protocol (closed-strand
|
|
389
|
+
// provision-then-record). invitePrivateKey stays the initiator's generated signing key.
|
|
390
|
+
memberPrivateKey: result.memberPrivateKey,
|
|
391
|
+
// The joiner's own single-use membership invitation into a closed host strand
|
|
392
|
+
// (already shape-checked by the manager); absent for open/unbound.
|
|
393
|
+
membershipInvite: result.membershipInvite,
|
|
394
|
+
// The responder's live strand-network addresses, or `[]` when it had none to give.
|
|
395
|
+
strandAddrs: result.strandAddrs
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Fallback: placeholder strandId (for testing without network)
|
|
400
|
+
log('No node provided, using placeholder strandId');
|
|
401
|
+
const strandId = mintPlaceholderStrandId();
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
memberKey,
|
|
405
|
+
invitePrivateKey,
|
|
406
|
+
strandId,
|
|
407
|
+
// No wire, so nothing was disclosed — an empty seed, never absent.
|
|
408
|
+
strandAddrs: []
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Create an open invitation for others to form strands with this party.
|
|
414
|
+
*
|
|
415
|
+
* @param sAppId The sApp to use for formed strands
|
|
416
|
+
* @param expirationMs How long the invitation is valid (ms from now)
|
|
417
|
+
* @param bootstrap Bootstrap addresses for contacting this party's cadre
|
|
418
|
+
* @returns The open invitation to share out-of-band
|
|
419
|
+
*/
|
|
420
|
+
async createOpenInvitation(
|
|
421
|
+
sAppId: string,
|
|
422
|
+
expirationMs: number,
|
|
423
|
+
bootstrap: string[]
|
|
424
|
+
): Promise<OpenInvitation> {
|
|
425
|
+
// Generate a unique token
|
|
426
|
+
const token = `invite-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
427
|
+
const expiration = new Date(Date.now() + expirationMs);
|
|
428
|
+
this.registerMintedInvitation(token, expiration.getTime());
|
|
429
|
+
|
|
430
|
+
log('Created open invitation: %s (expires %s)', token, expiration.toISOString());
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
token,
|
|
434
|
+
sAppId,
|
|
435
|
+
expiration,
|
|
436
|
+
bootstrap
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Remember a token this node minted (or published) so
|
|
442
|
+
* {@link hasOutstandingInvitation} can answer "yes" before the durable
|
|
443
|
+
* `FormationInvite` row is readable — or when there is no durable store at
|
|
444
|
+
* all. `expiresAtMs` may be `Number.POSITIVE_INFINITY` for an invitation that
|
|
445
|
+
* never expires.
|
|
446
|
+
*/
|
|
447
|
+
registerMintedInvitation(token: string, expiresAtMs: number): void {
|
|
448
|
+
this.mintedInvitations.set(token, expiresAtMs);
|
|
449
|
+
log('Registered minted invitation %s (expires %d)', token, expiresAtMs);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Take over every token `previous` minted or published, so a service that replaces it
|
|
454
|
+
* on a node keeps the connection gate open for invitations already handed out.
|
|
455
|
+
*/
|
|
456
|
+
adoptMintedInvitations(previous: StrandSolicitationService): void {
|
|
457
|
+
for (const [token, expiresAtMs] of previous.mintedInvitations) {
|
|
458
|
+
this.mintedInvitations.set(token, expiresAtMs);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Does this node currently expect a stranger — i.e. is at least one open
|
|
464
|
+
* invitation unexpired and not yet fully consumed? Sole input to the
|
|
465
|
+
* control-network connection gate's formation exemption.
|
|
466
|
+
*
|
|
467
|
+
* Two sources, in order:
|
|
468
|
+
*
|
|
469
|
+
* 1. The local mint registry ({@link registerMintedInvitation}), which is
|
|
470
|
+
* in-memory and dies with the process. Expired entries are dropped. With
|
|
471
|
+
* no {@link FormationUsageRecorder} configured there is no consumption
|
|
472
|
+
* oracle, so an unexpired minted token counts as outstanding by
|
|
473
|
+
* construction; with one, a token it reports used is deleted (consumption
|
|
474
|
+
* is permanent, so the registry never reconsiders it).
|
|
475
|
+
* 2. The recorder's optional {@link FormationUsageRecorder.hasOutstandingInvitation},
|
|
476
|
+
* which survives restarts and sees invitations replicated in from siblings.
|
|
477
|
+
*
|
|
478
|
+
* Re-entrant: concurrent inbound connections each call this independently and
|
|
479
|
+
* the only mutation is deleting expired/consumed entries, which is monotonic.
|
|
480
|
+
*/
|
|
481
|
+
async hasOutstandingInvitation(): Promise<boolean> {
|
|
482
|
+
const now = Date.now();
|
|
483
|
+
for (const [token, expiresAtMs] of this.mintedInvitations) {
|
|
484
|
+
if (expiresAtMs <= now) {
|
|
485
|
+
this.mintedInvitations.delete(token);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (!this.formationUsageRecorder) {
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
if (!(await this.formationUsageRecorder.isTokenUsed(token))) {
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
this.mintedInvitations.delete(token);
|
|
495
|
+
}
|
|
496
|
+
return (await this.formationUsageRecorder?.hasOutstandingInvitation?.()) ?? false;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Record that a formation was completed successfully.
|
|
501
|
+
* Called after strand provisioning to track usage.
|
|
502
|
+
*
|
|
503
|
+
* `consent` carries the joiner-signed fields `recordUsage` now requires
|
|
504
|
+
* (see {@link FormationUsageRecorder.recordUsage}); this helper only passes them through.
|
|
505
|
+
*
|
|
506
|
+
* NOTE: this reaches `recordUsage` without passing through
|
|
507
|
+
* `StrandFormationManager.provisionAsResponder`, so `disclosure` skips that path's
|
|
508
|
+
* `MAX_DISCLOSURE_BYTES` cap. Harmless today — its only callers are integration-test mocks
|
|
509
|
+
* that all default to `''` — but if a production caller ever passes a real disclosure here,
|
|
510
|
+
* cap it too (the approver signs those bytes verbatim).
|
|
511
|
+
*/
|
|
512
|
+
async recordFormationComplete(
|
|
513
|
+
token: string,
|
|
514
|
+
peerKey: string,
|
|
515
|
+
strandId: string,
|
|
516
|
+
consent: { usageStampId: string; peerSignature: string },
|
|
517
|
+
disclosure = ''
|
|
518
|
+
): Promise<void> {
|
|
519
|
+
if (this.formationUsageRecorder) {
|
|
520
|
+
await this.formationUsageRecorder.recordUsage({
|
|
521
|
+
token,
|
|
522
|
+
peerKey,
|
|
523
|
+
peerSignature: consent.peerSignature,
|
|
524
|
+
usageStampId: consent.usageStampId,
|
|
525
|
+
strandId,
|
|
526
|
+
disclosure
|
|
527
|
+
});
|
|
528
|
+
log('Recorded formation usage: token=%s strand=%s', token, strandId);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|