@serfab/cadre-core 1.8.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 +301 -121
- package/dist/cadre-node.js +588 -244
- 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 +7 -7
- package/dist/index.js +16 -13
- 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/node-local-snapshot.d.ts +6 -4
- package/dist/node-local-snapshot.js +7 -3
- package/dist/node-local-snapshot.js.map +1 -1
- 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 -12
- package/dist/peer-join-backfill.js +2 -3
- package/dist/peer-join-backfill.js.map +1 -1
- 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 -25
- package/dist/strand-formation-protocol.js +225 -51
- 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-fret-addrs.d.ts +54 -0
- package/dist/strand-fret-addrs.js +108 -0
- package/dist/strand-fret-addrs.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +9 -38
- package/dist/strand-instance-manager.js +17 -74
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-network-state-file.d.ts +12 -0
- package/dist/strand-network-state-file.js +38 -0
- package/dist/strand-network-state-file.js.map +1 -0
- package/dist/strand-network-state.d.ts +111 -0
- package/dist/strand-network-state.js +191 -0
- package/dist/strand-network-state.js.map +1 -0
- 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 +123 -40
- package/dist/types.js +5 -9
- package/dist/types.js.map +1 -1
- package/package.json +25 -24
- package/src/cadre-node.ts +637 -251
- 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 +36 -56
- package/src/invitation-bootstrap.ts +40 -0
- package/src/link-budget.ts +221 -120
- package/src/membership-connection-gater.ts +3 -3
- package/src/node-local-snapshot.ts +12 -7
- package/src/peer-dial.ts +18 -14
- package/src/peer-join-backfill.ts +5 -6
- 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 -803
- package/src/strand-formation-rejection.ts +129 -0
- package/src/strand-fret-addrs.ts +145 -0
- package/src/strand-instance-manager.ts +24 -97
- package/src/strand-network-state-file.ts +40 -0
- package/src/strand-network-state.ts +243 -0
- package/src/strand-solicitation.ts +17 -4
- package/src/types.ts +119 -40
- package/dist/strand-peer-book-file.d.ts +0 -12
- package/dist/strand-peer-book-file.js +0 -37
- package/dist/strand-peer-book-file.js.map +0 -1
- package/dist/strand-peer-book-protocol.d.ts +0 -168
- package/dist/strand-peer-book-protocol.js +0 -356
- package/dist/strand-peer-book-protocol.js.map +0 -1
- package/dist/strand-peer-book-swap.d.ts +0 -104
- package/dist/strand-peer-book-swap.js +0 -284
- package/dist/strand-peer-book-swap.js.map +0 -1
- package/dist/strand-peer-book.d.ts +0 -159
- package/dist/strand-peer-book.js +0 -361
- package/dist/strand-peer-book.js.map +0 -1
- package/dist/strand-peer-observer.d.ts +0 -88
- package/dist/strand-peer-observer.js +0 -180
- package/dist/strand-peer-observer.js.map +0 -1
- package/src/strand-peer-book-file.ts +0 -39
- package/src/strand-peer-book-protocol.ts +0 -466
- package/src/strand-peer-book-swap.ts +0 -338
- package/src/strand-peer-book.ts +0 -475
- package/src/strand-peer-observer.ts +0 -235
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-local, NON-replicated **strand network state**: for each strand this node
|
|
3
|
+
* runs, the last network state Optimystic's db-p2p saved for that strand's libp2p
|
|
4
|
+
* node — its FRET routing table (each peer entry carrying the peer's signed libp2p
|
|
5
|
+
* address record), the network-size high-water mark, and which of those peers were
|
|
6
|
+
* seen serving the strand. db-p2p reads it back once when the strand node is built
|
|
7
|
+
* and re-imports the table, which hands every record to the libp2p peerStore, so a
|
|
8
|
+
* restarted strand node already has addresses for the peers it was talking to
|
|
9
|
+
* (gotchoices/sereus#18).
|
|
10
|
+
*
|
|
11
|
+
* The payload is opaque here. db-p2p owns its version fence (a snapshot whose
|
|
12
|
+
* `version` it does not write is discarded whole) and FRET owns table validation
|
|
13
|
+
* (a structurally corrupt table is refused, a stale record dropped). This module
|
|
14
|
+
* checks only that an entry is a plain object with a numeric `version`, and never
|
|
15
|
+
* rewrites one.
|
|
16
|
+
*
|
|
17
|
+
* Three implementations mirroring `bootstrap-peer-store.ts` (same "must outlive the
|
|
18
|
+
* process, storage differs per platform" problem; both persistent forms share
|
|
19
|
+
* `node-local-snapshot.ts`):
|
|
20
|
+
* - {@link MemoryStrandNetworkStateStore} (this module, cross-platform) — ephemeral;
|
|
21
|
+
* the default when no store is injected via `CadreNodeConfig.strandNetworkState`.
|
|
22
|
+
* A node using it restarts with an empty routing table.
|
|
23
|
+
* - {@link PersistentStrandNetworkStateStore} (this module, cross-platform) —
|
|
24
|
+
* durable over any `DurableSlot` the embedding app supplies.
|
|
25
|
+
* - `FileStrandNetworkStateStore` — the above over a Node file in the node's state
|
|
26
|
+
* directory; Node-only, behind the subpath
|
|
27
|
+
* `@serfab/cadre-core/strand-network-state-file` so `node:fs` never lands in the
|
|
28
|
+
* RN/browser graph.
|
|
29
|
+
*
|
|
30
|
+
* One store per NODE holding every strand, keyed by strand id, so the embedder
|
|
31
|
+
* wires one slot, like the other node-local stores.
|
|
32
|
+
*
|
|
33
|
+
* **Nothing here is trust-bearing.** A routing-table entry grants no authority:
|
|
34
|
+
* every address record is signed by the peer it names and verified by FRET at
|
|
35
|
+
* import, and the dialed peer authenticates by peer id at the handshake.
|
|
36
|
+
*/
|
|
37
|
+
import debug from 'debug';
|
|
38
|
+
import type { NetworkStatePersistence, PersistedNetworkState } from '@optimystic/db-p2p';
|
|
39
|
+
import { NodeLocalSnapshot, type DurableSlot, type NodeLocalSnapshotSpec } from './node-local-snapshot.js';
|
|
40
|
+
|
|
41
|
+
const log = debug('sereus:cadre:strand-network-state');
|
|
42
|
+
|
|
43
|
+
export interface StrandNetworkStateStore {
|
|
44
|
+
/** Party this store is scoped to. */
|
|
45
|
+
readonly partyId: string;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The last state saved for `strandId`, or undefined. Synchronous: served from the
|
|
49
|
+
* in-memory half of the store. The returned object is the stored one — callers
|
|
50
|
+
* must not mutate it.
|
|
51
|
+
*/
|
|
52
|
+
load(strandId: string): PersistedNetworkState | undefined;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Replace the strand's state. Implementations MUST reflect it in {@link load}
|
|
56
|
+
* SYNCHRONOUSLY; the returned promise tracks durability only. The store keeps
|
|
57
|
+
* `state` itself, so the caller must not mutate it afterwards (db-p2p builds a
|
|
58
|
+
* fresh object per save).
|
|
59
|
+
*/
|
|
60
|
+
save(strandId: string, state: PersistedNetworkState): Promise<void>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Drop the strand's state — for a strand this node unpublished or left, whose
|
|
64
|
+
* peers must not be re-imported by a later launch. A no-op for the stored state
|
|
65
|
+
* when nothing is held, but {@link forgetGeneration} advances either way. Same
|
|
66
|
+
* contract as {@link save}: reflected synchronously, the promise tracks durability.
|
|
67
|
+
*/
|
|
68
|
+
forget(strandId: string): Promise<void>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How many times `strandId` has been forgotten through this store instance.
|
|
72
|
+
* {@link strandNetworkStatePersistence} compares it against the value it captured
|
|
73
|
+
* to drop a save that arrives after the forget.
|
|
74
|
+
*/
|
|
75
|
+
forgetGeneration(strandId: string): number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Per-strand forget counts; the in-process half of {@link StrandNetworkStateStore.forgetGeneration}. */
|
|
79
|
+
class ForgetGenerations {
|
|
80
|
+
private readonly counts = new Map<string, number>();
|
|
81
|
+
|
|
82
|
+
of(strandId: string): number {
|
|
83
|
+
return this.counts.get(strandId) ?? 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
advance(strandId: string): void {
|
|
87
|
+
this.counts.set(strandId, this.of(strandId) + 1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Ephemeral in-memory store for nodes without durable storage (tests, and the
|
|
93
|
+
* default when nothing is injected). Same contract, no disk: a strand relaunched in
|
|
94
|
+
* the same process re-imports its table (a hibernation resume, a stop()→start()),
|
|
95
|
+
* and a restarted process starts with none.
|
|
96
|
+
*/
|
|
97
|
+
export class MemoryStrandNetworkStateStore implements StrandNetworkStateStore {
|
|
98
|
+
private readonly strands = new Map<string, PersistedNetworkState>();
|
|
99
|
+
private readonly generations = new ForgetGenerations();
|
|
100
|
+
|
|
101
|
+
constructor(readonly partyId: string) {}
|
|
102
|
+
|
|
103
|
+
load(strandId: string): PersistedNetworkState | undefined {
|
|
104
|
+
return this.strands.get(strandId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async save(strandId: string, state: PersistedNetworkState): Promise<void> {
|
|
108
|
+
this.strands.set(strandId, state);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async forget(strandId: string): Promise<void> {
|
|
112
|
+
this.generations.advance(strandId);
|
|
113
|
+
if (this.strands.delete(strandId)) {
|
|
114
|
+
log('strand %s: network state forgotten (party=%s)', strandId, this.partyId);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
forgetGeneration(strandId: string): number {
|
|
119
|
+
return this.generations.of(strandId);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* What the store persists: `strands` maps strandId -> the state db-p2p saved. An
|
|
125
|
+
* entry that is not a plain object with a numeric `version` is dropped and its
|
|
126
|
+
* siblings kept (`drop-entry`): nothing here is trust-bearing, and one damaged
|
|
127
|
+
* strand must not cost every other strand its routing table. Everything past that
|
|
128
|
+
* structural check is db-p2p's and FRET's to judge at import.
|
|
129
|
+
*/
|
|
130
|
+
const STRAND_NETWORK_STATE_SNAPSHOT_SPEC: NodeLocalSnapshotSpec<PersistedNetworkState> = {
|
|
131
|
+
label: 'strand network state',
|
|
132
|
+
payloadKey: 'strands',
|
|
133
|
+
unusableEntry: 'drop-entry',
|
|
134
|
+
acceptEntry: (_strandId, raw) => {
|
|
135
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined;
|
|
136
|
+
if (typeof (raw as { version?: unknown }).version !== 'number') return undefined;
|
|
137
|
+
return raw as PersistedNetworkState;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Durable {@link StrandNetworkStateStore} over an app-supplied {@link DurableSlot} —
|
|
143
|
+
* the cross-platform half of every persistent backend (the Node
|
|
144
|
+
* `FileStrandNetworkStateStore` is this class over a file slot).
|
|
145
|
+
*
|
|
146
|
+
* Construct via {@link open}. Load and persist policy — what an absent, corrupt,
|
|
147
|
+
* foreign-party or unreadable slot does (the last one THROWS), and what a failed
|
|
148
|
+
* persist does — is documented once on `NodeLocalSnapshot`. Writes are serialised
|
|
149
|
+
* in-process by the snapshot's write chain, so several strand nodes saving at once
|
|
150
|
+
* cannot interleave partial snapshots.
|
|
151
|
+
*
|
|
152
|
+
* NOTE: an entry leaves only through {@link forget}, and nothing ages one out. A
|
|
153
|
+
* strand this machine detached because the strand watcher saw its control row gone
|
|
154
|
+
* (another machine of the party unpublished or left it), or one stopped and never
|
|
155
|
+
* relaunched, keeps its entry and it is rewritten with every save. The watcher path
|
|
156
|
+
* (`CadreNode.handleStrandRemoved`) does not forget because its removal is not proof
|
|
157
|
+
* the strand is gone: its query answers an empty list while the control database is
|
|
158
|
+
* absent. If snapshots grow on machines that host many short-lived strands, or a
|
|
159
|
+
* re-published strand id is seen importing its predecessor's table, drop entries at
|
|
160
|
+
* {@link open} that are older than FRET's own 14-day record age.
|
|
161
|
+
*/
|
|
162
|
+
export class PersistentStrandNetworkStateStore implements StrandNetworkStateStore {
|
|
163
|
+
private readonly generations = new ForgetGenerations();
|
|
164
|
+
|
|
165
|
+
private constructor(private readonly snapshot: NodeLocalSnapshot<PersistedNetworkState>) {}
|
|
166
|
+
|
|
167
|
+
/** Load (or cold-start) the party's strand network state from `slot`. */
|
|
168
|
+
static async open(slot: DurableSlot, partyId: string): Promise<PersistentStrandNetworkStateStore> {
|
|
169
|
+
return new PersistentStrandNetworkStateStore(
|
|
170
|
+
await NodeLocalSnapshot.open(slot, partyId, STRAND_NETWORK_STATE_SNAPSHOT_SPEC)
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
get partyId(): string {
|
|
175
|
+
return this.snapshot.partyId;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
load(strandId: string): PersistedNetworkState | undefined {
|
|
179
|
+
return this.snapshot.get(strandId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Replace, visible via {@link load} synchronously, then the full snapshot is persisted.
|
|
184
|
+
*
|
|
185
|
+
* NOTE: every save rewrites the party's whole snapshot — every strand's table —
|
|
186
|
+
* and db-p2p saves on every `connection:open`. Fine for a handful of strands; if
|
|
187
|
+
* slot writes show up in profiles, move to one slot per strand or coalesce saves.
|
|
188
|
+
*/
|
|
189
|
+
save(strandId: string, state: PersistedNetworkState): Promise<void> {
|
|
190
|
+
return this.snapshot.put(strandId, state);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Forget, gone from {@link load} synchronously, then persisted — unless nothing was there. */
|
|
194
|
+
forget(strandId: string): Promise<void> {
|
|
195
|
+
this.generations.advance(strandId);
|
|
196
|
+
if (this.snapshot.has(strandId)) {
|
|
197
|
+
log('strand %s: network state forgotten (party=%s); persisting', strandId, this.partyId);
|
|
198
|
+
}
|
|
199
|
+
return this.snapshot.remove(strandId);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
forgetGeneration(strandId: string): number {
|
|
203
|
+
return this.generations.of(strandId);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Adapts one strand's slice of the store to db-p2p's `NodeOptions.persistence`.
|
|
209
|
+
* Build one per strand runtime (`StrandInstanceManager.buildStrandRuntime` does).
|
|
210
|
+
*
|
|
211
|
+
* A save is dropped once the strand has been forgotten after this adapter was
|
|
212
|
+
* built: db-p2p saves fire-and-forget on connection events, so a node being torn
|
|
213
|
+
* down can still save after `forget(strandId)`, which would put the entry back.
|
|
214
|
+
* Saving resumes with the next runtime built for the strand (a relaunch or a
|
|
215
|
+
* hibernation resume), which builds a new adapter.
|
|
216
|
+
*
|
|
217
|
+
* NOTE: a strand that keeps running after its state was forgotten (self-revocation
|
|
218
|
+
* forgets but stops nothing) saves nothing more until its runtime is rebuilt. If a
|
|
219
|
+
* removed party is routinely re-admitted without a relaunch, re-arm saving at
|
|
220
|
+
* re-admission.
|
|
221
|
+
*
|
|
222
|
+
* NOTE: db-p2p saves on `connection:open` and on a changed serving verdict, not when
|
|
223
|
+
* a peer's addresses change. A peer whose address rotates during a long connection
|
|
224
|
+
* (a relay reservation moving) is saved with its old record until the next save,
|
|
225
|
+
* which matters only to a restart in between. If restarts are seen dialing rotated
|
|
226
|
+
* addresses, the fix is upstream: db-p2p saving when FRET's table changes.
|
|
227
|
+
*/
|
|
228
|
+
export function strandNetworkStatePersistence(
|
|
229
|
+
store: StrandNetworkStateStore,
|
|
230
|
+
strandId: string
|
|
231
|
+
): NetworkStatePersistence {
|
|
232
|
+
const generation = store.forgetGeneration(strandId);
|
|
233
|
+
return {
|
|
234
|
+
load: async () => store.load(strandId),
|
|
235
|
+
save: async (state) => {
|
|
236
|
+
if (store.forgetGeneration(strandId) !== generation) {
|
|
237
|
+
log('strand %s: dropping a network-state save that arrived after the strand was forgotten', strandId);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
await store.save(strandId, state);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -16,6 +16,7 @@ import { ed25519KeyPairFromLibp2p } from './ed25519-key.js';
|
|
|
16
16
|
import { mintPlaceholderStrandId } from './strand-id.js';
|
|
17
17
|
import {
|
|
18
18
|
StrandFormationManager,
|
|
19
|
+
type CadrePeerAddrsSource,
|
|
19
20
|
type MembershipInviteIssuer,
|
|
20
21
|
type StrandFormationManagerConfig
|
|
21
22
|
} from './strand-formation-manager.js';
|
|
@@ -110,9 +111,11 @@ export interface FormationUsageRecorder {
|
|
|
110
111
|
isTokenUsed(token: string): Promise<boolean>;
|
|
111
112
|
|
|
112
113
|
/**
|
|
113
|
-
* Check if a token is valid and not expired
|
|
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.
|
|
114
117
|
*/
|
|
115
|
-
isTokenValid(token: string): Promise<{ valid: boolean; invitation?: OpenInvitation }>;
|
|
118
|
+
isTokenValid(token: string): Promise<{ valid: boolean; reason?: 'unknown' | 'expired'; invitation?: OpenInvitation }>;
|
|
116
119
|
|
|
117
120
|
/**
|
|
118
121
|
* Resolve the host strand an invite binds to, classifying it as unbound / bound /
|
|
@@ -213,7 +216,7 @@ export interface StrandSolicitationServiceOptions {
|
|
|
213
216
|
/** Party ID for this node (used in protocol messages) */
|
|
214
217
|
partyId?: string;
|
|
215
218
|
/** Cadre peer addresses for this node */
|
|
216
|
-
cadrePeerAddrs?:
|
|
219
|
+
cadrePeerAddrs?: CadrePeerAddrsSource;
|
|
217
220
|
/**
|
|
218
221
|
* This node's live STRAND-network multiaddrs for a strand it is running (responder
|
|
219
222
|
* side), carried back to a validated joiner as its cross-party discovery seed. Wired
|
|
@@ -251,7 +254,7 @@ export class StrandSolicitationService {
|
|
|
251
254
|
private readonly strandProvisioner?: StrandProvisioner;
|
|
252
255
|
private readonly formationResponseValidator?: FormationResponseValidator;
|
|
253
256
|
private readonly partyId: string;
|
|
254
|
-
private readonly cadrePeerAddrs:
|
|
257
|
+
private readonly cadrePeerAddrs: CadrePeerAddrsSource;
|
|
255
258
|
private readonly resolveStrandAddrs?: (strandId: string) => string[];
|
|
256
259
|
private readonly issueMembershipInvite?: MembershipInviteIssuer;
|
|
257
260
|
private formationManager?: StrandFormationManager;
|
|
@@ -446,6 +449,16 @@ export class StrandSolicitationService {
|
|
|
446
449
|
log('Registered minted invitation %s (expires %d)', token, expiresAtMs);
|
|
447
450
|
}
|
|
448
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
|
+
|
|
449
462
|
/**
|
|
450
463
|
* Does this node currently expect a stranger — i.e. is at least one open
|
|
451
464
|
* invitation unexpired and not yet fully consumed? Sole input to the
|
package/src/types.ts
CHANGED
|
@@ -11,12 +11,13 @@ import type { SeedTrustPolicy } from './seed-trust-policy.js';
|
|
|
11
11
|
import type { KeyStore, KeyId } from './key-store.js';
|
|
12
12
|
import type { TrustedOwnerStore, TrustSource } from './trusted-owner-store.js';
|
|
13
13
|
import type { BootstrapPeerStore } from './bootstrap-peer-store.js';
|
|
14
|
-
import type {
|
|
14
|
+
import type { StrandNetworkStateStore } from './strand-network-state.js';
|
|
15
15
|
import type { EnrolledMachineStore } from './enrolled-machine-store.js';
|
|
16
16
|
import type { JoinedStrandStore } from './joined-strand-store.js';
|
|
17
17
|
import type { PushNotifier } from './push-notifier.js';
|
|
18
18
|
import type { RevocableTable } from './control-authorization.js';
|
|
19
19
|
import type { ControlRetryAbandonment } from './control-retry.js';
|
|
20
|
+
import type { FormationRejectionCode } from './strand-formation-rejection.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Extended Libp2p node with the coordinatedRepo attached by db-p2p's
|
|
@@ -478,8 +479,9 @@ export interface NetworkConfig {
|
|
|
478
479
|
* reservation drive, the control-cohort dial budgets, and the per-peer cohort read deadline
|
|
479
480
|
* ({@link cohortQueryTimeoutMs}, unless set explicitly). It is also handed to Optimystic
|
|
480
481
|
* (`NodeOptions.linkRoundTripMs`), default included, which derives its own deadlines from it:
|
|
481
|
-
* its request dials and responses, its block pushes, and libp2p's `
|
|
482
|
-
* `inboundUpgradeTimeout` on every node
|
|
482
|
+
* its request dials and responses, its block pushes, and libp2p's `addressDialTimeout`,
|
|
483
|
+
* `dialTimeout` and `inboundUpgradeTimeout` on every node, cadre adding its admission decisions
|
|
484
|
+
* to the three that open a connection. Reaching another machine through a
|
|
483
485
|
* relay costs a fixed number of exchanges, so a deadline written as milliseconds has a link
|
|
484
486
|
* speed above which it can never open a connection — which is the defect this declaration
|
|
485
487
|
* exists to make impossible to reintroduce one budget at a time. The counts, the measurement behind them,
|
|
@@ -496,8 +498,12 @@ export interface NetworkConfig {
|
|
|
496
498
|
* Refused where the libp2p node is built — inside `CadreNode.start()` for the control
|
|
497
499
|
* network, inside `CadreNode.addStrand` for a strand — if it is not a finite number above
|
|
498
500
|
* zero, because every consumer multiplies it into a deadline where a zero means "give up at
|
|
499
|
-
* once" and a `NaN` means "never".
|
|
500
|
-
*
|
|
501
|
+
* once" and a `NaN` means "never". A value above about 10.8 hours is refused in the same place,
|
|
502
|
+
* by Optimystic: the request dial cadre states from it (eleven round trips plus two admission
|
|
503
|
+
* decisions, `optimysticDialLimits` in `link-budget.ts`) then exceeds its
|
|
504
|
+
* `MAX_RPC_DIAL_TIMEOUT_MS` (about 4.97 days), and its error names `rpcDeadlines.dialTimeoutMs`.
|
|
505
|
+
* That is below Optimystic's own `MAX_LINK_ROUND_TRIP_MS` (about 13.3 hours) and far above any
|
|
506
|
+
* real link, so cadre adds no check of its own.
|
|
501
507
|
*/
|
|
502
508
|
linkRoundTripMs?: number;
|
|
503
509
|
/**
|
|
@@ -569,8 +575,8 @@ export interface NetworkConfig {
|
|
|
569
575
|
* so an address that never answers cannot use up the time the peer's other
|
|
570
576
|
* addresses needed. Defaults to
|
|
571
577
|
* {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS}. A value given
|
|
572
|
-
* here replaces that whole derived budget, including its
|
|
573
|
-
* called machine's admission
|
|
578
|
+
* here replaces that whole derived budget, including its allowances for the
|
|
579
|
+
* relay's and the called machine's admission decisions (`relayedDialBudgetMs`).
|
|
574
580
|
*/
|
|
575
581
|
perAddressDialTimeoutMs?: number;
|
|
576
582
|
/**
|
|
@@ -631,15 +637,11 @@ export interface NetworkConfig {
|
|
|
631
637
|
*
|
|
632
638
|
* WHY THE DEADLINE IS PINNED rather than given room to adapt. `pingTimeout` is an
|
|
633
639
|
* adaptive-timeout init, and equal `minTimeout`/`maxTimeout` clamp it to one value on
|
|
634
|
-
* every libp2p version.
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
* the overlapping-ping abort above straight back. Pinning both ends keeps the interval's
|
|
640
|
-
* margin true on the version bump instead of making it something to remember. It also
|
|
641
|
-
* sidesteps 3.3's other surprise: the monitor keeps one `AdaptiveTimeout` for all of a
|
|
642
|
-
* node's connections, so one slow peer would otherwise lengthen the deadline for every
|
|
640
|
+
* every libp2p version. libp2p 3.3 reports each ping's duration back to the monitor's
|
|
641
|
+
* `AdaptiveTimeout`, so a ceiling above `pingInterval` would let the deadline grow past the
|
|
642
|
+
* interval and put the overlapping-ping abort above straight back. Pinning both ends keeps
|
|
643
|
+
* the interval's margin true. It also sidesteps the monitor keeping one `AdaptiveTimeout`
|
|
644
|
+
* for all of a node's connections, so one slow peer cannot lengthen the deadline for every
|
|
643
645
|
* connection on that node.
|
|
644
646
|
*/
|
|
645
647
|
export const DEFAULT_CONNECTION_MONITOR = Object.freeze({
|
|
@@ -1030,32 +1032,29 @@ export interface CadreNodeConfig {
|
|
|
1030
1032
|
};
|
|
1031
1033
|
|
|
1032
1034
|
/**
|
|
1033
|
-
* Node-local strand
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1046
|
-
*
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
* `DurableSlot` and inject `PersistentStrandPeerBookStore.open(slot, partyId)`.
|
|
1050
|
-
*/
|
|
1051
|
-
strandPeers?: {
|
|
1035
|
+
* Node-local strand network state (see `strand-network-state.ts`): per strand, the
|
|
1036
|
+
* state Optimystic's db-p2p saves for the strand's libp2p node — its FRET routing
|
|
1037
|
+
* table, each entry carrying the peer's signed address record. A strand node reads
|
|
1038
|
+
* it back when it is built and re-imports the table, so a restarted machine already
|
|
1039
|
+
* holds addresses for the strand peers it was talking to (gotchoices/sereus#18).
|
|
1040
|
+
* Sibling of {@link bootstrapPeers} — same NON-replicated, per-party,
|
|
1041
|
+
* injected-backend shape — and, like it, nothing here is trust-bearing: FRET
|
|
1042
|
+
* verifies every record at import.
|
|
1043
|
+
*
|
|
1044
|
+
* Absent ⇒ an in-memory store is created at start() (ephemeral: a restarted process
|
|
1045
|
+
* starts every strand with an empty routing table). Every reference embedder
|
|
1046
|
+
* injects a durable backend over the same slot kind as its bootstrap-peer store. A
|
|
1047
|
+
* new platform needs no new store class: supply a `DurableSlot` and inject
|
|
1048
|
+
* `PersistentStrandNetworkStateStore.open(slot, partyId)`.
|
|
1049
|
+
*/
|
|
1050
|
+
strandNetworkState?: {
|
|
1052
1051
|
/**
|
|
1053
|
-
* Injected store instance — e.g. a `
|
|
1054
|
-
* Node-only subpath `@serfab/cadre-core/strand-
|
|
1052
|
+
* Injected store instance — e.g. a `FileStrandNetworkStateStore` from the
|
|
1053
|
+
* Node-only subpath `@serfab/cadre-core/strand-network-state-file`, persisted in
|
|
1055
1054
|
* the node's state directory. Its `partyId` must match
|
|
1056
1055
|
* `controlNetwork.partyId`; start() fails closed on a mismatch.
|
|
1057
1056
|
*/
|
|
1058
|
-
store?:
|
|
1057
|
+
store?: StrandNetworkStateStore;
|
|
1059
1058
|
};
|
|
1060
1059
|
|
|
1061
1060
|
/**
|
|
@@ -1201,6 +1200,69 @@ export interface StrandRow {
|
|
|
1201
1200
|
FounderOwnerKey: string | null;
|
|
1202
1201
|
}
|
|
1203
1202
|
|
|
1203
|
+
/**
|
|
1204
|
+
* A `CadreControl.PendingJoin` row: a join this party asked for through another party's
|
|
1205
|
+
* invitation, readable by every machine of the party. Each outcome carries only its own
|
|
1206
|
+
* columns (the schema's `OutcomeShape` check): `'joined'` sets `StrandId` (and, for a
|
|
1207
|
+
* closed strand, `MembershipInvite`), `'failed'` sets `FailureCode`, and a pending row
|
|
1208
|
+
* (`Outcome: null`) sets neither.
|
|
1209
|
+
*/
|
|
1210
|
+
export interface PendingJoinRow {
|
|
1211
|
+
/** base64url sha256 of the invitation token (`pendingJoinId`). */
|
|
1212
|
+
Id: string;
|
|
1213
|
+
/** `CadreNode.encodeInvitation` of the invitation. A bearer credential. */
|
|
1214
|
+
Invitation: string;
|
|
1215
|
+
/** `canonicalJson` of the disclosure the requester gave. */
|
|
1216
|
+
Disclosure: string;
|
|
1217
|
+
/** Epoch ms. */
|
|
1218
|
+
RequestedAt: number;
|
|
1219
|
+
/** Epoch ms; no attempt starts at or after it. */
|
|
1220
|
+
ExpiresAt: number;
|
|
1221
|
+
Outcome: null | 'joined' | 'failed';
|
|
1222
|
+
/** Epoch ms the outcome was recorded; null while pending. */
|
|
1223
|
+
OutcomeAt: number | null;
|
|
1224
|
+
/** `'joined'`: the strand the formation returned. */
|
|
1225
|
+
StrandId: string | null;
|
|
1226
|
+
/** `'joined'`, closed strand: JSON of the {@link StrandMembershipInvite} the formation delivered. */
|
|
1227
|
+
MembershipInvite: string | null;
|
|
1228
|
+
/** `'failed'`: a `FormationRejectionCode`, `'expired'` or `'local'`. Not constrained by the schema. */
|
|
1229
|
+
FailureCode: string | null;
|
|
1230
|
+
/** `'failed'`: human-readable text. */
|
|
1231
|
+
FailureReason: string | null;
|
|
1232
|
+
/** Single-use nonce of this row incarnation; `ControlDatabase.replacePendingJoin` names it as the row it replaces. */
|
|
1233
|
+
StampId: string;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* A join this party asked for through `CadreNode.requestJoin`, as one machine sees it.
|
|
1238
|
+
*
|
|
1239
|
+
* `pending`, `joined` and `failed` come from the party-wide `PendingJoin` row and read the same
|
|
1240
|
+
* on every machine. `trying` (an attempt is running on this machine) and `waiting` (this
|
|
1241
|
+
* machine's last attempt failed in a way worth retrying) are this machine's own view of a
|
|
1242
|
+
* pending row, so two machines can report different states for it.
|
|
1243
|
+
*/
|
|
1244
|
+
export interface PendingJoinStatus {
|
|
1245
|
+
/** `PendingJoin.Id`: the sha256 of the invitation token. */
|
|
1246
|
+
id: string;
|
|
1247
|
+
sAppId: string;
|
|
1248
|
+
/** Epoch ms. */
|
|
1249
|
+
requestedAt: number;
|
|
1250
|
+
/** Epoch ms; no attempt starts at or after it. */
|
|
1251
|
+
expiresAt: number;
|
|
1252
|
+
state: 'pending' | 'trying' | 'waiting' | 'joined' | 'failed';
|
|
1253
|
+
/** `'waiting'`: when this machine tries again, epoch ms. */
|
|
1254
|
+
nextAttemptAt?: number;
|
|
1255
|
+
/**
|
|
1256
|
+
* `'waiting'`: why this machine's last attempt failed. `'unreachable'` means no answer came
|
|
1257
|
+
* back; `'local'` means the attempt failed on this machine before an answer.
|
|
1258
|
+
*/
|
|
1259
|
+
lastError?: { code: FormationRejectionCode | 'unrecognized' | 'unreachable' | 'local'; reason: string };
|
|
1260
|
+
/** `'joined'`: the strand the join produced. */
|
|
1261
|
+
strandId?: string;
|
|
1262
|
+
/** `'failed'`: a `FormationRejectionCode`, `'expired'`, or `'local'` (approved, then a step on the joining machine failed). */
|
|
1263
|
+
failure?: { code: string; reason: string };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1204
1266
|
/**
|
|
1205
1267
|
* sApp configuration provided by the hosting application when creating a strand.
|
|
1206
1268
|
* This is what the app developer provides - NOT loaded from the network.
|
|
@@ -1377,7 +1439,13 @@ export interface OpenInvitation {
|
|
|
1377
1439
|
sAppId: string;
|
|
1378
1440
|
/** When this invitation expires */
|
|
1379
1441
|
expiration: Date;
|
|
1380
|
-
/**
|
|
1442
|
+
/**
|
|
1443
|
+
* Addresses of the inviting party's machines, each ending in `/p2p/<peerId>`: the minting
|
|
1444
|
+
* machine's own first, then up to three of its siblings with up to four addresses each
|
|
1445
|
+
* (`CadreNode.createOpenInvitation`). A joiner tries the machines in order, one formation
|
|
1446
|
+
* session each, so an entry may be dead by the time it is used; one that names no machine
|
|
1447
|
+
* is ignored.
|
|
1448
|
+
*/
|
|
1381
1449
|
bootstrap: string[];
|
|
1382
1450
|
}
|
|
1383
1451
|
|
|
@@ -1397,7 +1465,11 @@ export interface StrandMembershipInvite {
|
|
|
1397
1465
|
* The invite ed25519 PRIVATE seed (base64url). A single-use bearer credential:
|
|
1398
1466
|
* whoever holds it can `consumeInvite` exactly once. Same sensitivity class and
|
|
1399
1467
|
* handling as `memberPrivateKey` — delivered only inside the validated,
|
|
1400
|
-
* post-approval formation result
|
|
1468
|
+
* post-approval formation result. The inviting side never writes it to its control
|
|
1469
|
+
* DB. The joining side may copy it into its own `PendingJoin` row
|
|
1470
|
+
* (`MembershipInvite`), owner-signed and party-private like
|
|
1471
|
+
* `JoinedStrand.MemberPrivateKey`, so a join finished on one machine of the party
|
|
1472
|
+
* can be seated by whichever machine launches the strand first.
|
|
1401
1473
|
*/
|
|
1402
1474
|
invitePrivateKey: string;
|
|
1403
1475
|
}
|
|
@@ -1604,6 +1676,13 @@ export interface CadreNodeEvents {
|
|
|
1604
1676
|
* or connection state change.
|
|
1605
1677
|
*/
|
|
1606
1678
|
'control:write-abandoned': ControlRetryAbandonment;
|
|
1679
|
+
/**
|
|
1680
|
+
* Emitted when this machine's view of a join asked for with `CadreNode.requestJoin` changes:
|
|
1681
|
+
* its own attempt started or ended, or a pass read an outcome another owner machine wrote.
|
|
1682
|
+
* Owner machines only, since only they run the retry loop. A dismissed join emits nothing
|
|
1683
|
+
* further; `listPendingJoins` no longer names it.
|
|
1684
|
+
*/
|
|
1685
|
+
'pendingJoin:changed': PendingJoinStatus;
|
|
1607
1686
|
/** Emitted when a seed is received via the seed protocol */
|
|
1608
1687
|
'seed:received': { partyId: string; peerId: string };
|
|
1609
1688
|
/** Emitted when a seed is successfully applied */
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { StrandPeerBookOptions, StrandPeerBookStore } from './strand-peer-book.js';
|
|
2
|
-
/**
|
|
3
|
-
* File-backed {@link StrandPeerBookStore}. Open via {@link open}, which loads the
|
|
4
|
-
* existing file and returns the cross-platform store over it; an absent, corrupt,
|
|
5
|
-
* or wrong-party file is a cold start (empty book) and structurally junk or
|
|
6
|
-
* aged-out entries are dropped, while a present-but-unreadable file throws. Those
|
|
7
|
-
* rules and their reasoning live on `NodeLocalSnapshot.open`.
|
|
8
|
-
*/
|
|
9
|
-
export declare const FileStrandPeerBookStore: {
|
|
10
|
-
/** Load (or cold-start) the party's strand peer book from a file in `dir`. */
|
|
11
|
-
open(dir: string, partyId: string, options?: StrandPeerBookOptions): Promise<StrandPeerBookStore>;
|
|
12
|
-
};
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Node-only file-backed {@link StrandPeerBookStore}: one JSON file per party under
|
|
3
|
-
* a configured directory (the node's state directory — `cadre-cli` passes
|
|
4
|
-
* `ResolvedConfig.nodeStateDir`), holding the node-local, non-replicated strand
|
|
5
|
-
* peer book (the strand peers this node has met, with their last-known addresses).
|
|
6
|
-
*
|
|
7
|
-
* Nothing but the file itself lives here: the envelope, load policy, per-entry
|
|
8
|
-
* validation, the merge rule and the serialised snapshot writes are cross-platform
|
|
9
|
-
* in `PersistentStrandPeerBookStore` / `node-local-snapshot.ts`, and the file is a
|
|
10
|
-
* `FileDurableSlot`. This module exists only so the `node:fs` edge stays out of the
|
|
11
|
-
* package's cross-platform default entry (`./index.js`) — import it from the
|
|
12
|
-
* dedicated subpath instead:
|
|
13
|
-
*
|
|
14
|
-
* ```ts
|
|
15
|
-
* import { FileStrandPeerBookStore } from '@serfab/cadre-core/strand-peer-book-file';
|
|
16
|
-
* ```
|
|
17
|
-
*
|
|
18
|
-
* Same isolation pattern as `bootstrap-peer-store-file.ts` / `key-store-file.ts`.
|
|
19
|
-
*/
|
|
20
|
-
import { FileDurableSlot } from './file-durable-slot.js';
|
|
21
|
-
import { PersistentStrandPeerBookStore } from './strand-peer-book.js';
|
|
22
|
-
/** Base name of the store file: `<dir>/strand-peers.<encoded partyId>.json`. */
|
|
23
|
-
const SLOT_NAME = 'strand-peers';
|
|
24
|
-
/**
|
|
25
|
-
* File-backed {@link StrandPeerBookStore}. Open via {@link open}, which loads the
|
|
26
|
-
* existing file and returns the cross-platform store over it; an absent, corrupt,
|
|
27
|
-
* or wrong-party file is a cold start (empty book) and structurally junk or
|
|
28
|
-
* aged-out entries are dropped, while a present-but-unreadable file throws. Those
|
|
29
|
-
* rules and their reasoning live on `NodeLocalSnapshot.open`.
|
|
30
|
-
*/
|
|
31
|
-
export const FileStrandPeerBookStore = {
|
|
32
|
-
/** Load (or cold-start) the party's strand peer book from a file in `dir`. */
|
|
33
|
-
async open(dir, partyId, options) {
|
|
34
|
-
return PersistentStrandPeerBookStore.open(new FileDurableSlot(dir, SLOT_NAME, partyId), partyId, options);
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
//# sourceMappingURL=strand-peer-book-file.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"strand-peer-book-file.js","sourceRoot":"","sources":["../src/strand-peer-book-file.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,6BAA6B,EAAE,MAAM,uBAAuB,CAAC;AAGtE,gFAAgF;AAChF,MAAM,SAAS,GAAG,cAAc,CAAC;AAEjC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG;IACtC,8EAA8E;IAC9E,KAAK,CAAC,IAAI,CAAC,GAAW,EAAE,OAAe,EAAE,OAA+B;QACvE,OAAO,6BAA6B,CAAC,IAAI,CAAC,IAAI,eAAe,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC3G,CAAC;CACD,CAAC"}
|