@serfab/cadre-core 1.9.0 → 1.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/dist/cadre-node.d.ts +148 -17
- package/dist/cadre-node.js +361 -45
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-database.d.ts +105 -14
- package/dist/control-database.js +242 -73
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +6 -6
- package/dist/control-formation-recorder.js +7 -8
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +90 -3
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +21 -15
- package/dist/control-write-retry.js +21 -15
- package/dist/control-write-retry.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/invitation-bootstrap.d.ts +32 -0
- package/dist/invitation-bootstrap.js +30 -0
- package/dist/invitation-bootstrap.js.map +1 -0
- package/dist/link-budget.d.ts +204 -119
- package/dist/link-budget.js +208 -120
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +3 -3
- package/dist/membership-connection-gater.js +3 -3
- package/dist/peer-dial.d.ts +18 -14
- package/dist/peer-dial.js +18 -14
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +3 -3
- package/dist/pending-join-runner.d.ts +186 -0
- package/dist/pending-join-runner.js +649 -0
- package/dist/pending-join-runner.js.map +1 -0
- package/dist/push-fanout.d.ts +1 -1
- package/dist/push-fanout.js +1 -1
- package/dist/seed-bootstrap.d.ts +2 -2
- package/dist/seed-bootstrap.js +1 -1
- package/dist/strand-addr-protocol.js +2 -2
- package/dist/strand-formation-deadlines.d.ts +5 -5
- package/dist/strand-formation-deadlines.js +5 -5
- package/dist/strand-formation-manager.d.ts +18 -4
- package/dist/strand-formation-manager.js +48 -33
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +71 -14
- package/dist/strand-formation-protocol.js +222 -47
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-formation-rejection.d.ts +94 -0
- package/dist/strand-formation-rejection.js +77 -0
- package/dist/strand-formation-rejection.js.map +1 -0
- package/dist/strand-instance-manager.js +4 -1
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-solicitation.d.ts +11 -3
- package/dist/strand-solicitation.js +9 -0
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/types.d.ts +103 -17
- package/dist/types.js +5 -9
- package/dist/types.js.map +1 -1
- package/package.json +22 -22
- package/src/cadre-node.ts +399 -46
- package/src/control-authorization.ts +117 -116
- package/src/control-cohort.ts +1 -1
- package/src/control-database.ts +303 -82
- package/src/control-formation-recorder.ts +8 -9
- package/src/control-schema.ts +90 -3
- package/src/control-write-retry.ts +21 -15
- package/src/index.ts +21 -1
- package/src/invitation-bootstrap.ts +40 -0
- package/src/link-budget.ts +221 -120
- package/src/membership-connection-gater.ts +3 -3
- package/src/peer-dial.ts +18 -14
- package/src/peer-join-backfill.ts +3 -3
- package/src/pending-join-runner.ts +735 -0
- package/src/push-fanout.ts +1 -1
- package/src/seed-bootstrap.ts +2 -2
- package/src/strand-addr-protocol.ts +2 -2
- package/src/strand-formation-deadlines.ts +5 -5
- package/src/strand-formation-manager.ts +62 -37
- package/src/strand-formation-protocol.ts +1036 -802
- package/src/strand-formation-rejection.ts +129 -0
- package/src/strand-instance-manager.ts +4 -1
- package/src/strand-solicitation.ts +17 -4
- package/src/types.ts +99 -17
package/src/cadre-node.ts
CHANGED
|
@@ -36,7 +36,9 @@ import type {
|
|
|
36
36
|
CadrePeerVoucherFields,
|
|
37
37
|
CadrePeerRow,
|
|
38
38
|
PeerAddressRecord,
|
|
39
|
-
ResolveDeviceTokenOpts
|
|
39
|
+
ResolveDeviceTokenOpts,
|
|
40
|
+
PendingJoinRow,
|
|
41
|
+
PendingJoinStatus
|
|
40
42
|
} from './types.js';
|
|
41
43
|
import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS, DEFAULT_CONNECTION_MONITOR } from './types.js';
|
|
42
44
|
import { sign } from '@optimystic/quereus-plugin-crypto';
|
|
@@ -103,10 +105,18 @@ import {
|
|
|
103
105
|
CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS,
|
|
104
106
|
type PeerDialBudget
|
|
105
107
|
} from './peer-dial.js';
|
|
106
|
-
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
108
|
+
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
107
109
|
import { EnrollmentService } from './enrollment.js';
|
|
108
110
|
import { HibernationManager, type HibernationCallbacks } from './hibernation-manager.js';
|
|
109
|
-
import { ControlDatabase, isStrandIdConflict, type RevokedRowRef } from './control-database.js';
|
|
111
|
+
import { ControlDatabase, isPendingJoinConflict, isStrandIdConflict, pendingJoinId, type RevokedRowRef } from './control-database.js';
|
|
112
|
+
import { FormationPostApprovalError } from './strand-formation-rejection.js';
|
|
113
|
+
import {
|
|
114
|
+
PendingJoinRunner,
|
|
115
|
+
membershipInvitesToStage,
|
|
116
|
+
parseStoredDisclosure,
|
|
117
|
+
requestedPendingJoin,
|
|
118
|
+
type PendingJoinFields
|
|
119
|
+
} from './pending-join-runner.js';
|
|
110
120
|
import type { ControlRetryAbandonment } from './control-retry.js';
|
|
111
121
|
import { SeedBootstrapService, type SeedEventCallbacks, type SeedBootstrapConfig } from './seed-bootstrap.js';
|
|
112
122
|
import type { SeedTrustPolicy } from './seed-trust-policy.js';
|
|
@@ -114,6 +124,8 @@ import {
|
|
|
114
124
|
StrandSolicitationService,
|
|
115
125
|
type StrandSolicitationServiceOptions
|
|
116
126
|
} from './strand-solicitation.js';
|
|
127
|
+
import { ControlFormationUsageRecorder } from './control-formation-recorder.js';
|
|
128
|
+
import { selectInvitationSiblingAddrs, type InvitationSibling } from './invitation-bootstrap.js';
|
|
117
129
|
import {
|
|
118
130
|
createMembershipConnectionGater,
|
|
119
131
|
DEFAULT_ENROLLMENT_WINDOW_MS,
|
|
@@ -378,6 +390,15 @@ export class CadreNode implements SAppIdLookup {
|
|
|
378
390
|
private enrollmentService: EnrollmentService;
|
|
379
391
|
private seedBootstrapService: SeedBootstrapService | null = null;
|
|
380
392
|
private strandSolicitationService: StrandSolicitationService | null = null;
|
|
393
|
+
/**
|
|
394
|
+
* The service whose formation handler is registered on the control node. Trails
|
|
395
|
+
* {@link strandSolicitationService} while {@link initializeStrandSolicitation} swaps a new
|
|
396
|
+
* one in: the field is set at once so concurrent callers reuse it, the handler only when
|
|
397
|
+
* its queued swap runs.
|
|
398
|
+
*/
|
|
399
|
+
private registeredSolicitation: StrandSolicitationService | null = null;
|
|
400
|
+
/** Tail of the queued formation-handler swaps ({@link swapFormationResponder}). */
|
|
401
|
+
private solicitationSwaps: Promise<void> = Promise.resolve();
|
|
381
402
|
private strandWakeService: StrandWakeService | null = null;
|
|
382
403
|
/**
|
|
383
404
|
* Control-network strand-address responder. Answers a co-cadre sibling's
|
|
@@ -696,6 +717,22 @@ export class CadreNode implements SAppIdLookup {
|
|
|
696
717
|
*/
|
|
697
718
|
private readonly pendingMembershipInvites = new Map<string, StrandMembershipInvite>();
|
|
698
719
|
|
|
720
|
+
/**
|
|
721
|
+
* Invite keys of the membership invitations this process has staged, by
|
|
722
|
+
* {@link adoptFormationMembershipInvite} or from a `joined` `PendingJoin` row
|
|
723
|
+
* ({@link stageMembershipInvitesFromPendingJoins}), so one the reconciler has settled is not
|
|
724
|
+
* staged again by the next pending-join pass. Same lifetime as
|
|
725
|
+
* {@link pendingMembershipInvites}: a restarted process stages each live one once more, and the
|
|
726
|
+
* reconciler settles it again (spent, or the party already seated).
|
|
727
|
+
*/
|
|
728
|
+
private readonly stagedMembershipInviteKeys = new Set<string>();
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* The pending-join retry loop (`pending-join-runner.ts`): built by {@link start}, stopped by
|
|
732
|
+
* {@link cleanup}. It acts only while this machine is an enrolled owner.
|
|
733
|
+
*/
|
|
734
|
+
private pendingJoinRunner: PendingJoinRunner | null = null;
|
|
735
|
+
|
|
699
736
|
/**
|
|
700
737
|
* Founder launches refused because the strand was founded before the per-party
|
|
701
738
|
* identity split (`PreSplitStrandIdentityError`), keyed by strandId. Read by
|
|
@@ -1266,6 +1303,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1266
1303
|
});
|
|
1267
1304
|
await this.strandAddrService.initialize(this.controlNode);
|
|
1268
1305
|
|
|
1306
|
+
// Answer strand formation for this party from now on. Every machine of a party
|
|
1307
|
+
// holds the replicated FormationInvite/FormationUsage rows, so an always-on one can
|
|
1308
|
+
// check a token for an inviter that is offline. After the control database is up,
|
|
1309
|
+
// so the responder never reads a database that cannot answer yet.
|
|
1310
|
+
await this.installDefaultFormationResponder();
|
|
1311
|
+
|
|
1269
1312
|
// Server-side push-wake fan-out: only when push is configured.
|
|
1270
1313
|
if (this.config.push) {
|
|
1271
1314
|
this.pushFanoutService = this.buildPushFanout(this.config.push);
|
|
@@ -1306,6 +1349,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1306
1349
|
// Schedule self-registration in background
|
|
1307
1350
|
this.scheduleSelfRegistration();
|
|
1308
1351
|
|
|
1352
|
+
// Last: its first pass may dial another party's machines.
|
|
1353
|
+
this.startPendingJoinRunner();
|
|
1354
|
+
|
|
1309
1355
|
} catch (error) {
|
|
1310
1356
|
log('Failed to start CadreNode: %o', error);
|
|
1311
1357
|
// `driveControlRelayReservation` throws AFTER `control:connected` was emitted,
|
|
@@ -1904,11 +1950,15 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1904
1950
|
// replaces the default whole (see DEFAULT_CONNECTION_MONITOR).
|
|
1905
1951
|
connectionMonitor: network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
|
|
1906
1952
|
// The declared link every cadre budget is derived from, stated to Optimystic too so it
|
|
1907
|
-
// derives its own deadlines from the same number: its request
|
|
1908
|
-
//
|
|
1909
|
-
//
|
|
1910
|
-
//
|
|
1953
|
+
// derives its own deadlines from the same number: its request responses, its block
|
|
1954
|
+
// pushes, and libp2p's `inboundUpgradeTimeout`. Always stated, default included:
|
|
1955
|
+
// undeclared, Optimystic keeps LAN deadlines that cannot open a relayed connection at the
|
|
1956
|
+
// link sereus supports (`link-budget.ts`).
|
|
1911
1957
|
linkRoundTripMs: resolveLinkRoundTripMs(network?.linkRoundTripMs),
|
|
1958
|
+
// The three limits on opening a connection (libp2p's per-address and whole-dial limits,
|
|
1959
|
+
// and Optimystic's request dial): Optimystic's derivation from the same link plus the
|
|
1960
|
+
// relay's and the called machine's admission decisions, which run on this dial's clock.
|
|
1961
|
+
...optimysticDialLimits(network?.linkRoundTripMs),
|
|
1912
1962
|
// `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
|
|
1913
1963
|
// `{}` when `network.transports` is set, since the embedder owns transport policy
|
|
1914
1964
|
// then. Spread NEXT to `transports` because the two answer the same question.
|
|
@@ -1991,11 +2041,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1991
2041
|
* `StrandSolicitationService.hasOutstandingInvitation`). A formation
|
|
1992
2042
|
* initiator is another party's peer by design and its token is only
|
|
1993
2043
|
* checkable inside the protocol, so the gate asks the coarser question
|
|
1994
|
-
* "does this node expect a stranger at all?".
|
|
1995
|
-
*
|
|
1996
|
-
*
|
|
1997
|
-
* bring-up) and `formStrand`'s lazy initialization both leave the gate
|
|
1998
|
-
* armed, because neither mints an invitation.
|
|
2044
|
+
* "does this node expect a stranger at all?". REGISTERING the responder
|
|
2045
|
+
* does not suspend stranger denial: every node registers one at
|
|
2046
|
+
* {@link start}, and registering mints no invitation.
|
|
1999
2047
|
*
|
|
2000
2048
|
* Ordering is semantically free (the checks are OR'd) but decides who pays:
|
|
2001
2049
|
* checks 1-3 are in-memory, 4/5 share one control-DB read, and only a peer
|
|
@@ -2027,6 +2075,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2027
2075
|
* layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
|
|
2028
2076
|
* read is safe here — unlike the per-stream gate, which must consult the
|
|
2029
2077
|
* materialized {@link authorizedControlPeers} snapshot instead.
|
|
2078
|
+
* NOTE: check 6 adds a second control read (`hasOutstandingFormationInvite`) for a
|
|
2079
|
+
* stranger with no locally minted invitation in play, on every node now that every
|
|
2080
|
+
* node runs the responder — a relay-enabled storage node included. If stranger
|
|
2081
|
+
* connections to such a node ever arrive fast enough for that read to show, cache
|
|
2082
|
+
* the answer for a few seconds.
|
|
2030
2083
|
* NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
|
|
2031
2084
|
* replicated here is denied until the row converges (typically via the
|
|
2032
2085
|
* owner); either side's next outbound reconcile dial (outbound is never
|
|
@@ -2918,6 +2971,15 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2918
2971
|
* handling, is what keeps a mixed list from taking a whole peer offline.
|
|
2919
2972
|
*/
|
|
2920
2973
|
async resolvePeerAddrs(peerId: string, opts: ResolveOpts = {}): Promise<Multiaddr[]> {
|
|
2974
|
+
return (await this.resolvePeerRecord(peerId, opts))?.addrs ?? [];
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
/**
|
|
2978
|
+
* {@link resolvePeerAddrs} with the record's `UpdatedAt` kept beside the addresses, for a
|
|
2979
|
+
* caller that ranks peers by how recently they published. `null` wherever that returns `[]`
|
|
2980
|
+
* for a failed gate.
|
|
2981
|
+
*/
|
|
2982
|
+
private async resolvePeerRecord(peerId: string, opts: ResolveOpts): Promise<{ addrs: Multiaddr[]; updatedAt: number } | null> {
|
|
2921
2983
|
if (!this.controlDatabase) {
|
|
2922
2984
|
throw new Error('CadreNode must be started before resolving peer addrs');
|
|
2923
2985
|
}
|
|
@@ -2925,14 +2987,14 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2925
2987
|
const record = await this.controlDatabase.queryPeerRecord(peerId);
|
|
2926
2988
|
if (!record) {
|
|
2927
2989
|
log('resolvePeerAddrs: no record for %s', peerId);
|
|
2928
|
-
return
|
|
2990
|
+
return null;
|
|
2929
2991
|
}
|
|
2930
2992
|
|
|
2931
2993
|
// publicKey <-> peerId binding: the stored key must be the one embedded in
|
|
2932
2994
|
// the requested Ed25519 peer id (also rejects a non-Ed25519 / missing key).
|
|
2933
2995
|
if (!record.publicKey || ed25519PublicKeyB64FromPeerId(peerId) !== record.publicKey) {
|
|
2934
2996
|
log('resolvePeerAddrs: publicKey does not match peerId for %s', peerId);
|
|
2935
|
-
return
|
|
2997
|
+
return null;
|
|
2936
2998
|
}
|
|
2937
2999
|
|
|
2938
3000
|
// Self-signature over (peerId, addrs, updatedAt).
|
|
@@ -2943,14 +3005,14 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2943
3005
|
log('resolvePeerAddrs: signature verification failed for %s (updatedAt=%d, addrs=%o, sig=%s)',
|
|
2944
3006
|
peerId, record.updatedAt, record.addrs,
|
|
2945
3007
|
record.sig ? `${record.sig.slice(0, 16)}…` : '(empty)');
|
|
2946
|
-
return
|
|
3008
|
+
return null;
|
|
2947
3009
|
}
|
|
2948
3010
|
|
|
2949
3011
|
// Freshness: never hand back a dead relay reservation.
|
|
2950
3012
|
const maxAgeMs = opts.maxAgeMs ?? DEFAULT_PEER_RECORD_MAX_AGE_MS;
|
|
2951
3013
|
if (!isPeerRecordFresh(record.updatedAt, maxAgeMs, Date.now())) {
|
|
2952
3014
|
log('resolvePeerAddrs: record for %s is stale (updatedAt=%d, maxAgeMs=%d)', peerId, record.updatedAt, maxAgeMs);
|
|
2953
|
-
return
|
|
3015
|
+
return null;
|
|
2954
3016
|
}
|
|
2955
3017
|
|
|
2956
3018
|
// Pluggable trust gate (defaults to current-member).
|
|
@@ -2963,7 +3025,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2963
3025
|
});
|
|
2964
3026
|
if (!trusted) {
|
|
2965
3027
|
log('resolvePeerAddrs: trust policy rejected %s', peerId);
|
|
2966
|
-
return
|
|
3028
|
+
return null;
|
|
2967
3029
|
}
|
|
2968
3030
|
|
|
2969
3031
|
// Order signaling-first (the on-record order was what we verified above),
|
|
@@ -2974,7 +3036,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2974
3036
|
if (opts.signalingOnly) {
|
|
2975
3037
|
addrs = addrs.filter(isSignalingAddr);
|
|
2976
3038
|
}
|
|
2977
|
-
return this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId);
|
|
3039
|
+
return { addrs: this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId), updatedAt: record.updatedAt };
|
|
2978
3040
|
}
|
|
2979
3041
|
|
|
2980
3042
|
/**
|
|
@@ -3535,15 +3597,18 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3535
3597
|
*
|
|
3536
3598
|
* The list may name transports this node cannot dial (a lent node reports TCP
|
|
3537
3599
|
* and `/ws` addresses to a phone that dials WebSockets only). That needs no
|
|
3538
|
-
* filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.
|
|
3600
|
+
* filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.3.11)
|
|
3539
3601
|
* rejects an address no transport can dial before touching the network, so
|
|
3540
3602
|
* such an address costs a log line.
|
|
3541
3603
|
*
|
|
3542
3604
|
* The list is NOT handed to one `dial()`. libp2p tries a multi-address dial's
|
|
3543
|
-
* addresses one after another under a single deadline,
|
|
3544
|
-
*
|
|
3545
|
-
*
|
|
3546
|
-
*
|
|
3605
|
+
* addresses one after another under a single deadline, and sorts loopback
|
|
3606
|
+
* addresses last — so one or two addresses that never answer use up most of
|
|
3607
|
+
* the deadline before the address that works is tried. libp2p 3.3's
|
|
3608
|
+
* per-address `addressDialTimeout` does not prevent that: cadre sizes it for a
|
|
3609
|
+
* cold relayed open through two admission decisions (`optimysticDialLimits`,
|
|
3610
|
+
* 39 s at the default declared link).
|
|
3611
|
+
* {@link dialPeerAddrs} dials each address on its own time limit instead.
|
|
3547
3612
|
*/
|
|
3548
3613
|
private async resolveControlDialAddrs(peerId: string, resolved: Multiaddr[]): Promise<Multiaddr[]> {
|
|
3549
3614
|
if (resolved.length > 0) {
|
|
@@ -3932,6 +3997,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3932
3997
|
|
|
3933
3998
|
// 4. In-session pending owner authorize writes.
|
|
3934
3999
|
await this.drainPendingPeerWrites();
|
|
4000
|
+
|
|
4001
|
+
// 5. `PendingJoin` rows this process wrote while alone. Their tombstones went out in step 1.
|
|
4002
|
+
await this.pendingJoinRunner?.reissueWritesMadeAlone();
|
|
3935
4003
|
}
|
|
3936
4004
|
|
|
3937
4005
|
/**
|
|
@@ -4611,6 +4679,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4611
4679
|
// it is stopped here.
|
|
4612
4680
|
this.relayReserveSupervisor?.stop();
|
|
4613
4681
|
this.relayReserveSupervisor = null;
|
|
4682
|
+
// Before anything it reads is torn down. An attempt in flight is not awaited (NOTE at
|
|
4683
|
+
// `PendingJoinRunner.stop`).
|
|
4684
|
+
this.pendingJoinRunner?.stop();
|
|
4685
|
+
this.pendingJoinRunner = null;
|
|
4614
4686
|
// Drop the rest of the relay-reservation posture: a torn-down node holds no
|
|
4615
4687
|
// reservation, so neither a restarted instance nor a caller inspecting a failed
|
|
4616
4688
|
// start reports the previous attempt's `reserved`.
|
|
@@ -4658,11 +4730,14 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4658
4730
|
this.pushFanoutService = null;
|
|
4659
4731
|
}
|
|
4660
4732
|
|
|
4661
|
-
// Unregister
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4733
|
+
// Unregister the formation responder once any queued swap has run, so no swap
|
|
4734
|
+
// registers a handler after this. A restarted node installs a fresh one.
|
|
4735
|
+
await this.solicitationSwaps;
|
|
4736
|
+
if (this.registeredSolicitation && this.controlNode) {
|
|
4737
|
+
await this.registeredSolicitation.unregisterResponder(this.controlNode);
|
|
4665
4738
|
}
|
|
4739
|
+
this.registeredSolicitation = null;
|
|
4740
|
+
this.strandSolicitationService = null;
|
|
4666
4741
|
|
|
4667
4742
|
// Stop strand watcher
|
|
4668
4743
|
if (this.strandWatcher) {
|
|
@@ -6258,9 +6333,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6258
6333
|
* caller runs the drive regardless).
|
|
6259
6334
|
*
|
|
6260
6335
|
* NOTE: against a relay that is DOWN this hook costs up to two strand-addr
|
|
6261
|
-
* timeouts (dial by peer id, then by addr;
|
|
6336
|
+
* timeouts (dial by peer id, then by addr; 28.5 s each at the default declared link round trip)
|
|
6262
6337
|
* before the reservation drive even starts, so one failed re-drive holds the supervisor
|
|
6263
|
-
* `driving` for those
|
|
6338
|
+
* `driving` for those 57 s plus the drive's own deadline — 75 s at the default, and longer on
|
|
6264
6339
|
* a host that declared a slower one (`link-budget.ts`).
|
|
6265
6340
|
* Bounded and harmless while the relay is unreachable anyway; if recovery
|
|
6266
6341
|
* latency after a relay comes back ever matters, skip the announce when the
|
|
@@ -7253,6 +7328,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7253
7328
|
...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
|
|
7254
7329
|
}), this.controlNode, this.controlDatabase);
|
|
7255
7330
|
log('Seed bootstrap service initialized');
|
|
7331
|
+
// Embedders wire the owner key after start(), so the runner's first pass usually found
|
|
7332
|
+
// this machine not yet an owner; without this it would wait a full poll interval.
|
|
7333
|
+
this.pendingJoinRunner?.kick();
|
|
7256
7334
|
}
|
|
7257
7335
|
|
|
7258
7336
|
/**
|
|
@@ -7973,20 +8051,33 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7973
8051
|
// ============================================================================
|
|
7974
8052
|
|
|
7975
8053
|
/**
|
|
7976
|
-
*
|
|
7977
|
-
*
|
|
8054
|
+
* Replace this node's strand solicitation service — the formation responder every node
|
|
8055
|
+
* installs at {@link start}, and the initiator side of {@link formStrand}. Optional: call it
|
|
8056
|
+
* only to customize the responder (an approver, a provisioner, formation deadlines). The
|
|
8057
|
+
* new service's handler replaces the previous one's on the control node, and invitations
|
|
8058
|
+
* the previous service minted stay outstanding.
|
|
8059
|
+
*
|
|
8060
|
+
* Without `options.formationUsageRecorder`, tokens are checked against this party's
|
|
8061
|
+
* replicated `FormationInvite`/`FormationUsage` rows ({@link ControlFormationUsageRecorder}).
|
|
8062
|
+
* A responder that accepts every token is reachable only by constructing
|
|
8063
|
+
* {@link StrandSolicitationService} directly.
|
|
7978
8064
|
*
|
|
7979
8065
|
* @param options - Configuration for the solicitation service
|
|
7980
8066
|
*/
|
|
7981
8067
|
async initializeStrandSolicitation(options?: StrandSolicitationServiceOptions): Promise<void> {
|
|
7982
|
-
|
|
8068
|
+
const controlNode = this.controlNode;
|
|
8069
|
+
const controlDatabase = this.controlDatabase;
|
|
8070
|
+
if (!controlNode || !controlDatabase) {
|
|
7983
8071
|
throw new Error('CadreNode must be started before initializing strand solicitation');
|
|
7984
8072
|
}
|
|
7985
8073
|
|
|
7986
8074
|
const service = new StrandSolicitationService({
|
|
7987
8075
|
...options,
|
|
8076
|
+
formationUsageRecorder: options?.formationUsageRecorder ?? new ControlFormationUsageRecorder(controlDatabase),
|
|
7988
8077
|
partyId: this.config.controlNetwork.partyId,
|
|
7989
|
-
|
|
8078
|
+
// Read per formation: the responder is installed during start, before a relay
|
|
8079
|
+
// reservation gives a relay-only node any address, and addresses change after.
|
|
8080
|
+
cadrePeerAddrs: () => this.getMultiaddrs(),
|
|
7990
8081
|
// Every formation deadline derives from this node's declared link, like every other
|
|
7991
8082
|
// dial budget here; a caller's own declaration on the formation config still wins.
|
|
7992
8083
|
formationConfig: {
|
|
@@ -8004,24 +8095,61 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8004
8095
|
this.issueStrandMembershipInvite(strandId, signal)
|
|
8005
8096
|
});
|
|
8006
8097
|
|
|
8007
|
-
// Set before the registration is awaited, so a concurrent createOpenInvitation /
|
|
8008
|
-
// formStrand uses this service instead of building a second one whose handler
|
|
8009
|
-
// would collide; a failed registration puts the previous service back.
|
|
8010
|
-
// NOTE: same stray-handler caveat as installSeedBootstrapService — a failed
|
|
8011
|
-
// peer-store merge leaves the formation handler registered, so a retry is a duplicate.
|
|
8012
8098
|
const previous = this.strandSolicitationService;
|
|
8099
|
+
if (previous) {
|
|
8100
|
+
service.adoptMintedInvitations(previous);
|
|
8101
|
+
}
|
|
8102
|
+
// Set before the swap is awaited, so a concurrent createOpenInvitation / formStrand
|
|
8103
|
+
// uses this service instead of building another.
|
|
8013
8104
|
this.strandSolicitationService = service;
|
|
8105
|
+
const swap = this.solicitationSwaps.then(() => this.swapFormationResponder(service, controlNode));
|
|
8106
|
+
// The queue only orders swaps; a failed one rejects this call's own await below.
|
|
8107
|
+
this.solicitationSwaps = swap.catch(() => undefined);
|
|
8108
|
+
await swap;
|
|
8109
|
+
log('Strand solicitation service initialized');
|
|
8110
|
+
}
|
|
8111
|
+
|
|
8112
|
+
/**
|
|
8113
|
+
* Answer formation from start. Log-and-continue: a node that cannot is still useful, and
|
|
8114
|
+
* {@link createOpenInvitation} / {@link formStrand} install the responder when it is missing.
|
|
8115
|
+
*/
|
|
8116
|
+
private async installDefaultFormationResponder(): Promise<void> {
|
|
8117
|
+
try {
|
|
8118
|
+
await this.initializeStrandSolicitation();
|
|
8119
|
+
} catch (error) {
|
|
8120
|
+
log('Formation responder not installed at start; createOpenInvitation/formStrand will retry: %o', error);
|
|
8121
|
+
}
|
|
8122
|
+
}
|
|
8123
|
+
|
|
8124
|
+
/**
|
|
8125
|
+
* Move the control node's formation handler from the registered service to `service`.
|
|
8126
|
+
* Queued by {@link initializeStrandSolicitation} so swaps run one at a time: libp2p allows
|
|
8127
|
+
* one handler per protocol id, and two overlapping swaps would each find the other's
|
|
8128
|
+
* handler in the way. A failed swap registers the previous service again, points
|
|
8129
|
+
* {@link strandSolicitationService} back at it unless a later call has replaced it, and
|
|
8130
|
+
* rethrows.
|
|
8131
|
+
*
|
|
8132
|
+
* NOTE: same stray-handler caveat as {@link installSeedBootstrapService} — a failed
|
|
8133
|
+
* peer-store merge leaves the formation handler registered, so the restore below (and a
|
|
8134
|
+
* retry) rejects as a duplicate.
|
|
8135
|
+
*/
|
|
8136
|
+
private async swapFormationResponder(service: StrandSolicitationService, controlNode: Libp2p): Promise<void> {
|
|
8137
|
+
const previous = this.registeredSolicitation;
|
|
8014
8138
|
try {
|
|
8015
|
-
await
|
|
8139
|
+
await previous?.unregisterResponder(controlNode);
|
|
8140
|
+
await service.registerResponder(controlNode);
|
|
8141
|
+
this.registeredSolicitation = service;
|
|
8016
8142
|
} catch (error) {
|
|
8017
8143
|
if (this.strandSolicitationService === service) this.strandSolicitationService = previous;
|
|
8144
|
+
await previous?.registerResponder(controlNode).catch((restoreError: unknown) =>
|
|
8145
|
+
log('Re-registering the previous formation responder failed; none answers until the next initializeStrandSolicitation: %o', restoreError));
|
|
8018
8146
|
throw error;
|
|
8019
8147
|
}
|
|
8020
|
-
log('Strand solicitation service initialized');
|
|
8021
8148
|
}
|
|
8022
8149
|
|
|
8023
8150
|
/**
|
|
8024
|
-
* Get the strand solicitation service (for advanced use)
|
|
8151
|
+
* Get the strand solicitation service (for advanced use). Non-null on a started node unless
|
|
8152
|
+
* the install at {@link start} failed.
|
|
8025
8153
|
*/
|
|
8026
8154
|
getStrandSolicitationService(): StrandSolicitationService | null {
|
|
8027
8155
|
return this.strandSolicitationService;
|
|
@@ -8030,20 +8158,25 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8030
8158
|
/**
|
|
8031
8159
|
* Create an open invitation for others to form strands with this party.
|
|
8032
8160
|
*
|
|
8161
|
+
* The invitation's bootstrap list names this machine first (it is the one most likely to
|
|
8162
|
+
* run the host strand), then a few of the party's other machines
|
|
8163
|
+
* ({@link siblingInvitationAddrs}), so a joiner can still form while this one is offline.
|
|
8164
|
+
*
|
|
8033
8165
|
* @param sAppId - The sApp to use for formed strands
|
|
8034
8166
|
* @param expirationMs - How long the invitation is valid (ms from now)
|
|
8035
8167
|
* @returns The open invitation to share out-of-band
|
|
8168
|
+
* @throws when neither this machine nor any other machine of the party has an address
|
|
8036
8169
|
*/
|
|
8037
8170
|
async createOpenInvitation(
|
|
8038
8171
|
sAppId: string,
|
|
8039
8172
|
expirationMs: number = 24 * 60 * 60 * 1000 // 24 hours default
|
|
8040
8173
|
): Promise<OpenInvitation> {
|
|
8041
8174
|
if (!this.strandSolicitationService) {
|
|
8042
|
-
//
|
|
8175
|
+
// Only when the install at start failed.
|
|
8043
8176
|
await this.initializeStrandSolicitation();
|
|
8044
8177
|
}
|
|
8045
8178
|
|
|
8046
|
-
const bootstrap = this.getMultiaddrs();
|
|
8179
|
+
const bootstrap = [...this.getMultiaddrs(), ...await this.siblingInvitationAddrs()];
|
|
8047
8180
|
if (bootstrap.length === 0) {
|
|
8048
8181
|
throw new Error('No multiaddrs available for invitation');
|
|
8049
8182
|
}
|
|
@@ -8056,11 +8189,44 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8056
8189
|
}
|
|
8057
8190
|
|
|
8058
8191
|
/**
|
|
8059
|
-
*
|
|
8192
|
+
* Addresses of the party's other machines for an invitation's bootstrap list
|
|
8193
|
+
* ({@link selectInvitationSiblingAddrs} picks which). The source is each authorized
|
|
8194
|
+
* member's signed `CadrePeer` record ({@link resolvePeerRecord}), so a machine whose record
|
|
8195
|
+
* is missing, stale or untrusted is left out; live connections only order them.
|
|
8196
|
+
*
|
|
8197
|
+
* Best-effort: a failed read leaves the invitation naming this machine alone, which is what
|
|
8198
|
+
* it named before siblings were added.
|
|
8199
|
+
*/
|
|
8200
|
+
private async siblingInvitationAddrs(): Promise<string[]> {
|
|
8201
|
+
const controlNode = this.controlNode;
|
|
8202
|
+
if (!controlNode || !this.controlDatabase) {
|
|
8203
|
+
return [];
|
|
8204
|
+
}
|
|
8205
|
+
try {
|
|
8206
|
+
const siblings: InvitationSibling[] = [];
|
|
8207
|
+
for (const { peerId } of await this.listAuthorizedMembers()) {
|
|
8208
|
+
const resolved = await this.resolvePeerRecord(peerId, {});
|
|
8209
|
+
if (resolved && resolved.addrs.length > 0) {
|
|
8210
|
+
siblings.push({ peerId, updatedAt: resolved.updatedAt, addrs: resolved.addrs.map(String) });
|
|
8211
|
+
}
|
|
8212
|
+
}
|
|
8213
|
+
const connected = new Set(controlNode.getConnections().map((c) => c.remotePeer.toString()));
|
|
8214
|
+
return selectInvitationSiblingAddrs(siblings, connected);
|
|
8215
|
+
} catch (error) {
|
|
8216
|
+
log('createOpenInvitation: resolving the other machines failed; the invitation names only this one: %o', error);
|
|
8217
|
+
return [];
|
|
8218
|
+
}
|
|
8219
|
+
}
|
|
8220
|
+
|
|
8221
|
+
/**
|
|
8222
|
+
* Form a strand with a responder via an open invitation: one attempt, from this machine, with
|
|
8223
|
+
* nothing recorded party-wide. {@link requestJoin} is the path that keeps trying.
|
|
8060
8224
|
*
|
|
8061
8225
|
* @param invitation - The open invitation received out-of-band
|
|
8062
8226
|
* @param disclosure - Identity/context information to share with the responder
|
|
8063
8227
|
* @returns The member key and strand info if successful
|
|
8228
|
+
* @throws `FormationRejectedError` or `FormationUnreachableError` when the formation fails, and
|
|
8229
|
+
* `FormationPostApprovalError` when it was approved and a step on this machine then failed
|
|
8064
8230
|
*/
|
|
8065
8231
|
async formStrand(
|
|
8066
8232
|
invitation: OpenInvitation,
|
|
@@ -8091,6 +8257,190 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8091
8257
|
return result;
|
|
8092
8258
|
}
|
|
8093
8259
|
|
|
8260
|
+
/**
|
|
8261
|
+
* Ask to join through `invitation` and have the party keep trying until the join works, the
|
|
8262
|
+
* invitation is used up or expires, or the request is dismissed: across restarts, and from
|
|
8263
|
+
* any owner machine of the party (`docs/strands.md` → "Joining while the inviter is
|
|
8264
|
+
* offline"). Records a party-wide `PendingJoin` row, runs one attempt on this machine at
|
|
8265
|
+
* once, and returns the status after it: `'joined'` when the inviter answered yes, `'waiting'`
|
|
8266
|
+
* when it was unreachable or not ready, `'failed'` when it refused for good.
|
|
8267
|
+
*
|
|
8268
|
+
* Asking again for an invitation whose request is still pending adopts that request as it
|
|
8269
|
+
* is; asking again after it finished starts a fresh one. Later changes arrive as
|
|
8270
|
+
* `pendingJoin:changed`. A joined strand is offered through `strand:discovered` like any
|
|
8271
|
+
* {@link formStrand} join. Do not also call {@link formStrand} with the same invitation:
|
|
8272
|
+
* nothing coordinates the two, and one of them gets `token-spent`.
|
|
8273
|
+
*
|
|
8274
|
+
* @throws on a machine that is not an enrolled owner (the row is owner-signed), and on an
|
|
8275
|
+
* invitation already past its expiration
|
|
8276
|
+
*/
|
|
8277
|
+
async requestJoin(invitation: OpenInvitation, disclosure: StrandFormationDisclosure = {}): Promise<PendingJoinStatus> {
|
|
8278
|
+
const { database, runner } = this.requirePendingJoins();
|
|
8279
|
+
const signer = await this.requireOwnerSigner(
|
|
8280
|
+
'requestJoin needs an enrolled owner machine of this party, because the party-wide join request is ' +
|
|
8281
|
+
'owner-signed. formStrand(invitation, disclosure) joins once from this machine, with no retries.'
|
|
8282
|
+
);
|
|
8283
|
+
const now = Date.now();
|
|
8284
|
+
const expiresAt = invitation.expiration.getTime();
|
|
8285
|
+
if (!Number.isFinite(expiresAt)) {
|
|
8286
|
+
throw new Error('The invitation carries no valid expiration');
|
|
8287
|
+
}
|
|
8288
|
+
if (now >= expiresAt) {
|
|
8289
|
+
throw new Error(`The invitation expired at ${invitation.expiration.toISOString()}; ask the inviter for a fresh one`);
|
|
8290
|
+
}
|
|
8291
|
+
const requested = requestedPendingJoin(
|
|
8292
|
+
pendingJoinId(invitation.token), this.encodeInvitation(invitation), disclosure, expiresAt, now);
|
|
8293
|
+
const row = await this.recordJoinRequest(database, requested, signer);
|
|
8294
|
+
log('requestJoin: pending join %s recorded; attempting now', row.Id);
|
|
8295
|
+
return runner.attemptNow(row);
|
|
8296
|
+
}
|
|
8297
|
+
|
|
8298
|
+
/**
|
|
8299
|
+
* Every join asked for with {@link requestJoin} and not dismissed, pending or finished, as this
|
|
8300
|
+
* machine sees it (see `PendingJoinStatus` for which states are machine-local).
|
|
8301
|
+
*/
|
|
8302
|
+
async listPendingJoins(): Promise<PendingJoinStatus[]> {
|
|
8303
|
+
const { database, runner } = this.requirePendingJoins();
|
|
8304
|
+
return (await database.queryPendingJoins()).map((row) => runner.statusOf(row));
|
|
8305
|
+
}
|
|
8306
|
+
|
|
8307
|
+
/**
|
|
8308
|
+
* Remove a join request party-wide. On a pending request this cancels it: every owner machine
|
|
8309
|
+
* stops at its next read, and an attempt already running does not record its outcome (a join
|
|
8310
|
+
* it made stays on that machine, as any {@link formStrand} join does). Owner machines only.
|
|
8311
|
+
* Resolves `false` when there was no such request.
|
|
8312
|
+
*/
|
|
8313
|
+
async dismissPendingJoin(id: string): Promise<boolean> {
|
|
8314
|
+
const { database, runner } = this.requirePendingJoins();
|
|
8315
|
+
const { ownerKey, signMessage } = await this.requireOwnerSigner(
|
|
8316
|
+
'dismissPendingJoin needs an enrolled owner machine of this party: the removal is owner-signed');
|
|
8317
|
+
const removed = await database.deletePendingJoin(id, ownerKey, signMessage);
|
|
8318
|
+
runner.forgetRow(id);
|
|
8319
|
+
return removed;
|
|
8320
|
+
}
|
|
8321
|
+
|
|
8322
|
+
private requirePendingJoins(): { database: ControlDatabase; runner: PendingJoinRunner } {
|
|
8323
|
+
if (!this._running || !this.controlDatabase || !this.pendingJoinRunner) {
|
|
8324
|
+
throw new Error('CadreNode must be started before using pending joins');
|
|
8325
|
+
}
|
|
8326
|
+
return { database: this.controlDatabase, runner: this.pendingJoinRunner };
|
|
8327
|
+
}
|
|
8328
|
+
|
|
8329
|
+
/** This machine's owner key and signer for an owner-signed control write, or a throw carrying `refusal`. */
|
|
8330
|
+
private async requireOwnerSigner(refusal: string): Promise<{ ownerKey: string; signMessage: (message: Uint8Array) => string }> {
|
|
8331
|
+
const signingKey = await this.enrolledOwnerSigningKey();
|
|
8332
|
+
if (!signingKey) {
|
|
8333
|
+
throw new Error(refusal);
|
|
8334
|
+
}
|
|
8335
|
+
return { ownerKey: signingKey.publicKeyB64, signMessage: signMessageWith(signingKey.privateKeyB64) };
|
|
8336
|
+
}
|
|
8337
|
+
|
|
8338
|
+
/**
|
|
8339
|
+
* Write a join request's row, or adopt the party's existing row for the same invitation: a
|
|
8340
|
+
* pending one as it is, a finished one replaced by `requested` (the user asked again).
|
|
8341
|
+
*/
|
|
8342
|
+
private async recordJoinRequest(
|
|
8343
|
+
database: ControlDatabase,
|
|
8344
|
+
requested: PendingJoinFields,
|
|
8345
|
+
{ ownerKey, signMessage }: { ownerKey: string; signMessage: (message: Uint8Array) => string }
|
|
8346
|
+
): Promise<PendingJoinRow> {
|
|
8347
|
+
let written: PendingJoinRow;
|
|
8348
|
+
try {
|
|
8349
|
+
written = await database.insertPendingJoin(requested, ownerKey, signMessage);
|
|
8350
|
+
} catch (error) {
|
|
8351
|
+
if (!isPendingJoinConflict(error)) {
|
|
8352
|
+
throw error;
|
|
8353
|
+
}
|
|
8354
|
+
const existing = await database.queryPendingJoin(requested.Id);
|
|
8355
|
+
if (!existing) {
|
|
8356
|
+
throw new Error(
|
|
8357
|
+
`A removed join request for this invitation (${requested.Id}) is still held on this machine until its ` +
|
|
8358
|
+
'clean-up runs, which needs a connection to the party. Ask again once connected.',
|
|
8359
|
+
{ cause: error }
|
|
8360
|
+
);
|
|
8361
|
+
}
|
|
8362
|
+
if (existing.Outcome === null) {
|
|
8363
|
+
return existing;
|
|
8364
|
+
}
|
|
8365
|
+
written = await database.replacePendingJoin(existing.StampId, requested, ownerKey, signMessage);
|
|
8366
|
+
}
|
|
8367
|
+
this.pendingJoinRunner?.noteWritten(written);
|
|
8368
|
+
return written;
|
|
8369
|
+
}
|
|
8370
|
+
|
|
8371
|
+
/** Build and start the pending-join retry loop over this node (see {@link pendingJoinRunner}). */
|
|
8372
|
+
private startPendingJoinRunner(): void {
|
|
8373
|
+
const database = (): ControlDatabase => {
|
|
8374
|
+
if (!this.controlDatabase) {
|
|
8375
|
+
throw new Error('CadreNode is stopped: no control database for pending joins');
|
|
8376
|
+
}
|
|
8377
|
+
return this.controlDatabase;
|
|
8378
|
+
};
|
|
8379
|
+
const outcomeSigner = (): Promise<{ ownerKey: string; signMessage: (message: Uint8Array) => string }> =>
|
|
8380
|
+
this.requireOwnerSigner('Cannot record a pending join outcome: this machine is not an enrolled owner of the party');
|
|
8381
|
+
this.pendingJoinRunner = new PendingJoinRunner({
|
|
8382
|
+
selfId: this.controlNode!.peerId.toString(),
|
|
8383
|
+
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
8384
|
+
isOwner: async () => (await this.enrolledOwnerSigningKey()) !== null,
|
|
8385
|
+
// NOTE: one owner-key read and one `PendingJoin` read per pass (30 s); for a party that never
|
|
8386
|
+
// asked for a join the second reads a never-written block, which consults the cohort. If it
|
|
8387
|
+
// shows up in a device profile, poll more slowly while the table reads empty.
|
|
8388
|
+
readRows: () => database().queryPendingJoins(),
|
|
8389
|
+
readRow: (id) => database().queryPendingJoin(id),
|
|
8390
|
+
attempt: (row) => this.formStrand(this.pendingJoinInvitation(row), parseStoredDisclosure(row)),
|
|
8391
|
+
replace: async (expectedStampId, next) => {
|
|
8392
|
+
const { ownerKey, signMessage } = await outcomeSigner();
|
|
8393
|
+
return database().replacePendingJoin(expectedStampId, next, ownerKey, signMessage);
|
|
8394
|
+
},
|
|
8395
|
+
remove: async (id) => {
|
|
8396
|
+
const { ownerKey, signMessage } = await outcomeSigner();
|
|
8397
|
+
return database().deletePendingJoin(id, ownerKey, signMessage);
|
|
8398
|
+
},
|
|
8399
|
+
isAlone: () => this.committedAlone(),
|
|
8400
|
+
sAppIdOf: (row) => this.pendingJoinSAppId(row),
|
|
8401
|
+
observeRows: (rows) => this.stageMembershipInvitesFromPendingJoins(rows),
|
|
8402
|
+
emit: (status) => this.emit('pendingJoin:changed', status),
|
|
8403
|
+
});
|
|
8404
|
+
this.pendingJoinRunner.start();
|
|
8405
|
+
}
|
|
8406
|
+
|
|
8407
|
+
/** The invitation a `PendingJoin` row stores. A failure names the row, never the invitation, which is a bearer credential. */
|
|
8408
|
+
private pendingJoinInvitation(row: PendingJoinRow): OpenInvitation {
|
|
8409
|
+
try {
|
|
8410
|
+
return this.decodeInvitation(row.Invitation);
|
|
8411
|
+
} catch {
|
|
8412
|
+
throw new Error(`PendingJoin ${row.Id}: the stored invitation does not decode`);
|
|
8413
|
+
}
|
|
8414
|
+
}
|
|
8415
|
+
|
|
8416
|
+
private pendingJoinSAppId(row: PendingJoinRow): string {
|
|
8417
|
+
try {
|
|
8418
|
+
return this.decodeInvitation(row.Invitation).sAppId;
|
|
8419
|
+
} catch {
|
|
8420
|
+
log('PendingJoin %s: the stored invitation does not decode; reporting no sApp', row.Id);
|
|
8421
|
+
return '';
|
|
8422
|
+
}
|
|
8423
|
+
}
|
|
8424
|
+
|
|
8425
|
+
/**
|
|
8426
|
+
* Stage the membership invitation of each `joined` row this process has not staged yet, so a
|
|
8427
|
+
* closed strand joined on another owner machine is seated by whichever machine launches it
|
|
8428
|
+
* first; the party key it admits is the replicated `StrandPartyKey` row the finishing machine
|
|
8429
|
+
* seated. A strand that already has an invitation staged keeps it, for the reconciler to
|
|
8430
|
+
* settle first.
|
|
8431
|
+
*/
|
|
8432
|
+
private stageMembershipInvitesFromPendingJoins(rows: readonly PendingJoinRow[]): void {
|
|
8433
|
+
for (const { rowId, strandId, invite } of membershipInvitesToStage(rows, this.stagedMembershipInviteKeys, Date.now())) {
|
|
8434
|
+
this.stagedMembershipInviteKeys.add(invite.inviteKey);
|
|
8435
|
+
if (this.pendingMembershipInvites.has(strandId)) {
|
|
8436
|
+
continue;
|
|
8437
|
+
}
|
|
8438
|
+
this.pendingMembershipInvites.set(strandId, invite);
|
|
8439
|
+
log('pending join %s: staged its membership invitation for strand %s', rowId, strandId);
|
|
8440
|
+
this.strandManager.notifyMembershipInviteStaged(strandId);
|
|
8441
|
+
}
|
|
8442
|
+
}
|
|
8443
|
+
|
|
8094
8444
|
/**
|
|
8095
8445
|
* Remember the strand a formation just joined (see {@link joinedStrandStore}), so it
|
|
8096
8446
|
* is re-offered after a restart even when the app is killed before its `addStrand`, and
|
|
@@ -8111,7 +8461,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8111
8461
|
joinedAt: Date.now()
|
|
8112
8462
|
});
|
|
8113
8463
|
} catch (error) {
|
|
8114
|
-
throw new
|
|
8464
|
+
throw new FormationPostApprovalError(
|
|
8465
|
+
result.strandId,
|
|
8115
8466
|
`Formation for strand ${result.strandId} was approved (its one-time token is spent), but ` +
|
|
8116
8467
|
'remembering the join in this node\'s joined-strand store failed, so the strand would not ' +
|
|
8117
8468
|
'come back after a restart. Fix the store (usually the configured keyStore), then redeem a ' +
|
|
@@ -8147,7 +8498,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8147
8498
|
try {
|
|
8148
8499
|
await this.ensureStrandPartyKey(strandId);
|
|
8149
8500
|
} catch (error) {
|
|
8150
|
-
throw new
|
|
8501
|
+
throw new FormationPostApprovalError(
|
|
8502
|
+
strandId,
|
|
8151
8503
|
`Formation for strand ${strandId} was approved (its one-time token is spent), but ` +
|
|
8152
8504
|
'persisting this party\'s membership identity (StrandPartyKey) failed — the joiner ' +
|
|
8153
8505
|
'cannot become a member without it. Fix the underlying cause, then redeem a fresh ' +
|
|
@@ -8156,6 +8508,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8156
8508
|
);
|
|
8157
8509
|
}
|
|
8158
8510
|
this.pendingMembershipInvites.set(strandId, invite);
|
|
8511
|
+
this.stagedMembershipInviteKeys.add(invite.inviteKey);
|
|
8159
8512
|
log('formStrand: staged membership invitation for strand %s (party key persisted)', strandId);
|
|
8160
8513
|
this.strandManager.notifyMembershipInviteStaged(strandId);
|
|
8161
8514
|
}
|