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