@serfab/cadre-core 1.14.0 → 1.16.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.
Files changed (171) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +64 -29
  3. package/dist/cadre-invite-protocol.js +207 -38
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +375 -79
  9. package/dist/cadre-node.js +758 -186
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-database.d.ts +92 -12
  12. package/dist/control-database.js +323 -142
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-schema.d.ts +1 -1
  15. package/dist/control-schema.js +19 -3
  16. package/dist/control-schema.js.map +1 -1
  17. package/dist/ed25519-key.d.ts +7 -9
  18. package/dist/ed25519-key.js +20 -10
  19. package/dist/ed25519-key.js.map +1 -1
  20. package/dist/fetch-impl.d.ts +12 -0
  21. package/dist/fetch-impl.js +23 -0
  22. package/dist/fetch-impl.js.map +1 -0
  23. package/dist/file-durable-slot.js +2 -1
  24. package/dist/file-durable-slot.js.map +1 -1
  25. package/dist/file-safe-name.d.ts +25 -0
  26. package/dist/file-safe-name.js +29 -0
  27. package/dist/file-safe-name.js.map +1 -0
  28. package/dist/formation-approval.js +5 -11
  29. package/dist/formation-approval.js.map +1 -1
  30. package/dist/fs-atomic.d.ts +0 -19
  31. package/dist/fs-atomic.js +3 -24
  32. package/dist/fs-atomic.js.map +1 -1
  33. package/dist/index.d.ts +12 -5
  34. package/dist/index.js +31 -6
  35. package/dist/index.js.map +1 -1
  36. package/dist/invitation-bootstrap.d.ts +50 -12
  37. package/dist/invitation-bootstrap.js +76 -13
  38. package/dist/invitation-bootstrap.js.map +1 -1
  39. package/dist/joined-strand-store.d.ts +7 -0
  40. package/dist/joined-strand-store.js +9 -0
  41. package/dist/joined-strand-store.js.map +1 -1
  42. package/dist/key-store-file.js +2 -1
  43. package/dist/key-store-file.js.map +1 -1
  44. package/dist/link-budget.d.ts +9 -4
  45. package/dist/link-budget.js +9 -4
  46. package/dist/link-budget.js.map +1 -1
  47. package/dist/machine-removal.d.ts +78 -0
  48. package/dist/machine-removal.js +166 -0
  49. package/dist/machine-removal.js.map +1 -0
  50. package/dist/membership-connection-gater.d.ts +6 -5
  51. package/dist/membership-connection-gater.js +6 -5
  52. package/dist/membership-connection-gater.js.map +1 -1
  53. package/dist/node-claim-payload.d.ts +55 -0
  54. package/dist/node-claim-payload.js +108 -4
  55. package/dist/node-claim-payload.js.map +1 -1
  56. package/dist/node-default-transports.d.ts +27 -0
  57. package/dist/node-default-transports.js +35 -0
  58. package/dist/node-default-transports.js.map +1 -0
  59. package/dist/node-transports.d.ts +30 -0
  60. package/dist/node-transports.js +40 -0
  61. package/dist/node-transports.js.map +1 -0
  62. package/dist/peer-dial.d.ts +124 -27
  63. package/dist/peer-dial.js +254 -30
  64. package/dist/peer-dial.js.map +1 -1
  65. package/dist/peer-join-backfill.js +6 -0
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/peer-record.d.ts +6 -45
  68. package/dist/peer-record.js +8 -50
  69. package/dist/peer-record.js.map +1 -1
  70. package/dist/primary-lan-address.d.ts +8 -0
  71. package/dist/primary-lan-address.js +46 -0
  72. package/dist/primary-lan-address.js.map +1 -0
  73. package/dist/provider/client.d.ts +131 -0
  74. package/dist/provider/client.js +226 -0
  75. package/dist/provider/client.js.map +1 -0
  76. package/dist/provider/descriptor.d.ts +41 -0
  77. package/dist/provider/descriptor.js +103 -0
  78. package/dist/provider/descriptor.js.map +1 -0
  79. package/dist/provider/index.d.ts +9 -0
  80. package/dist/provider/index.js +10 -0
  81. package/dist/provider/index.js.map +1 -0
  82. package/dist/provider/oauth.d.ts +125 -0
  83. package/dist/provider/oauth.js +199 -0
  84. package/dist/provider/oauth.js.map +1 -0
  85. package/dist/provider/rent-node.d.ts +101 -0
  86. package/dist/provider/rent-node.js +220 -0
  87. package/dist/provider/rent-node.js.map +1 -0
  88. package/dist/relay-addrs.d.ts +9 -4
  89. package/dist/relay-addrs.js +9 -4
  90. package/dist/relay-addrs.js.map +1 -1
  91. package/dist/relay-reservation.d.ts +51 -16
  92. package/dist/relay-reservation.js +107 -24
  93. package/dist/relay-reservation.js.map +1 -1
  94. package/dist/seed-bootstrap.d.ts +22 -5
  95. package/dist/seed-bootstrap.js +48 -25
  96. package/dist/seed-bootstrap.js.map +1 -1
  97. package/dist/strand-commit-durability.d.ts +54 -0
  98. package/dist/strand-commit-durability.js +74 -0
  99. package/dist/strand-commit-durability.js.map +1 -0
  100. package/dist/strand-database.d.ts +23 -8
  101. package/dist/strand-database.js +41 -13
  102. package/dist/strand-database.js.map +1 -1
  103. package/dist/strand-first-sync-gate.d.ts +7 -1
  104. package/dist/strand-first-sync-gate.js +7 -1
  105. package/dist/strand-first-sync-gate.js.map +1 -1
  106. package/dist/strand-formation-protocol.js +4 -1
  107. package/dist/strand-formation-protocol.js.map +1 -1
  108. package/dist/strand-fret-addrs.d.ts +19 -1
  109. package/dist/strand-fret-addrs.js +27 -9
  110. package/dist/strand-fret-addrs.js.map +1 -1
  111. package/dist/strand-instance-manager.d.ts +81 -19
  112. package/dist/strand-instance-manager.js +216 -105
  113. package/dist/strand-instance-manager.js.map +1 -1
  114. package/dist/strand-move.d.ts +124 -0
  115. package/dist/strand-move.js +438 -0
  116. package/dist/strand-move.js.map +1 -0
  117. package/dist/strand-network-config.js +7 -6
  118. package/dist/strand-network-config.js.map +1 -1
  119. package/dist/strand-peer-redial.d.ts +113 -0
  120. package/dist/strand-peer-redial.js +236 -0
  121. package/dist/strand-peer-redial.js.map +1 -0
  122. package/dist/strand-wake-protocol.js +10 -7
  123. package/dist/strand-wake-protocol.js.map +1 -1
  124. package/dist/types.d.ts +212 -4
  125. package/dist/types.js +10 -0
  126. package/dist/types.js.map +1 -1
  127. package/package.json +18 -10
  128. package/src/cadre-invite-protocol.ts +1052 -864
  129. package/src/cadre-join-request.ts +167 -0
  130. package/src/cadre-node.ts +10003 -9381
  131. package/src/control-database.ts +4663 -4451
  132. package/src/control-schema.ts +19 -3
  133. package/src/ed25519-key.ts +20 -10
  134. package/src/fetch-impl.ts +23 -0
  135. package/src/file-durable-slot.ts +64 -63
  136. package/src/file-safe-name.ts +32 -0
  137. package/src/formation-approval.ts +604 -610
  138. package/src/fs-atomic.ts +92 -117
  139. package/src/index.ts +71 -4
  140. package/src/invitation-bootstrap.ts +91 -13
  141. package/src/joined-strand-store.ts +508 -498
  142. package/src/key-store-file.ts +142 -141
  143. package/src/link-budget.ts +9 -4
  144. package/src/machine-removal.ts +216 -0
  145. package/src/membership-connection-gater.ts +638 -637
  146. package/src/node-claim-payload.ts +138 -4
  147. package/src/node-default-transports.ts +38 -0
  148. package/src/node-transports.ts +49 -0
  149. package/src/peer-dial.ts +604 -336
  150. package/src/peer-join-backfill.ts +6 -0
  151. package/src/peer-record.ts +159 -202
  152. package/src/primary-lan-address.ts +47 -0
  153. package/src/provider/client.ts +326 -0
  154. package/src/provider/descriptor.ts +129 -0
  155. package/src/provider/index.ts +59 -0
  156. package/src/provider/oauth.ts +285 -0
  157. package/src/provider/rent-node.ts +275 -0
  158. package/src/relay-addrs.ts +9 -4
  159. package/src/relay-reservation.ts +1211 -1121
  160. package/src/seed-bootstrap.ts +1502 -1471
  161. package/src/strand-commit-durability.ts +116 -0
  162. package/src/strand-database.ts +46 -14
  163. package/src/strand-first-sync-gate.ts +7 -1
  164. package/src/strand-formation-protocol.ts +1039 -1036
  165. package/src/strand-fret-addrs.ts +179 -145
  166. package/src/strand-instance-manager.ts +2026 -1883
  167. package/src/strand-move.ts +585 -0
  168. package/src/strand-network-config.ts +7 -6
  169. package/src/strand-peer-redial.ts +307 -0
  170. package/src/strand-wake-protocol.ts +10 -7
  171. package/src/types.ts +220 -4
@@ -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: every bare entry also makes libp2p start its own relay discovery whenever
148
- // that listener's slot is empty (launch, and every loss). Measured on a node with
149
- // no peer router: one peer-store scan plus a random walk that fails at once
150
- // (`NoPeerRoutersError`) per loss event — no dial churn. If strand nodes ever get
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)