@serfab/cadre-core 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -274
- package/dist/cadre-invite-protocol.d.ts +383 -0
- package/dist/cadre-invite-protocol.js +774 -0
- package/dist/cadre-invite-protocol.js.map +1 -0
- package/dist/cadre-join-request.d.ts +42 -0
- package/dist/cadre-join-request.js +144 -0
- package/dist/cadre-join-request.js.map +1 -0
- package/dist/cadre-node.d.ts +849 -210
- package/dist/cadre-node.js +1574 -374
- package/dist/cadre-node.js.map +1 -1
- package/dist/claim-proof.d.ts +45 -0
- package/dist/claim-proof.js +132 -0
- package/dist/claim-proof.js.map +1 -0
- package/dist/control-authorization.d.ts +80 -19
- package/dist/control-authorization.js +55 -13
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +605 -108
- package/dist/control-database.js +1299 -280
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +142 -0
- package/dist/control-protocol-guard.js +293 -0
- package/dist/control-protocol-guard.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +817 -1067
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +4 -0
- package/dist/control-stream.js +22 -7
- package/dist/control-stream.js.map +1 -1
- package/dist/delegate-admission.d.ts +1 -1
- package/dist/delegate-admission.js +1 -1
- package/dist/device-token.d.ts +11 -9
- package/dist/device-token.js +14 -12
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +22 -9
- package/dist/ed25519-key.js +47 -10
- package/dist/ed25519-key.js.map +1 -1
- package/dist/fetch-impl.d.ts +12 -0
- package/dist/fetch-impl.js +23 -0
- package/dist/fetch-impl.js.map +1 -0
- package/dist/file-durable-slot.js +2 -1
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/file-safe-name.d.ts +25 -0
- package/dist/file-safe-name.js +29 -0
- package/dist/file-safe-name.js.map +1 -0
- package/dist/formation-approval.js +5 -11
- package/dist/formation-approval.js.map +1 -1
- package/dist/fs-atomic.d.ts +0 -19
- package/dist/fs-atomic.js +3 -24
- package/dist/fs-atomic.js.map +1 -1
- package/dist/index.d.ts +22 -10
- package/dist/index.js +54 -11
- package/dist/index.js.map +1 -1
- package/dist/invitation-bootstrap.d.ts +50 -12
- package/dist/invitation-bootstrap.js +76 -13
- package/dist/invitation-bootstrap.js.map +1 -1
- package/dist/joined-strand-store.d.ts +7 -0
- package/dist/joined-strand-store.js +9 -0
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store-file.js +2 -1
- package/dist/key-store-file.js.map +1 -1
- package/dist/link-budget.d.ts +25 -33
- package/dist/link-budget.js +25 -35
- package/dist/link-budget.js.map +1 -1
- package/dist/machine-removal.d.ts +78 -0
- package/dist/machine-removal.js +166 -0
- package/dist/machine-removal.js.map +1 -0
- package/dist/membership-connection-gater.d.ts +191 -151
- package/dist/membership-connection-gater.js +236 -173
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +74 -0
- package/dist/node-claim-payload.js +224 -0
- package/dist/node-claim-payload.js.map +1 -0
- package/dist/node-default-transports.d.ts +27 -0
- package/dist/node-default-transports.js +35 -0
- package/dist/node-default-transports.js.map +1 -0
- package/dist/node-transports.d.ts +30 -0
- package/dist/node-transports.js +40 -0
- package/dist/node-transports.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +22 -0
- package/dist/owner-anchor-sync.js +143 -0
- package/dist/owner-anchor-sync.js.map +1 -0
- package/dist/peer-authorization.d.ts +113 -18
- package/dist/peer-authorization.js +225 -29
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-dial.d.ts +143 -27
- package/dist/peer-dial.js +276 -31
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +26 -2
- package/dist/peer-join-backfill.js +60 -12
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +17 -52
- package/dist/peer-record.js +20 -58
- package/dist/peer-record.js.map +1 -1
- package/dist/pending-join-runner.d.ts +66 -58
- package/dist/pending-join-runner.js +136 -145
- package/dist/pending-join-runner.js.map +1 -1
- package/dist/primary-lan-address.d.ts +8 -0
- package/dist/primary-lan-address.js +46 -0
- package/dist/primary-lan-address.js.map +1 -0
- package/dist/provider/client.d.ts +131 -0
- package/dist/provider/client.js +226 -0
- package/dist/provider/client.js.map +1 -0
- package/dist/provider/descriptor.d.ts +41 -0
- package/dist/provider/descriptor.js +103 -0
- package/dist/provider/descriptor.js.map +1 -0
- package/dist/provider/index.d.ts +9 -0
- package/dist/provider/index.js +10 -0
- package/dist/provider/index.js.map +1 -0
- package/dist/provider/oauth.d.ts +125 -0
- package/dist/provider/oauth.js +199 -0
- package/dist/provider/oauth.js.map +1 -0
- package/dist/provider/rent-node.d.ts +101 -0
- package/dist/provider/rent-node.js +220 -0
- package/dist/provider/rent-node.js.map +1 -0
- package/dist/push-fanout.d.ts +8 -2
- package/dist/push-fanout.js +8 -2
- package/dist/push-fanout.js.map +1 -1
- package/dist/relay-addrs.d.ts +9 -4
- package/dist/relay-addrs.js +9 -4
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +51 -16
- package/dist/relay-reservation.js +107 -24
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +165 -123
- package/dist/seed-bootstrap.js +313 -275
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +98 -37
- package/dist/seed-trust-policy.js +188 -2
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +24 -26
- package/dist/strand-addr-protocol.js +19 -26
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-commit-durability.d.ts +54 -0
- package/dist/strand-commit-durability.js +74 -0
- package/dist/strand-commit-durability.js.map +1 -0
- package/dist/strand-database.d.ts +23 -8
- package/dist/strand-database.js +41 -13
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +7 -1
- package/dist/strand-first-sync-gate.js +7 -1
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-protocol.js +5 -2
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-fret-addrs.d.ts +19 -1
- package/dist/strand-fret-addrs.js +27 -9
- package/dist/strand-fret-addrs.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +81 -19
- package/dist/strand-instance-manager.js +216 -105
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-move.d.ts +124 -0
- package/dist/strand-move.js +438 -0
- package/dist/strand-move.js.map +1 -0
- package/dist/strand-network-config.js +7 -6
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-peer-redial.d.ts +113 -0
- package/dist/strand-peer-redial.js +236 -0
- package/dist/strand-peer-redial.js.map +1 -0
- package/dist/strand-revocation-enforcer.d.ts +11 -11
- package/dist/strand-revocation-enforcer.js +11 -11
- package/dist/strand-wake-protocol.d.ts +13 -16
- package/dist/strand-wake-protocol.js +23 -27
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/trusted-owner-store.d.ts +35 -5
- package/dist/trusted-owner-store.js +50 -8
- package/dist/trusted-owner-store.js.map +1 -1
- package/dist/types.d.ts +512 -89
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -1
- package/package.json +19 -10
- package/src/cadre-invite-protocol.ts +1052 -0
- package/src/cadre-join-request.ts +167 -0
- package/src/cadre-node.ts +1703 -386
- package/src/claim-proof.ts +147 -0
- package/src/control-authorization.ts +203 -117
- package/src/control-cohort.ts +1 -1
- package/src/control-database.ts +4663 -3453
- package/src/control-protocol-guard.ts +348 -0
- package/src/control-schema.ts +828 -1078
- package/src/control-stream.ts +26 -7
- package/src/delegate-admission.ts +1 -1
- package/src/device-token.ts +14 -10
- package/src/ed25519-key.ts +47 -10
- package/src/fetch-impl.ts +23 -0
- package/src/file-durable-slot.ts +64 -63
- package/src/file-safe-name.ts +32 -0
- package/src/formation-approval.ts +604 -610
- package/src/fs-atomic.ts +92 -117
- package/src/index.ts +789 -648
- package/src/invitation-bootstrap.ts +91 -13
- package/src/joined-strand-store.ts +508 -498
- package/src/key-store-file.ts +142 -141
- package/src/link-budget.ts +25 -36
- package/src/machine-removal.ts +216 -0
- package/src/membership-connection-gater.ts +259 -191
- package/src/node-claim-payload.ts +259 -0
- package/src/node-default-transports.ts +38 -0
- package/src/node-transports.ts +49 -0
- package/src/owner-anchor-sync.ts +171 -0
- package/src/peer-authorization.ts +258 -29
- package/src/peer-dial.ts +604 -315
- package/src/peer-join-backfill.ts +62 -11
- package/src/peer-record.ts +159 -197
- package/src/pending-join-runner.ts +729 -735
- package/src/primary-lan-address.ts +47 -0
- package/src/provider/client.ts +326 -0
- package/src/provider/descriptor.ts +129 -0
- package/src/provider/index.ts +59 -0
- package/src/provider/oauth.ts +285 -0
- package/src/provider/rent-node.ts +275 -0
- package/src/push-fanout.ts +8 -2
- package/src/relay-addrs.ts +9 -4
- package/src/relay-reservation.ts +1211 -1121
- package/src/seed-bootstrap.ts +1502 -1448
- package/src/seed-trust-policy.ts +353 -140
- package/src/strand-addr-protocol.ts +26 -35
- package/src/strand-commit-durability.ts +116 -0
- package/src/strand-database.ts +46 -14
- package/src/strand-first-sync-gate.ts +7 -1
- package/src/strand-formation-protocol.ts +1039 -1036
- package/src/strand-fret-addrs.ts +179 -145
- package/src/strand-instance-manager.ts +2026 -1883
- package/src/strand-move.ts +585 -0
- package/src/strand-network-config.ts +7 -6
- package/src/strand-peer-redial.ts +307 -0
- package/src/strand-revocation-enforcer.ts +11 -11
- package/src/strand-wake-protocol.ts +23 -30
- package/src/trusted-owner-store.ts +242 -171
- package/src/types.ts +2687 -2242
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moving a party's strands into another party of the same owner.
|
|
3
|
+
*
|
|
4
|
+
* A party's participation in a strand is control-database state: its `Strand` row (a strand
|
|
5
|
+
* it founded) or `JoinedStrand` row (one it joined), carrying the strand's shared read secret,
|
|
6
|
+
* and its `StrandPartyKey` row, the membership identity whose public half seats the party's
|
|
7
|
+
* `Member` / `Manager` rows in the strand database. Nothing in the strand database names a
|
|
8
|
+
* party, so a strand follows those rows: re-seated in another party under the same member key,
|
|
9
|
+
* on the same peer, the strand's other members see the same member on the same peer and
|
|
10
|
+
* nothing on the strand side changes.
|
|
11
|
+
*
|
|
12
|
+
* The case this serves is a phone app that founded a one-node cadre of its own (party B) and
|
|
13
|
+
* then joins the user's main cadre (party A) by switching its one node across
|
|
14
|
+
* (docs/architecture.md → "An app joins an existing cadre" → "Moving the app's strands"). Two
|
|
15
|
+
* nodes never run at once, so the move has two ends: {@link exportStrandBundle} reads everything
|
|
16
|
+
* B's node knows about its strands while it still runs, the embedder carries the bundle across
|
|
17
|
+
* the switch as data, and {@link adoptStrandBundle} seats it on A's node once the app's key is
|
|
18
|
+
* an owner there. Nothing is deleted in B; dissolving it is the embedder's later step, once
|
|
19
|
+
* {@link unheldBundleContent} finds nothing of B's export that A's {@link readStrandHoldings} lacks.
|
|
20
|
+
*
|
|
21
|
+
* Built over `CadreNode`'s public surface, so `cadre-node.ts` does not grow by the feature.
|
|
22
|
+
*/
|
|
23
|
+
import debug from 'debug';
|
|
24
|
+
import { sign } from '@optimystic/quereus-plugin-crypto';
|
|
25
|
+
import type { PersistedNetworkState } from '@optimystic/db-p2p';
|
|
26
|
+
import type { CadreNode } from './cadre-node.js';
|
|
27
|
+
import {
|
|
28
|
+
isPendingJoinConflict,
|
|
29
|
+
isStrandIdConflict,
|
|
30
|
+
pendingJoinId,
|
|
31
|
+
type ControlDatabase,
|
|
32
|
+
type JoinRequestFields
|
|
33
|
+
} from './control-database.js';
|
|
34
|
+
import { joinedStrandRow } from './joined-strand-store.js';
|
|
35
|
+
import type { PendingJoin, StrandRow } from './types.js';
|
|
36
|
+
|
|
37
|
+
const log = debug('sereus:cadre:strand-move');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Everything one party knows about its strands, as read from a running node of that party.
|
|
41
|
+
* Carries secrets: every read secret, every party key and every bearer invitation. JSON-safe,
|
|
42
|
+
* so an embedder can keep it in its secure store across a node switch.
|
|
43
|
+
*/
|
|
44
|
+
export interface StrandBundle {
|
|
45
|
+
v: 1;
|
|
46
|
+
/** The exporting party. */
|
|
47
|
+
partyId: string;
|
|
48
|
+
/** Epoch ms. */
|
|
49
|
+
exportedAt: number;
|
|
50
|
+
strands: StrandBundleEntry[];
|
|
51
|
+
/** Every row `queryPendingJoins` returned, outcome included. */
|
|
52
|
+
pendingJoins: PendingJoin[];
|
|
53
|
+
/** The invitations still redeemable at export. */
|
|
54
|
+
formationInvites: FormationInviteEntry[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface StrandBundleEntry {
|
|
58
|
+
/** A `Strand` row, or a `JoinedStrand` row / machine-local unpublished join. */
|
|
59
|
+
kind: 'own' | 'joined';
|
|
60
|
+
row: Pick<StrandRow, 'Id' | 'Type' | 'MemberPrivateKey'>;
|
|
61
|
+
/** `StrandPartyKey.PrivateKey` when the party holds one. */
|
|
62
|
+
partyKey: string | null;
|
|
63
|
+
/** The strand's saved routing table, opaque here; absent when the node held none. */
|
|
64
|
+
networkState?: PersistedNetworkState;
|
|
65
|
+
/** The addresses the strand's formation carried, in memory on the exporting node. */
|
|
66
|
+
carriedAddrs: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface FormationInviteEntry {
|
|
70
|
+
token: string;
|
|
71
|
+
sAppId: string;
|
|
72
|
+
expiresAtMs: number | null;
|
|
73
|
+
/** `TotalUses` minus the usage recorded so far; null is unlimited. */
|
|
74
|
+
usesLeft: number | null;
|
|
75
|
+
validationUrl: string | null;
|
|
76
|
+
/** A bound invitation's host strand. */
|
|
77
|
+
strandId: string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One item an adoption could not seat this time; retrying the same bundle will. */
|
|
81
|
+
export interface StrandMoveFailure {
|
|
82
|
+
kind: 'strand' | 'invitation' | 'pending-join';
|
|
83
|
+
/** The strand id, the join request id, or the id a join request for the invitation would carry (never the bearer token). */
|
|
84
|
+
id: string;
|
|
85
|
+
reason: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface StrandMoveReport {
|
|
89
|
+
fromPartyId: string;
|
|
90
|
+
/** Strand ids now seated in this party, those already there included. */
|
|
91
|
+
moved: string[];
|
|
92
|
+
/** Permanent: retrying will not help. */
|
|
93
|
+
refused: Array<{ strandId: string; reason: string }>;
|
|
94
|
+
/** Transient: adopt the same bundle again. */
|
|
95
|
+
failed: StrandMoveFailure[];
|
|
96
|
+
invitationsMoved: number;
|
|
97
|
+
invitationsDropped: number;
|
|
98
|
+
pendingJoinsMoved: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** {@link adoptStrandBundle} refused before any write: the running node's key is not an owner of its party. */
|
|
102
|
+
export class StrandMoveNotOwnerError extends Error {
|
|
103
|
+
constructor(readonly partyId: string) {
|
|
104
|
+
super(`Cannot adopt strands into party ${partyId}: this node's key is not one of its owners, and every re-seated row is owner-signed`);
|
|
105
|
+
this.name = 'StrandMoveNotOwnerError';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** {@link adoptStrandBundle} refused before any write: the bundle was exported from the party the node runs. */
|
|
110
|
+
export class StrandMoveSamePartyError extends Error {
|
|
111
|
+
constructor(readonly partyId: string) {
|
|
112
|
+
super(`Cannot adopt strands into party ${partyId}: the bundle was exported from that same party`);
|
|
113
|
+
this.name = 'StrandMoveSamePartyError';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read, from a running node, everything its party knows about its strands: the party's own
|
|
119
|
+
* strands, the strands it joined (party-wide rows and this machine's unpublished records), each
|
|
120
|
+
* with its party key, saved routing table and carried addresses; every pending join with its
|
|
121
|
+
* outcome; and every invitation still redeemable.
|
|
122
|
+
*
|
|
123
|
+
* @throws when the node is not started.
|
|
124
|
+
*/
|
|
125
|
+
export async function exportStrandBundle(node: CadreNode): Promise<StrandBundle> {
|
|
126
|
+
const database = requireControlDatabase(node, 'export a strand bundle');
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
const bundle: StrandBundle = {
|
|
129
|
+
v: 1,
|
|
130
|
+
partyId: node.partyId,
|
|
131
|
+
exportedAt: now,
|
|
132
|
+
strands: await exportStrands(node, database),
|
|
133
|
+
pendingJoins: await database.queryPendingJoins(),
|
|
134
|
+
formationInvites: await exportFormationInvites(database, now)
|
|
135
|
+
};
|
|
136
|
+
log('export(%s): %d strand(s), %d pending join(s), %d invitation(s)',
|
|
137
|
+
node.partyId, bundle.strands.length, bundle.pendingJoins.length, bundle.formationInvites.length);
|
|
138
|
+
return bundle;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** One entry per strand id, the party's own first: an id in both `Strand` and `JoinedStrand` exports once, as `own`. */
|
|
142
|
+
async function exportStrands(node: CadreNode, database: ControlDatabase): Promise<StrandBundleEntry[]> {
|
|
143
|
+
const entries = new Map<string, StrandBundleEntry>();
|
|
144
|
+
for (const row of await database.queryStrands()) {
|
|
145
|
+
entries.set(row.Id, await exportStrand(node, database, 'own', row));
|
|
146
|
+
}
|
|
147
|
+
// Publication needs a control connection, so a one-node party's joins are machine-local
|
|
148
|
+
// records until it holds one (a relay's or a foreign party's node counts) and `JoinedStrand`
|
|
149
|
+
// rows after. Both are read.
|
|
150
|
+
const unpublished = (await node.listUnpublishedJoinedStrands()).map(joinedStrandRow);
|
|
151
|
+
for (const row of [...await database.queryJoinedStrands(), ...unpublished]) {
|
|
152
|
+
if (!entries.has(row.Id)) {
|
|
153
|
+
entries.set(row.Id, await exportStrand(node, database, 'joined', row));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return [...entries.values()];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function exportStrand(
|
|
160
|
+
node: CadreNode,
|
|
161
|
+
database: ControlDatabase,
|
|
162
|
+
kind: StrandBundleEntry['kind'],
|
|
163
|
+
row: StrandRow
|
|
164
|
+
): Promise<StrandBundleEntry> {
|
|
165
|
+
const networkState = node.getStrandNetworkStateStore()?.load(row.Id);
|
|
166
|
+
return {
|
|
167
|
+
kind,
|
|
168
|
+
row: { Id: row.Id, Type: row.Type, MemberPrivateKey: row.MemberPrivateKey },
|
|
169
|
+
partyKey: await database.queryStrandPartyKey(row.Id),
|
|
170
|
+
...(networkState !== undefined && { networkState }),
|
|
171
|
+
carriedAddrs: node.getCarriedStrandAddrs(row.Id)
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The invitations still redeemable at export: unexpired, with uses left after the usage
|
|
177
|
+
* recorded so far. `FormationUsage` rows are append-only history and stay behind, so the
|
|
178
|
+
* adopting party's count restarts at zero against the reduced cap. A spent or expired
|
|
179
|
+
* invitation is left out: there is nothing to keep.
|
|
180
|
+
*/
|
|
181
|
+
async function exportFormationInvites(database: ControlDatabase, now: number): Promise<FormationInviteEntry[]> {
|
|
182
|
+
const entries: FormationInviteEntry[] = [];
|
|
183
|
+
for (const row of await database.queryFormationInvites()) {
|
|
184
|
+
if (isExpired(row.expiresAtMs, now)) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const usesLeft = row.totalUses === null ? null : row.totalUses - await database.countFormationUsage(row.token);
|
|
188
|
+
if (usesLeft !== null && usesLeft <= 0) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
entries.push({
|
|
192
|
+
token: row.token,
|
|
193
|
+
sAppId: row.sAppId,
|
|
194
|
+
expiresAtMs: row.expiresAtMs,
|
|
195
|
+
usesLeft,
|
|
196
|
+
validationUrl: row.validationUrl,
|
|
197
|
+
strandId: row.strandId
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return entries;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Seat `bundle` in the running node's party, every row owner-signed by the node's own identity
|
|
205
|
+
* key. Refuses up front, with a typed error and no write, when that key is not an owner of the
|
|
206
|
+
* party ({@link StrandMoveNotOwnerError}) or the bundle came from this very party
|
|
207
|
+
* ({@link StrandMoveSamePartyError}).
|
|
208
|
+
*
|
|
209
|
+
* Strands first, then the invitations, then the pending joins. Every write is idempotent (a row
|
|
210
|
+
* already present with the same content is skipped and counted as moved), so the same bundle
|
|
211
|
+
* can be adopted again after a crash or a transient write failure and finishes what is left.
|
|
212
|
+
* One strand's failure does not stop the others: it is reported under `failed` for the retry.
|
|
213
|
+
* One transaction over the whole bundle was weighed and declined: the per-row writers already
|
|
214
|
+
* carry the stamp, signature and retry policy, and the resumable bundle covers a crash between
|
|
215
|
+
* rows.
|
|
216
|
+
*
|
|
217
|
+
* @throws when the node is not started, beside the two typed refusals.
|
|
218
|
+
*/
|
|
219
|
+
export async function adoptStrandBundle(node: CadreNode, bundle: StrandBundle): Promise<StrandMoveReport> {
|
|
220
|
+
const database = requireControlDatabase(node, 'adopt a strand bundle');
|
|
221
|
+
if (bundle.partyId === node.partyId) {
|
|
222
|
+
throw new StrandMoveSamePartyError(node.partyId);
|
|
223
|
+
}
|
|
224
|
+
if (!(await node.wireOwnKeyIfOwner())) {
|
|
225
|
+
throw new StrandMoveNotOwnerError(node.partyId);
|
|
226
|
+
}
|
|
227
|
+
const owner = node.getIdentityOwnerKey();
|
|
228
|
+
const signer: OwnerSigner = { ownerKey: owner.publicKeyB64, signMessage: signMessageWith(owner.privateKeyB64) };
|
|
229
|
+
const report: StrandMoveReport = {
|
|
230
|
+
fromPartyId: bundle.partyId,
|
|
231
|
+
moved: [],
|
|
232
|
+
refused: [],
|
|
233
|
+
failed: [],
|
|
234
|
+
invitationsMoved: 0,
|
|
235
|
+
invitationsDropped: 0,
|
|
236
|
+
pendingJoinsMoved: 0
|
|
237
|
+
};
|
|
238
|
+
for (const entry of bundle.strands) {
|
|
239
|
+
await adoptStrand(node, database, signer, entry, report);
|
|
240
|
+
}
|
|
241
|
+
const settled = settledStrands(report);
|
|
242
|
+
const now = Date.now();
|
|
243
|
+
for (const invite of bundle.formationInvites) {
|
|
244
|
+
await adoptFormationInvite(node, database, invite, settled, report, now);
|
|
245
|
+
}
|
|
246
|
+
for (const join of bundle.pendingJoins) {
|
|
247
|
+
await adoptPendingJoin(database, signer, join, report);
|
|
248
|
+
}
|
|
249
|
+
log('adopt(%s ← %s): %d moved, %d refused, %d failed; %d invitation(s) moved, %d dropped; %d pending join(s)',
|
|
250
|
+
node.partyId, bundle.partyId, report.moved.length, report.refused.length, report.failed.length,
|
|
251
|
+
report.invitationsMoved, report.invitationsDropped, report.pendingJoinsMoved);
|
|
252
|
+
return report;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface OwnerSigner {
|
|
256
|
+
ownerKey: string;
|
|
257
|
+
signMessage: (message: Uint8Array) => string;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The strands an adoption has decided on, by outcome, for the invitations bound to them. */
|
|
261
|
+
interface SettledStrands {
|
|
262
|
+
moved: Set<string>;
|
|
263
|
+
failed: Set<string>;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function settledStrands(report: StrandMoveReport): SettledStrands {
|
|
267
|
+
return {
|
|
268
|
+
moved: new Set(report.moved),
|
|
269
|
+
failed: new Set(report.failed.filter((entry) => entry.kind === 'strand').map((entry) => entry.id))
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Seat one strand, in the order the adopting node's strand watcher demands. The watcher polls
|
|
275
|
+
* `Strand` and `JoinedStrand` every five seconds and launches, or offers, a new row at once; a
|
|
276
|
+
* closed strand's founder launch resolves the party key and, finding none, mints a fresh one,
|
|
277
|
+
* which would found the strand a second time under a stranger's identity. So the party key is
|
|
278
|
+
* seated before the row, and the strand's peers (its saved routing table and carried addresses)
|
|
279
|
+
* before both, so the launch's cohort seed has somewhere to dial. Every step is
|
|
280
|
+
* insert-if-absent.
|
|
281
|
+
*/
|
|
282
|
+
async function adoptStrand(
|
|
283
|
+
node: CadreNode,
|
|
284
|
+
database: ControlDatabase,
|
|
285
|
+
signer: OwnerSigner,
|
|
286
|
+
entry: StrandBundleEntry,
|
|
287
|
+
report: StrandMoveReport
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const strandId = entry.row.Id;
|
|
290
|
+
try {
|
|
291
|
+
const live = await readLiveStrand(database, strandId);
|
|
292
|
+
const refusal = strandRefusal(entry, live);
|
|
293
|
+
if (refusal !== null) {
|
|
294
|
+
log('adopt: strand %s refused: %s', strandId, refusal);
|
|
295
|
+
report.refused.push({ strandId, reason: refusal });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
await seatStrandPeers(node, entry);
|
|
299
|
+
if (entry.partyKey !== null) {
|
|
300
|
+
await node.ensureStrandPartyKey(strandId, entry.partyKey);
|
|
301
|
+
}
|
|
302
|
+
if (entry.kind === 'own') {
|
|
303
|
+
await node.publishStrand(strandId, entry.row.Type, entry.row.MemberPrivateKey ?? undefined);
|
|
304
|
+
} else {
|
|
305
|
+
await seatJoinedStrand(database, signer, entry.row, live.joined);
|
|
306
|
+
}
|
|
307
|
+
report.moved.push(strandId);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
log('adopt: strand %s (%s) not seated this time; adopt the same bundle again: %o', strandId, entry.kind, error);
|
|
310
|
+
report.failed.push({ kind: 'strand', id: strandId, reason: errorText(error) });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** What the adopting party already holds for a strand id. */
|
|
315
|
+
interface LiveStrand {
|
|
316
|
+
partyKey: string | null;
|
|
317
|
+
own: StrandRow | null;
|
|
318
|
+
joined: StrandRow | null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function readLiveStrand(database: ControlDatabase, strandId: string): Promise<LiveStrand> {
|
|
322
|
+
return {
|
|
323
|
+
partyKey: await database.queryStrandPartyKey(strandId),
|
|
324
|
+
own: await database.queryStrand(strandId),
|
|
325
|
+
joined: await database.queryJoinedStrand(strandId)
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Why `entry` can never be seated here, or null. A strand this party already participates in
|
|
331
|
+
* under its own identity keeps that membership: its `StrandPartyKey` is the key its `Member`
|
|
332
|
+
* row was seated under, and the bundle's key is another party's seat. A row for the id of the
|
|
333
|
+
* other kind (the party founded what the bundle joined, or joined what the bundle founded), or
|
|
334
|
+
* of the same kind with different content, is the same refusal. Decided from a read before any
|
|
335
|
+
* write, so a retry reports the refusal again instead of failing on a write; the writes
|
|
336
|
+
* re-check (`ensureStrandPartyKey` and `publishStrand` throw on a mismatch), so a row landing
|
|
337
|
+
* between the read and the write fails the strand this time and is refused on the retry.
|
|
338
|
+
*/
|
|
339
|
+
function strandRefusal(entry: StrandBundleEntry, live: LiveStrand): string | null {
|
|
340
|
+
const strandId = entry.row.Id;
|
|
341
|
+
if (live.partyKey !== null && live.partyKey !== entry.partyKey) {
|
|
342
|
+
return `this party already holds its own membership identity for strand ${strandId} and keeps that membership`;
|
|
343
|
+
}
|
|
344
|
+
if (entry.kind === 'own' && live.joined !== null) {
|
|
345
|
+
return `this party already participates in strand ${strandId} as a strand it joined and keeps that membership`;
|
|
346
|
+
}
|
|
347
|
+
if (entry.kind === 'joined' && live.own !== null) {
|
|
348
|
+
return `this party already holds strand ${strandId} as its own and keeps that membership`;
|
|
349
|
+
}
|
|
350
|
+
const same = entry.kind === 'own' ? live.own : live.joined;
|
|
351
|
+
if (same !== null && !sameStrandContent(same, entry.row)) {
|
|
352
|
+
return `this party already holds strand ${strandId} as ${entry.kind === 'own' ? 'its own' : 'a join'} with different content`;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The strand's saved routing table and carried addresses, when this node holds none for it: a
|
|
359
|
+
* launch here since an earlier attempt may have saved fresher state than the bundle carries.
|
|
360
|
+
*/
|
|
361
|
+
async function seatStrandPeers(node: CadreNode, entry: StrandBundleEntry): Promise<void> {
|
|
362
|
+
const strandId = entry.row.Id;
|
|
363
|
+
const store = node.getStrandNetworkStateStore();
|
|
364
|
+
if (entry.networkState !== undefined && store !== null && store.load(strandId) === undefined) {
|
|
365
|
+
await store.save(strandId, entry.networkState);
|
|
366
|
+
}
|
|
367
|
+
if (entry.carriedAddrs.length > 0 && node.getCarriedStrandAddrs(strandId).length === 0) {
|
|
368
|
+
await node.recordCarriedStrandAddrs(strandId, entry.carriedAddrs);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* `insertJoinedStrand`, read as done when the identical row is already there: `existing` from
|
|
374
|
+
* the refusal read (a retry), or one landing between that read and the insert (a sibling's
|
|
375
|
+
* publish; `strandRefusal` has already compared the content of the first, and the second is
|
|
376
|
+
* compared here).
|
|
377
|
+
*/
|
|
378
|
+
async function seatJoinedStrand(
|
|
379
|
+
database: ControlDatabase,
|
|
380
|
+
signer: OwnerSigner,
|
|
381
|
+
row: StrandBundleEntry['row'],
|
|
382
|
+
existing: StrandRow | null
|
|
383
|
+
): Promise<void> {
|
|
384
|
+
if (existing !== null) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
await database.insertJoinedStrand(row, signer.ownerKey, signer.signMessage);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (!isStrandIdConflict(error, 'JoinedStrand')) {
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
const landed = await database.queryJoinedStrand(row.Id);
|
|
394
|
+
if (landed === null || !sameStrandContent(landed, row)) {
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
log('adopt: joined strand %s landed concurrently with identical content; adopted', row.Id);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Copy one invitation the exporting party handed out. Its envelope names the app's machine,
|
|
403
|
+
* whose peer id and relay address do not change with the party, so the row here is what keeps
|
|
404
|
+
* an envelope already handed out redeemable. Dropped and counted: an invitation with a
|
|
405
|
+
* `ValidationUrl` (its sign-offs verify against a `ValidationKey` row, and which keys may
|
|
406
|
+
* validate here is this party's owners' decision) and one bound to a strand not seated here,
|
|
407
|
+
* whether refused above or no longer held by the exporting party (`unpublishStrand` leaves a
|
|
408
|
+
* bound row behind): the formation recorder would refuse every redemption as a missing host.
|
|
409
|
+
* Dropped silently: one that expired since the export. One bound to a strand that failed this
|
|
410
|
+
* time is left for the retry that seats the strand.
|
|
411
|
+
*/
|
|
412
|
+
async function adoptFormationInvite(
|
|
413
|
+
node: CadreNode,
|
|
414
|
+
database: ControlDatabase,
|
|
415
|
+
invite: FormationInviteEntry,
|
|
416
|
+
settled: SettledStrands,
|
|
417
|
+
report: StrandMoveReport,
|
|
418
|
+
now: number
|
|
419
|
+
): Promise<void> {
|
|
420
|
+
if (invite.strandId !== null && settled.failed.has(invite.strandId)) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const hostMissing = invite.strandId !== null && !settled.moved.has(invite.strandId);
|
|
424
|
+
if (invite.validationUrl !== null || hostMissing) {
|
|
425
|
+
report.invitationsDropped += 1;
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (isExpired(invite.expiresAtMs, now)) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
if (await database.queryFormationInvite(invite.token) === null) {
|
|
433
|
+
await node.publishFormationInvite(invite.token, invite.sAppId, {
|
|
434
|
+
expiresAtMs: invite.expiresAtMs ?? undefined,
|
|
435
|
+
totalUses: invite.usesLeft ?? undefined,
|
|
436
|
+
strandId: invite.strandId ?? undefined
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
report.invitationsMoved += 1;
|
|
440
|
+
} catch (error) {
|
|
441
|
+
log('adopt: invitation for sApp %s not seated this time; adopt the same bundle again: %o', invite.sAppId, error);
|
|
442
|
+
report.failed.push({ kind: 'invitation', id: pendingJoinId(invite.token), reason: errorText(error) });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Re-seat one join the exporting party asked for, with its outcome, under a fresh request
|
|
448
|
+
* stamp. A `joined` outcome's membership invitation matters: for a closed strand it is the seat
|
|
449
|
+
* the party's membership reconciler has not redeemed yet, and this node's retry loop re-stages
|
|
450
|
+
* it from the moved row at its next read. A `failed` outcome is history for the user to
|
|
451
|
+
* dismiss; the seven-day removal cleans it up.
|
|
452
|
+
*
|
|
453
|
+
* A request this party already holds for the invitation is read as done, whether an earlier
|
|
454
|
+
* adoption seated it or the user asked for the same invitation from here. Its outcome is written
|
|
455
|
+
* only when the live request is still pending and is this bundle's own (every request column
|
|
456
|
+
* equal), which closes the crash window between the request insert and its outcome insert
|
|
457
|
+
* without touching a request this party made on its own. A `joined` outcome whose strand was
|
|
458
|
+
* refused above moves too: its invitation is staged like any other, and the reconciler, finding
|
|
459
|
+
* the party's member row already seated, burns it unspent or drops it dead.
|
|
460
|
+
*
|
|
461
|
+
* NOTE: if party-private app tables land in the control database
|
|
462
|
+
* (tickets/blocked/decide-party-private-labels-and-app-state.md), they must move with the
|
|
463
|
+
* strands, and this is the site: one more per-row idempotent write beside the join.
|
|
464
|
+
*/
|
|
465
|
+
async function adoptPendingJoin(
|
|
466
|
+
database: ControlDatabase,
|
|
467
|
+
signer: OwnerSigner,
|
|
468
|
+
join: PendingJoin,
|
|
469
|
+
report: StrandMoveReport
|
|
470
|
+
): Promise<void> {
|
|
471
|
+
const { StampId: _stampId, outcome, ...request } = join;
|
|
472
|
+
try {
|
|
473
|
+
const live = await seatJoinRequest(database, signer, request);
|
|
474
|
+
if (live !== null && outcome !== null && live.outcome === null) {
|
|
475
|
+
await database.recordJoinOutcome(live, outcome, signer.ownerKey, signer.signMessage);
|
|
476
|
+
}
|
|
477
|
+
report.pendingJoinsMoved += 1;
|
|
478
|
+
} catch (error) {
|
|
479
|
+
log('adopt: pending join %s not seated this time; adopt the same bundle again: %o', join.Id, error);
|
|
480
|
+
report.failed.push({ kind: 'pending-join', id: join.Id, reason: errorText(error) });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The live request for `request` after its insert: the row just written, the identical row
|
|
486
|
+
* already there, or null when the party holds a different request for the invitation (its
|
|
487
|
+
* own ask, left alone) or a dismissed one whose retired stamp is still held until the reap
|
|
488
|
+
* pass (the party chose not to have it).
|
|
489
|
+
*/
|
|
490
|
+
async function seatJoinRequest(database: ControlDatabase, signer: OwnerSigner, request: JoinRequestFields): Promise<PendingJoin | null> {
|
|
491
|
+
try {
|
|
492
|
+
return await database.insertJoinRequest(request, signer.ownerKey, signer.signMessage);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
if (!isPendingJoinConflict(error)) {
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
const existing = await database.queryPendingJoin(request.Id);
|
|
498
|
+
return existing !== null && sameRequest(existing, request) ? existing : null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function sameRequest(live: PendingJoin, request: JoinRequestFields): boolean {
|
|
503
|
+
return live.Invitation === request.Invitation
|
|
504
|
+
&& live.Disclosure === request.Disclosure
|
|
505
|
+
&& live.RequestedAt === request.RequestedAt
|
|
506
|
+
&& live.ExpiresAt === request.ExpiresAt;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** What a party already holds of strands and joins, read from its running node. */
|
|
510
|
+
export interface StrandHoldings {
|
|
511
|
+
strandIds: ReadonlySet<string>;
|
|
512
|
+
pendingJoinIds: ReadonlySet<string>;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Every strand id the node's party holds, of either kind (`Strand`, `JoinedStrand` and this
|
|
517
|
+
* machine's unpublished joins), and every pending join id it holds, whatever the outcome.
|
|
518
|
+
*
|
|
519
|
+
* @throws when the node is not started.
|
|
520
|
+
*/
|
|
521
|
+
export async function readStrandHoldings(node: CadreNode): Promise<StrandHoldings> {
|
|
522
|
+
const database = requireControlDatabase(node, 'read its strand holdings');
|
|
523
|
+
const strands = [
|
|
524
|
+
...await database.queryStrands(),
|
|
525
|
+
...await database.queryJoinedStrands(),
|
|
526
|
+
...await node.listUnpublishedJoinedStrands()
|
|
527
|
+
];
|
|
528
|
+
return {
|
|
529
|
+
strandIds: new Set(strands.map((row) => row.Id)),
|
|
530
|
+
pendingJoinIds: new Set((await database.queryPendingJoins()).map((join) => join.Id))
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* What of `bundle` a party with `holdings` lacks, so the exporting party can be dissolved only
|
|
536
|
+
* when this is empty. A strand counts as held under any kind: one the adoption refused is in
|
|
537
|
+
* the holdings (that is why it was refused), so its data stays reachable and only the
|
|
538
|
+
* exporting party's seat in it is lost. A pending join counts only while open at `nowMs`: a
|
|
539
|
+
* `joined` one's strand is covered by the strand check, a `failed` one is history, and an
|
|
540
|
+
* expired one can never be attempted. Formation invitations are not compared: the exporting
|
|
541
|
+
* node never runs again once dissolved, so one the adoption dropped was unredeemable from then.
|
|
542
|
+
*/
|
|
543
|
+
export function unheldBundleContent(bundle: StrandBundle, holdings: StrandHoldings, nowMs: number): {
|
|
544
|
+
strandIds: string[];
|
|
545
|
+
pendingJoinIds: string[];
|
|
546
|
+
} {
|
|
547
|
+
return {
|
|
548
|
+
strandIds: bundle.strands.map((entry) => entry.row.Id).filter((strandId) => !holdings.strandIds.has(strandId)),
|
|
549
|
+
pendingJoinIds: bundle.pendingJoins
|
|
550
|
+
.filter((join) => isOpenJoin(join, nowMs) && !holdings.pendingJoinIds.has(join.Id))
|
|
551
|
+
.map((join) => join.Id)
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** No outcome yet, and before `ExpiresAt`: the pending-join runner attempts nothing at or after it. */
|
|
556
|
+
function isOpenJoin(join: PendingJoin, nowMs: number): boolean {
|
|
557
|
+
return join.outcome === null && nowMs < join.ExpiresAt;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Content only: `Type` and the read secret. `FounderOwnerKey` is provenance, and a moved strand's changes. */
|
|
561
|
+
function sameStrandContent(live: StrandRow, desired: StrandBundleEntry['row']): boolean {
|
|
562
|
+
return live.Type === desired.Type && (live.MemberPrivateKey ?? null) === (desired.MemberPrivateKey ?? null);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** The comparison `hasOutstandingFormationInvite` makes, so an invitation the responder would refuse is never copied. */
|
|
566
|
+
function isExpired(expiresAtMs: number | null, now: number): boolean {
|
|
567
|
+
return expiresAtMs !== null && expiresAtMs <= now;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function requireControlDatabase(node: CadreNode, action: string): ControlDatabase {
|
|
571
|
+
const database = node.getControlDatabase();
|
|
572
|
+
if (database === null) {
|
|
573
|
+
throw new Error(`CadreNode must be started before it can ${action}`);
|
|
574
|
+
}
|
|
575
|
+
return database;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function signMessageWith(privateKeyB64: string): (message: Uint8Array) => string {
|
|
579
|
+
return (message: Uint8Array): string =>
|
|
580
|
+
sign(message, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function errorText(error: unknown): string {
|
|
584
|
+
return error instanceof Error ? error.message : String(error);
|
|
585
|
+
}
|
|
@@ -143,13 +143,14 @@ export function strandNodeAddrs(network: NetworkConfig | undefined): StrandNodeA
|
|
|
143
143
|
const kept = relayAddrs.length > 0 ? direct.filter((addr) => !isCircuitListenAddr(addr)) : direct;
|
|
144
144
|
// The search entries are appended AFTER the dedupe on purpose: they are identical
|
|
145
145
|
// strings, and each one must survive as its own listener (see the module doc).
|
|
146
|
+
// A node given these entries is built with libp2p's relay discovery off whenever it
|
|
147
|
+
// has a transport list to wrap (`node-transports.ts`), so an empty slot starts no
|
|
148
|
+
// peer-store scan or random walk; only its supervisor fills it.
|
|
146
149
|
//
|
|
147
|
-
// NOTE:
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
// (
|
|
151
|
-
// a peer router (a DHT), that walk turns into real dials on every relay loss;
|
|
152
|
-
// cap or disable `circuitRelayTransport`'s discovery then.
|
|
150
|
+
// NOTE: where discovery stays on (a Node host that passed no `defaultTransports`),
|
|
151
|
+
// each empty slot still runs one peer-store scan plus a random walk that fails at once
|
|
152
|
+
// (`NoPeerRoutersError`), measured on a node with no peer router. If strand nodes ever
|
|
153
|
+
// get a peer router (a DHT), that walk turns into real dials on every relay loss.
|
|
153
154
|
const listenAddrs = [
|
|
154
155
|
...dedupe(kept.map(ephemeralPortListenAddr)),
|
|
155
156
|
...relayAddrs.map(() => RELAY_SEARCH_LISTEN_ADDR)
|