@serfab/cadre-core 1.5.0 → 1.7.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 +206 -67
- package/dist/cadre-node.js +399 -98
- package/dist/cadre-node.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +112 -0
- package/dist/joined-strand-store.js +263 -0
- package/dist/joined-strand-store.js.map +1 -0
- package/dist/key-store.d.ts +6 -0
- package/dist/key-store.js.map +1 -1
- package/dist/link-budget.d.ts +95 -53
- package/dist/link-budget.js +97 -53
- package/dist/link-budget.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +5 -4
- package/dist/node-local-snapshot.js +6 -3
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-dial.d.ts +3 -3
- package/dist/peer-dial.js +3 -3
- package/dist/peer-join-backfill.d.ts +35 -16
- package/dist/peer-join-backfill.js +117 -57
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/relay-reservation.d.ts +2 -2
- package/dist/relay-reservation.js +2 -2
- package/dist/seed-bootstrap.js +1 -1
- package/dist/strand-first-sync-gate.d.ts +88 -43
- package/dist/strand-first-sync-gate.js +92 -46
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +39 -0
- package/dist/strand-instance-manager.js +80 -3
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-peer-book-file.d.ts +12 -0
- package/dist/strand-peer-book-file.js +37 -0
- package/dist/strand-peer-book-file.js.map +1 -0
- package/dist/strand-peer-book-protocol.d.ts +159 -0
- package/dist/strand-peer-book-protocol.js +360 -0
- package/dist/strand-peer-book-protocol.js.map +1 -0
- package/dist/strand-peer-book-swap.d.ts +104 -0
- package/dist/strand-peer-book-swap.js +284 -0
- package/dist/strand-peer-book-swap.js.map +1 -0
- package/dist/strand-peer-book.d.ts +159 -0
- package/dist/strand-peer-book.js +354 -0
- package/dist/strand-peer-book.js.map +1 -0
- package/dist/strand-peer-observer.d.ts +88 -0
- package/dist/strand-peer-observer.js +180 -0
- package/dist/strand-peer-observer.js.map +1 -0
- package/dist/types.d.ts +84 -17
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -1
- package/package.json +11 -7
- package/src/cadre-node.ts +7776 -7445
- package/src/index.ts +68 -0
- package/src/joined-strand-store.ts +308 -0
- package/src/key-store.ts +6 -0
- package/src/link-budget.ts +100 -53
- package/src/node-local-snapshot.ts +293 -289
- package/src/peer-dial.ts +3 -3
- package/src/peer-join-backfill.ts +709 -647
- package/src/relay-reservation.ts +2 -2
- package/src/seed-bootstrap.ts +1 -1
- package/src/strand-first-sync-gate.ts +352 -306
- package/src/strand-instance-manager.ts +106 -3
- package/src/strand-peer-book-file.ts +39 -0
- package/src/strand-peer-book-protocol.ts +463 -0
- package/src/strand-peer-book-swap.ts +338 -0
- package/src/strand-peer-book.ts +468 -0
- package/src/strand-peer-observer.ts +235 -0
- package/src/types.ts +86 -17
package/src/index.ts
CHANGED
|
@@ -117,6 +117,73 @@ export {
|
|
|
117
117
|
type EnrolledMachineStore
|
|
118
118
|
} from './enrolled-machine-store.js';
|
|
119
119
|
|
|
120
|
+
// Node-local record of the strands this node joined from ANOTHER party, re-offered on
|
|
121
|
+
// every start as `strand:discovered`. Cross-platform: its durable form rides the
|
|
122
|
+
// KeyStore seam above (the record carries a closed strand's read secret), so there is
|
|
123
|
+
// no file-backed subpath.
|
|
124
|
+
export {
|
|
125
|
+
MemoryJoinedStrandStore,
|
|
126
|
+
KeyStoreJoinedStrandStore,
|
|
127
|
+
type JoinedStrandStore,
|
|
128
|
+
type JoinedStrandRecord
|
|
129
|
+
} from './joined-strand-store.js';
|
|
130
|
+
|
|
131
|
+
// Node-local strand peer book: per strand, the strand peers this node has met and
|
|
132
|
+
// their last-known addresses, dialed first on every launch so a restarted machine
|
|
133
|
+
// re-finds the other parties. Same cross-platform split as the bootstrap-peer store
|
|
134
|
+
// above — interface + in-memory + slot-backed stores here, Node-only file backend
|
|
135
|
+
// behind the subpath '@serfab/cadre-core/strand-peer-book-file'.
|
|
136
|
+
export {
|
|
137
|
+
MemoryStrandPeerBookStore,
|
|
138
|
+
PersistentStrandPeerBookStore,
|
|
139
|
+
mergeStrandPeerEntry,
|
|
140
|
+
sanitizeStrandPeerEntry,
|
|
141
|
+
strandPeerFreshness,
|
|
142
|
+
MAX_STRAND_PEERS,
|
|
143
|
+
STRAND_PEER_MAX_AGE_MS,
|
|
144
|
+
type StrandPeerBookStore,
|
|
145
|
+
type StrandPeerBookOptions,
|
|
146
|
+
type StrandPeerEntry
|
|
147
|
+
} from './strand-peer-book.js';
|
|
148
|
+
export {
|
|
149
|
+
StrandPeerObserver,
|
|
150
|
+
dialableAddrs,
|
|
151
|
+
connectedIdentifiedPeers,
|
|
152
|
+
STRAND_PEER_OBSERVE_THROTTLE_MS,
|
|
153
|
+
type StrandPeerObservation,
|
|
154
|
+
type StrandPeerObserverDeps,
|
|
155
|
+
type StrandPeerObserverOptions,
|
|
156
|
+
type ConnectedIdentifiedPeer
|
|
157
|
+
} from './strand-peer-observer.js';
|
|
158
|
+
// The signed book SWAP between strand peers (`/sereus/strand-peers/1.0.0`): the wire
|
|
159
|
+
// protocol, signing and verification, and the per-strand driver that arms it.
|
|
160
|
+
export {
|
|
161
|
+
STRAND_PEER_BOOK_PROTOCOL,
|
|
162
|
+
SIGNED_STRAND_PEER_ENTRY_VERSION,
|
|
163
|
+
STRAND_PEER_ISSUED_AT_SKEW_MS,
|
|
164
|
+
MAX_BOOK_FRAME_SIZE,
|
|
165
|
+
StrandPeerBookService,
|
|
166
|
+
exchangeStrandPeerBook,
|
|
167
|
+
signStrandPeerEntry,
|
|
168
|
+
signedStrandPeerEntryPayload,
|
|
169
|
+
verifySignedStrandPeerEntry,
|
|
170
|
+
verifyStrandPeerBookFrame,
|
|
171
|
+
trimBookFrameToFit,
|
|
172
|
+
assertBookFrameFits,
|
|
173
|
+
type SignedStrandPeerEntry,
|
|
174
|
+
type StrandPeerBookFrame,
|
|
175
|
+
type StrandPeerEntryVerdict,
|
|
176
|
+
type StrandPeerBookServiceDeps,
|
|
177
|
+
type ExchangeStrandPeerBookOptions
|
|
178
|
+
} from './strand-peer-book-protocol.js';
|
|
179
|
+
export {
|
|
180
|
+
StrandPeerBookSwap,
|
|
181
|
+
STRAND_PEER_BOOK_SWAP_THROTTLE_MS,
|
|
182
|
+
OWN_ENTRY_RESIGN_DEBOUNCE_MS,
|
|
183
|
+
type StrandPeerBookSwapDeps,
|
|
184
|
+
type StrandPeerBookSwapOptions
|
|
185
|
+
} from './strand-peer-book-swap.js';
|
|
186
|
+
|
|
120
187
|
// Storage scope keys — what `CadreNodeConfig.storage.provider` is called with. A
|
|
121
188
|
// strand's key is its strand id; the control database's key carries the party id,
|
|
122
189
|
// so two parties on one device never share a control store. Every key stays within
|
|
@@ -457,6 +524,7 @@ export {
|
|
|
457
524
|
PUSH_TRANSFER_ALLOWANCE_MS,
|
|
458
525
|
resolveLinkRoundTripMs,
|
|
459
526
|
relayedDialBudgetMs,
|
|
527
|
+
connectionManagerTimeouts,
|
|
460
528
|
relayReservationBudgetMs,
|
|
461
529
|
circuitRequestBudgetMs,
|
|
462
530
|
peerJoinPushBudget,
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-local record of the strands this node joined from ANOTHER party.
|
|
3
|
+
*
|
|
4
|
+
* A party's own strands come back after a restart through its control database: the
|
|
5
|
+
* `Strand` table holds their rows, the strand watcher polls it, and each row no sApp
|
|
6
|
+
* config claims is offered as `strand:discovered`. A strand joined through formation with
|
|
7
|
+
* another party (`CadreNode.formStrand`) has no row there — the other party's control
|
|
8
|
+
* database holds it — so nothing re-offered it after a restart, and every embedding app
|
|
9
|
+
* kept its own list of joins, read secret included (gotchoices/sereus#18).
|
|
10
|
+
*
|
|
11
|
+
* `CadreNode` records each such join here, hands the records to its strand watcher beside
|
|
12
|
+
* the control rows ({@link JoinedStrandRows}), and forgets one on `forgetJoinedStrand` or
|
|
13
|
+
* when this party is removed from the strand.
|
|
14
|
+
*
|
|
15
|
+
* Two implementations:
|
|
16
|
+
* - {@link MemoryJoinedStrandStore} — joins die with the process. The default only for a
|
|
17
|
+
* node configured with no `keyStore`.
|
|
18
|
+
* - {@link KeyStoreJoinedStrandStore} — one {@link KeyStore} slot per join. A record holds
|
|
19
|
+
* the closed strand's shared read secret, and the KeyStore is the one seam every
|
|
20
|
+
* platform already routes secrets through (the platform enclave on React Native, the
|
|
21
|
+
* state directory for `FileKeyStore`), so the record goes there rather than into a
|
|
22
|
+
* `DurableSlot` like the bootstrap-peer store.
|
|
23
|
+
*
|
|
24
|
+
* Party-scoped like every other node-local store, because one KeyStore can serve several
|
|
25
|
+
* parties: the React Native app keeps one per device and lets the user switch party. A
|
|
26
|
+
* join made for one party must never be offered to a node started for another.
|
|
27
|
+
*
|
|
28
|
+
* Dependency-free beyond the KeyStore seam, so safe in every entry graph.
|
|
29
|
+
*/
|
|
30
|
+
import debug from 'debug';
|
|
31
|
+
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
32
|
+
import type { KeyId, KeyStore } from './key-store.js';
|
|
33
|
+
import { assertStrandScopeKey, isValidStrandScopeKey } from './storage-scope.js';
|
|
34
|
+
import type { StrandRow } from './types.js';
|
|
35
|
+
|
|
36
|
+
const log = debug('sereus:cadre:joined-strand-store');
|
|
37
|
+
|
|
38
|
+
/** One strand this node joined from ANOTHER party — nothing in this party's control DB names it. */
|
|
39
|
+
export interface JoinedStrandRecord {
|
|
40
|
+
/** The strand id (a valid scope key, see storage-scope.ts). */
|
|
41
|
+
Id: string;
|
|
42
|
+
Type: 'o' | 'c';
|
|
43
|
+
/** The closed strand's shared read secret the formation delivered; null for an open strand. */
|
|
44
|
+
MemberPrivateKey: string | null;
|
|
45
|
+
/** Wall-clock ms this node first recorded the join. Diagnostics only. */
|
|
46
|
+
joinedAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface JoinedStrandStore {
|
|
50
|
+
/** Party this store is scoped to. */
|
|
51
|
+
readonly partyId: string;
|
|
52
|
+
|
|
53
|
+
/** Every remembered join. A call reflects every {@link record}/{@link forget} that resolved before it was made. */
|
|
54
|
+
list(): Promise<JoinedStrandRecord[]>;
|
|
55
|
+
|
|
56
|
+
/** Remember a join, REPLACING any record with the same `Id`. */
|
|
57
|
+
record(record: JoinedStrandRecord): Promise<void>;
|
|
58
|
+
|
|
59
|
+
/** Forget a join. A no-op when there is no record for `strandId`. */
|
|
60
|
+
forget(strandId: string): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The {@link StrandRow} a record stands for — the shape `addStrand` and `strand:discovered`
|
|
65
|
+
* carry. `FounderOwnerKey` is null because a joiner never founds; `CadreNode.launchStrand`
|
|
66
|
+
* reads null as "not mine" and joins.
|
|
67
|
+
*/
|
|
68
|
+
export function joinedStrandRow(record: JoinedStrandRecord): StrandRow {
|
|
69
|
+
return { Id: record.Id, Type: record.Type, MemberPrivateKey: record.MemberPrivateKey, FounderOwnerKey: null };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Ephemeral store for a node without a `keyStore`. Warns once, at the first join it is
|
|
74
|
+
* asked to remember, because a join held only here is not re-offered after a restart —
|
|
75
|
+
* exactly the gotchoices/sereus#18 shape, and it should show in a log rather than pass
|
|
76
|
+
* silently.
|
|
77
|
+
*/
|
|
78
|
+
export class MemoryJoinedStrandStore implements JoinedStrandStore {
|
|
79
|
+
private readonly records = new Map<string, JoinedStrandRecord>();
|
|
80
|
+
private warned = false;
|
|
81
|
+
|
|
82
|
+
constructor(readonly partyId: string) {}
|
|
83
|
+
|
|
84
|
+
async list(): Promise<JoinedStrandRecord[]> {
|
|
85
|
+
return [...this.records.values()];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async record(record: JoinedStrandRecord): Promise<void> {
|
|
89
|
+
this.warnOnce();
|
|
90
|
+
this.records.set(record.Id, { ...record });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async forget(strandId: string): Promise<void> {
|
|
94
|
+
this.records.delete(strandId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private warnOnce(): void {
|
|
98
|
+
if (this.warned) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.warned = true;
|
|
102
|
+
console.warn(
|
|
103
|
+
'CadreNode has no keyStore and no joinedStrands.store, so strands joined from another party ' +
|
|
104
|
+
'are remembered in memory only and will NOT be re-offered after a restart. Configure a keyStore, ' +
|
|
105
|
+
'or inject joinedStrands: { store: new KeyStoreJoinedStrandStore(<a durable KeyStore>, partyId) }.'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Slot id prefix for every joined-strand record; the party segment and the strand id follow. */
|
|
111
|
+
const SLOT_PREFIX = 'cadre/joined-strand/';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Durable store over a {@link KeyStore}: one slot per join, id
|
|
115
|
+
* `cadre/joined-strand/<base64url party id>/<strand id>`, holding the record's UTF-8 JSON.
|
|
116
|
+
* The party segment is base64url for the reason `controlStorageScope` gives: a party id is
|
|
117
|
+
* arbitrary text, and base64url never contains the `/` that ends the segment.
|
|
118
|
+
*
|
|
119
|
+
* Loads every slot once, at the first {@link list}, then answers from memory and writes
|
|
120
|
+
* through, so the strand watcher's five-second poll does not read the enclave on every
|
|
121
|
+
* pass. A load that fails — `KeyStoreAccessError` from a refused unlock prompt, say —
|
|
122
|
+
* is rethrown rather than read as "no joins" and retried at the next call, exactly as
|
|
123
|
+
* `loadOrCreateIdentityKey` treats the identity slot. A slot that does not parse as a
|
|
124
|
+
* record for its own id is dropped with a log and its siblings kept: refusing to start
|
|
125
|
+
* repairs nothing, and the other joins are still good.
|
|
126
|
+
*/
|
|
127
|
+
export class KeyStoreJoinedStrandStore implements JoinedStrandStore {
|
|
128
|
+
private readonly slotPrefix: string;
|
|
129
|
+
/** The loaded records; null until a load succeeds. */
|
|
130
|
+
private records: Map<string, JoinedStrandRecord> | null = null;
|
|
131
|
+
/** Runs every KeyStore access in call order, so a load never interleaves with a write. */
|
|
132
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
133
|
+
|
|
134
|
+
constructor(private readonly keyStore: KeyStore, readonly partyId: string) {
|
|
135
|
+
this.slotPrefix = SLOT_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base64url') + '/';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
list(): Promise<JoinedStrandRecord[]> {
|
|
139
|
+
return this.inOrder(async () => [...(await this.loaded()).values()]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Throws `InvalidStrandIdError` for an id that could never load back, before anything is written. */
|
|
143
|
+
record(record: JoinedStrandRecord): Promise<void> {
|
|
144
|
+
assertStrandScopeKey(record.Id);
|
|
145
|
+
const stored: JoinedStrandRecord = { ...record };
|
|
146
|
+
return this.inOrder(async () => {
|
|
147
|
+
await this.keyStore.set(this.slotId(record.Id), uint8ArrayFromString(JSON.stringify(stored), 'utf8'));
|
|
148
|
+
this.records?.set(record.Id, stored);
|
|
149
|
+
log('joined strand remembered (party=%s, strand=%s, type=%s)', this.partyId, record.Id, record.Type);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
forget(strandId: string): Promise<void> {
|
|
154
|
+
return this.inOrder(async () => {
|
|
155
|
+
await this.keyStore.delete(this.slotId(strandId));
|
|
156
|
+
this.records?.delete(strandId);
|
|
157
|
+
log('joined strand forgotten (party=%s, strand=%s)', this.partyId, strandId);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private slotId(strandId: string): KeyId {
|
|
162
|
+
return this.slotPrefix + strandId;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private inOrder<T>(op: () => Promise<T>): Promise<T> {
|
|
166
|
+
const result = this.queue.then(op);
|
|
167
|
+
// The caller receives `result`, rejection included; the queue only needs to know it settled.
|
|
168
|
+
this.queue = result.catch(() => undefined);
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// NOTE: a failed load is retried at the next list(), which is every strand-watcher poll;
|
|
173
|
+
// over a backend that gates reads behind an unlock prompt the user keeps declining, that
|
|
174
|
+
// is a prompt every five seconds. Every KeyStore shipped today is ungated for this reason
|
|
175
|
+
// (see `SecureStoreKeyStoreOptions.requireAuthentication`); if one is ever gated, back
|
|
176
|
+
// off the retry here.
|
|
177
|
+
private async loaded(): Promise<Map<string, JoinedStrandRecord>> {
|
|
178
|
+
if (this.records) {
|
|
179
|
+
return this.records;
|
|
180
|
+
}
|
|
181
|
+
const records = new Map<string, JoinedStrandRecord>();
|
|
182
|
+
for (const slot of await this.keyStore.list()) {
|
|
183
|
+
if (!slot.startsWith(this.slotPrefix)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const strandId = slot.slice(this.slotPrefix.length);
|
|
187
|
+
const bytes = await this.keyStore.get(slot);
|
|
188
|
+
if (!bytes) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const record = parseRecord(strandId, bytes);
|
|
192
|
+
if (record) {
|
|
193
|
+
records.set(strandId, record);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
this.records = records;
|
|
197
|
+
log('joined strands loaded (party=%s, count=%d)', this.partyId, records.size);
|
|
198
|
+
return records;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The record a slot holds, or undefined (logged) when it is not one for `strandId`. */
|
|
203
|
+
function parseRecord(strandId: string, bytes: Uint8Array): JoinedStrandRecord | undefined {
|
|
204
|
+
let parsed: unknown;
|
|
205
|
+
try {
|
|
206
|
+
parsed = JSON.parse(uint8ArrayToString(bytes, 'utf8'));
|
|
207
|
+
} catch (error) {
|
|
208
|
+
log('dropping joined-strand slot for %s: not JSON (%o)', strandId, error);
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
const { Id, Type, MemberPrivateKey, joinedAt } = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Record<string, unknown>;
|
|
212
|
+
const valid = Id === strandId
|
|
213
|
+
&& isValidStrandScopeKey(strandId)
|
|
214
|
+
&& (Type === 'o' || Type === 'c')
|
|
215
|
+
&& (MemberPrivateKey === null || typeof MemberPrivateKey === 'string')
|
|
216
|
+
&& typeof joinedAt === 'number';
|
|
217
|
+
if (!valid) {
|
|
218
|
+
log('dropping joined-strand slot for %s: not a joined-strand record for that id', strandId);
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
return { Id: strandId, Type: Type as 'o' | 'c', MemberPrivateKey: MemberPrivateKey as string | null, joinedAt: joinedAt as number };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* What the strand watcher sees of the joined-strand store: the control rows plus one row per
|
|
226
|
+
* remembered join, which is how a join comes back as `strand:discovered` (or auto-launches)
|
|
227
|
+
* after a restart with no second offer path. One instance per node session.
|
|
228
|
+
*
|
|
229
|
+
* Holds the two pieces of state a plain union would get wrong, both of which would otherwise
|
|
230
|
+
* make the watcher detach running strands, since a joined row missing from a poll reads to it
|
|
231
|
+
* as a removal:
|
|
232
|
+
* - the last list the store answered, reused when a later list fails, so a read failure is
|
|
233
|
+
* not "every join was forgotten";
|
|
234
|
+
* - joins forgotten because this party was removed from the strand
|
|
235
|
+
* ({@link forgetAfterThisSession}): gone from the store, so the next start does not
|
|
236
|
+
* re-attach them, but still offered until this session ends, because `strand:revoked`
|
|
237
|
+
* promises that nothing is torn down for the app.
|
|
238
|
+
*/
|
|
239
|
+
export class JoinedStrandRows {
|
|
240
|
+
private lastListed: JoinedStrandRecord[] = [];
|
|
241
|
+
private readonly keptForSession = new Map<string, JoinedStrandRecord>();
|
|
242
|
+
|
|
243
|
+
constructor(private readonly store: JoinedStrandStore) {}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `control` plus a row for every remembered join. A control row wins an id collision: the
|
|
247
|
+
* strand is this party's own now (its founding party enrolled this machine, say), the
|
|
248
|
+
* control database remembers it, and the record is stale — so it is forgotten here.
|
|
249
|
+
*/
|
|
250
|
+
async withControlRows(control: readonly StrandRow[]): Promise<StrandRow[]> {
|
|
251
|
+
const controlIds = new Set(control.map((row) => row.Id));
|
|
252
|
+
const joined = new Map<string, JoinedStrandRecord>(this.keptForSession);
|
|
253
|
+
for (const record of await this.listed()) {
|
|
254
|
+
joined.set(record.Id, record);
|
|
255
|
+
}
|
|
256
|
+
const rows = [...control];
|
|
257
|
+
for (const record of joined.values()) {
|
|
258
|
+
if (controlIds.has(record.Id)) {
|
|
259
|
+
await this.forgetSuperseded(record.Id);
|
|
260
|
+
} else {
|
|
261
|
+
rows.push(joinedStrandRow(record));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return rows;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Forget a join for good: the app is leaving the strand. */
|
|
268
|
+
async forget(strandId: string): Promise<void> {
|
|
269
|
+
this.keptForSession.delete(strandId);
|
|
270
|
+
await this.store.forget(strandId);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Forget a join durably, but keep offering it until this session ends. For a strand this
|
|
275
|
+
* party was removed from: it must not re-attach on every launch, and it must not be torn
|
|
276
|
+
* down underneath the app either. A no-op for a strand with no record.
|
|
277
|
+
*/
|
|
278
|
+
async forgetAfterThisSession(strandId: string): Promise<void> {
|
|
279
|
+
const record = (await this.listed()).find((listed) => listed.Id === strandId);
|
|
280
|
+
if (!record) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
// Kept BEFORE the store forgets, so no poll can list the store without it and union
|
|
284
|
+
// without the kept copy.
|
|
285
|
+
this.keptForSession.set(strandId, record);
|
|
286
|
+
await this.store.forget(strandId);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private async listed(): Promise<JoinedStrandRecord[]> {
|
|
290
|
+
try {
|
|
291
|
+
this.lastListed = await this.store.list();
|
|
292
|
+
} catch (error) {
|
|
293
|
+
log('joined-strand store list failed; offering the last list it answered (%d join(s)): %o',
|
|
294
|
+
this.lastListed.length, error);
|
|
295
|
+
}
|
|
296
|
+
return this.lastListed;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async forgetSuperseded(strandId: string): Promise<void> {
|
|
300
|
+
this.keptForSession.delete(strandId);
|
|
301
|
+
try {
|
|
302
|
+
await this.store.forget(strandId);
|
|
303
|
+
log('joined strand %s now has a control row — record forgotten', strandId);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
log('forgetting superseded joined strand %s failed; the next poll retries: %o', strandId, error);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
package/src/key-store.ts
CHANGED
|
@@ -32,6 +32,12 @@ export const DEFAULT_IDENTITY_KEY_ID: KeyId = 'cadre/identity';
|
|
|
32
32
|
* Key material is raw bytes (e.g. libp2p protobuf-serialized private keys).
|
|
33
33
|
* Text-only backends encode/decode (base64) internally; byte-native backends
|
|
34
34
|
* (LevelDB) store as-is.
|
|
35
|
+
*
|
|
36
|
+
* Not only keys: `KeyStoreJoinedStrandStore` keeps one small UTF-8 JSON record
|
|
37
|
+
* (about 200 bytes, a closed strand's read secret among its fields) per strand
|
|
38
|
+
* this node joined from another party, under ids beginning
|
|
39
|
+
* `cadre/joined-strand/`. {@link list} therefore returns those ids too, and a
|
|
40
|
+
* backend must accept `/` in an id and values of that size.
|
|
35
41
|
*/
|
|
36
42
|
export interface KeyStore {
|
|
37
43
|
/** Resolves to the stored material, or undefined if the slot is empty. */
|
package/src/link-budget.ts
CHANGED
|
@@ -35,63 +35,80 @@
|
|
|
35
35
|
*
|
|
36
36
|
* | operation | link round trips | at the declared default |
|
|
37
37
|
* | ---------------------------------------------------- | ---------------- | ----------------------- |
|
|
38
|
-
* | open a relayed connection to another machine | 4 |
|
|
39
|
-
* | dial the relay itself | 1 |
|
|
40
|
-
* | request a reservation on an open relay connection | 1 |
|
|
41
|
-
* | negotiate a protocol over an established circuit | 1 |
|
|
42
|
-
*
|
|
43
|
-
* **Measured** 2026-09-26, one Windows machine, loopback dedicated relay
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* already thrown the half-built connection away — so the first stream over it died with
|
|
38
|
+
* | open a relayed connection to another machine | 4 | 14 000 ms |
|
|
39
|
+
* | dial the relay itself | 1 | 3 500 ms |
|
|
40
|
+
* | request a reservation on an open relay connection | 1 | 3 500 ms |
|
|
41
|
+
* | negotiate a protocol over an established circuit | 1 | 3 500 ms |
|
|
42
|
+
*
|
|
43
|
+
* **Measured** 2026-09-26, one Windows machine, loopback dedicated relay: a relayed dial took
|
|
44
|
+
* 20-25 ms at no delay, **7 255-7 279 ms at 900 ms one-way**, and **12 066-12 094 ms at 1 500 ms
|
|
45
|
+
* one-way**. Four link round trips of pure delay would be 7 200 and 12 000, so the real cost
|
|
46
|
+
* sits about 60-95 ms ABOVE the arithmetic — the handshakes, which no delay figure contains.
|
|
47
|
+
* That residue is why {@link DECLARED_LINK_ROUND_TRIP_MS} carries headroom over the link sereus
|
|
48
|
+
* supports rather than matching it exactly: a declaration equal to the supported round trip
|
|
49
|
+
* would derive a budget marginally BELOW the dial it has to contain.
|
|
50
|
+
*
|
|
51
|
+
* ── libp2p's own two limits ──
|
|
52
|
+
*
|
|
53
|
+
* Two limits inside libp2p bound the same relayed dial as cadre's budgets do, and both were
|
|
54
|
+
* 10 000 ms until `@optimystic/db-p2p` 1.7.0 let an embedder set them: the DIALER's
|
|
55
|
+
* `connectionManager.dialTimeout`, which bounds every dial that carries no abort signal of its
|
|
56
|
+
* own, and the LISTENER's `connectionManager.inboundUpgradeTimeout`, which is how long the
|
|
57
|
+
* machine being called lets a half-built connection finish its handshakes. At 10 000 ms each,
|
|
58
|
+
* no relayed connection could be opened above about 1 250 ms one-way (a 2.5-second link round
|
|
59
|
+
* trip) whatever cadre declared. The listener's limit is the one that makes that failure
|
|
60
|
+
* silent: at 1 500 ms one-way the dialer's own 12 094 ms dial RESOLVED while the listener had
|
|
61
|
+
* already thrown the connection away at 10 s, so the first stream over it died with
|
|
63
62
|
* `Unexpected EOF - stream closed while reading 0/1 bytes` and the listener reported no peer at
|
|
64
|
-
* all.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
63
|
+
* all. cadre now declares both from this module ({@link connectionManagerTimeouts}) on the
|
|
64
|
+
* control node and every strand node.
|
|
65
|
+
*
|
|
66
|
+
* ── What still fails at the supported link ──
|
|
67
|
+
*
|
|
68
|
+
* - **Optimystic's own request dials.** `@optimystic/db-p2p`'s RPC clients dial with fixed
|
|
69
|
+
* 3 000 ms deadlines of their own (`DEFAULT_DIAL_TIMEOUT_MS`, the `pushDialTimeoutMs`
|
|
70
|
+
* defaults), which neither limit above reaches because a caller's signal replaces
|
|
71
|
+
* `dialTimeout`. So an Optimystic request that has to OPEN a relayed connection fails above
|
|
72
|
+
* 375 ms one-way. A request over a connection that is already open does not dial, and the
|
|
73
|
+
* connections cadre opens itself are budgeted here. Upstream:
|
|
74
|
+
* `debt-rpc-dial-deadlines-cannot-open-a-slow-relayed-connection` in optimystic.
|
|
75
|
+
* - **Cadre deadlines still typed as milliseconds.** The strand wake, strand-address and seed
|
|
76
|
+
* delivery deadlines, and the cohort read deadline, bound exchanges over the same link but do
|
|
77
|
+
* not derive from it: `tickets/backlog/debt-three-more-dial-deadlines-ignore-the-declared-link`.
|
|
78
|
+
* - **A machine that declares a faster link than its peers.** Every machine is the listener
|
|
79
|
+
* for the others, so its `inboundUpgradeTimeout` — derived from ITS declaration — bounds
|
|
80
|
+
* connections other machines open to it. A peer declaring 3 500 ms dialing a machine that
|
|
81
|
+
* declared 500 ms gets exactly the silent failure above. Declare the same link on every
|
|
82
|
+
* machine of a party.
|
|
75
83
|
*/
|
|
76
84
|
|
|
85
|
+
import type { Libp2pConnectionTimeouts } from '@optimystic/db-p2p';
|
|
86
|
+
|
|
77
87
|
/**
|
|
78
88
|
* The link round trip cadre assumes when a host declares none, in milliseconds. A deployment
|
|
79
89
|
* that knows its own link states it with `NetworkConfig.linkRoundTripMs` instead.
|
|
80
90
|
*
|
|
81
|
-
* **Why
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
91
|
+
* **Why 3 500.** Sereus supports two machines that reach each other only through a relay up to
|
|
92
|
+
* a **3-second link round trip** (1 500 ms each way — a congested mobile or satellite link;
|
|
93
|
+
* `docs/architecture.md` → "Relay Integration"). The worst relayed dial measured at that link
|
|
94
|
+
* was 12 094 ms, above the 12 000 ms a declaration of exactly 3 000 would derive, so the
|
|
95
|
+
* declaration rounds the supported link up to the next half second. 3 500 derives 14 000 ms
|
|
96
|
+
* and leaves about 1.9 s over that dial: room for a phone's Noise handshake, which the
|
|
97
|
+
* loopback measurement on a desktop does not contain. (The previous 2 000 followed the same
|
|
98
|
+
* convention for the 1.8 s band sereus supported before, with about 720 ms to spare.)
|
|
99
|
+
*
|
|
100
|
+
* Two budgets outside this module were sized against the 1.8 s band and do NOT move with it:
|
|
101
|
+
* `COHORT_READ_DEADLINE_MS` (`quereus-plugin-sereus/src/cluster-size.ts`, 5 000 ms) and
|
|
102
|
+
* `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS` (`strand-first-sync-gate.ts`). The module doc's
|
|
103
|
+
* "What still fails" list carries the first.
|
|
104
|
+
*
|
|
105
|
+
* What 3 500 costs, against 2 000: a peer that is genuinely gone holds each operation longer
|
|
106
|
+
* before it is abandoned and retried — a relayed dial or a reservation drive 14 s instead of
|
|
107
|
+
* 8 s, and a control-cohort dial of one peer across all its addresses 56 s instead of 32 s.
|
|
108
|
+
* Raise it further for a link slower still; lower it only on a deployment where EVERY machine
|
|
109
|
+
* of the party is that close, for the reason the "What still fails" list gives.
|
|
93
110
|
*/
|
|
94
|
-
export const DECLARED_LINK_ROUND_TRIP_MS =
|
|
111
|
+
export const DECLARED_LINK_ROUND_TRIP_MS = 3500;
|
|
95
112
|
|
|
96
113
|
/**
|
|
97
114
|
* Link round trips one relayed connection setup costs: transport, encryption and multiplexer
|
|
@@ -132,10 +149,10 @@ export const CIRCUIT_REQUEST_ROUND_TRIPS = 2;
|
|
|
132
149
|
*
|
|
133
150
|
* NOTE: 6 000 ms is not a measurement of any throughput — it is the residue of the 10 000 ms
|
|
134
151
|
* this deadline shipped as before it was split into a latency part and a transfer part, chosen
|
|
135
|
-
* so that at the
|
|
136
|
-
* slower. Nobody has measured how long 1 MiB takes to cross a relayed mobile
|
|
137
|
-
* ever time out with the transfer only part done, measure that and raise THIS,
|
|
138
|
-
* round trip.
|
|
152
|
+
* so that at the declaration then in force (2 000 ms) the derived value stayed 10 000 and no
|
|
153
|
+
* fast link got slower. Nobody has measured how long 1 MiB takes to cross a relayed mobile
|
|
154
|
+
* link. If pushes ever time out with the transfer only part done, measure that and raise THIS,
|
|
155
|
+
* not the declared round trip.
|
|
139
156
|
*/
|
|
140
157
|
export const PUSH_TRANSFER_ALLOWANCE_MS = 6000;
|
|
141
158
|
|
|
@@ -175,6 +192,36 @@ export function relayedDialBudgetMs(linkRoundTripMs?: number): number {
|
|
|
175
192
|
return RELAYED_DIAL_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs);
|
|
176
193
|
}
|
|
177
194
|
|
|
195
|
+
/**
|
|
196
|
+
* libp2p's two connection-manager limits for a node at the declared link, handed to
|
|
197
|
+
* `@optimystic/db-p2p`'s `NodeOptions.connectionManager` on the control node and every strand
|
|
198
|
+
* node.
|
|
199
|
+
*
|
|
200
|
+
* Both bound the same thing — opening a relayed connection, measured at 8 one-way link delays,
|
|
201
|
+
* which is {@link RELAYED_DIAL_ROUND_TRIPS} (4) link round trips — so both are
|
|
202
|
+
* {@link relayedDialBudgetMs}: 4 x 3 500 = 14 000 ms at the default declaration, covering the
|
|
203
|
+
* 12 094 ms measured at the supported 3-second link.
|
|
204
|
+
*
|
|
205
|
+
* The listener's limit is deliberately NOT smaller than the dialer's. The listener's clock
|
|
206
|
+
* starts only when the relay hands it the circuit, a few one-way delays after the dialer's
|
|
207
|
+
* started, and both sides finish the handshakes at about the same moment; so with equal limits
|
|
208
|
+
* the listener never discards a connection that the dialer's own dial would still accept. A
|
|
209
|
+
* listener limit below the dialer's is what produced the silent failure in the module doc.
|
|
210
|
+
*
|
|
211
|
+
* The cost of a longer `inboundUpgradeTimeout` is that a peer which opens a connection and then
|
|
212
|
+
* stalls its handshake holds that half-built connection 14 s instead of 10 s.
|
|
213
|
+
*
|
|
214
|
+
* NOTE: derived from the declaration alone, so a per-field override that raises a cadre dial
|
|
215
|
+
* budget above it (`controlCohort.perAddressDialTimeoutMs`, `strandBackfill.dialTimeoutMs`)
|
|
216
|
+
* does not raise these; a dial that outlasts the listener's limit gets the silent failure
|
|
217
|
+
* again. If such overrides start being used for slow links, raise the declaration instead or
|
|
218
|
+
* take the largest configured dial budget here.
|
|
219
|
+
*/
|
|
220
|
+
export function connectionManagerTimeouts(linkRoundTripMs?: number): Libp2pConnectionTimeouts {
|
|
221
|
+
const budgetMs = relayedDialBudgetMs(linkRoundTripMs);
|
|
222
|
+
return { dialTimeout: budgetMs, inboundUpgradeTimeout: budgetMs };
|
|
223
|
+
}
|
|
224
|
+
|
|
178
225
|
/**
|
|
179
226
|
* Deadline for one whole relay reservation drive: {@link RELAY_RESERVATION_ROUND_TRIPS} at the
|
|
180
227
|
* declared link round trip.
|