@serfab/cadre-core 1.7.0 → 1.9.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 (188) hide show
  1. package/README.md +8 -6
  2. package/dist/cadre-node.d.ts +527 -206
  3. package/dist/cadre-node.js +1084 -528
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-authorization.d.ts +2 -2
  6. package/dist/control-authorization.js +1 -0
  7. package/dist/control-authorization.js.map +1 -1
  8. package/dist/control-cohort.d.ts +6 -0
  9. package/dist/control-cohort.js +6 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +46 -8
  12. package/dist/control-database.js +97 -18
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +37 -14
  15. package/dist/control-formation-recorder.js +60 -10
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +13 -12
  18. package/dist/control-read-retry.js +14 -12
  19. package/dist/control-read-retry.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +67 -5
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +9 -1
  24. package/dist/control-stream.js +24 -1
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +12 -2
  27. package/dist/control-write-retry.js +13 -2
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +11 -8
  30. package/dist/delegate-admission.js +19 -11
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/diagnostics/connection-path.d.ts +0 -7
  33. package/dist/diagnostics/connection-path.js +0 -7
  34. package/dist/diagnostics/connection-path.js.map +1 -1
  35. package/dist/formation-approval.d.ts +7 -0
  36. package/dist/formation-approval.js +9 -3
  37. package/dist/formation-approval.js.map +1 -1
  38. package/dist/fs-atomic.d.ts +11 -2
  39. package/dist/fs-atomic.js +13 -3
  40. package/dist/fs-atomic.js.map +1 -1
  41. package/dist/hibernation-manager.d.ts +111 -8
  42. package/dist/hibernation-manager.js +234 -49
  43. package/dist/hibernation-manager.js.map +1 -1
  44. package/dist/index.d.ts +10 -10
  45. package/dist/index.js +25 -20
  46. package/dist/index.js.map +1 -1
  47. package/dist/joined-strand-store.d.ts +90 -25
  48. package/dist/joined-strand-store.js +223 -55
  49. package/dist/joined-strand-store.js.map +1 -1
  50. package/dist/key-store-file.js +14 -5
  51. package/dist/key-store-file.js.map +1 -1
  52. package/dist/link-budget.d.ts +367 -69
  53. package/dist/link-budget.js +375 -72
  54. package/dist/link-budget.js.map +1 -1
  55. package/dist/membership-connection-gater.d.ts +30 -43
  56. package/dist/membership-connection-gater.js +31 -42
  57. package/dist/membership-connection-gater.js.map +1 -1
  58. package/dist/node-local-snapshot.d.ts +6 -4
  59. package/dist/node-local-snapshot.js +7 -3
  60. package/dist/node-local-snapshot.js.map +1 -1
  61. package/dist/peer-dial.d.ts +24 -4
  62. package/dist/peer-dial.js +74 -5
  63. package/dist/peer-dial.js.map +1 -1
  64. package/dist/peer-join-backfill.d.ts +2 -10
  65. package/dist/peer-join-backfill.js +8 -8
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/push-fanout.d.ts +6 -0
  68. package/dist/push-fanout.js +6 -0
  69. package/dist/push-fanout.js.map +1 -1
  70. package/dist/relay-reservation.d.ts +3 -1
  71. package/dist/relay-reservation.js +3 -1
  72. package/dist/relay-reservation.js.map +1 -1
  73. package/dist/relay-server.d.ts +79 -0
  74. package/dist/relay-server.js +74 -0
  75. package/dist/relay-server.js.map +1 -0
  76. package/dist/seed-bootstrap.d.ts +57 -4
  77. package/dist/seed-bootstrap.js +107 -83
  78. package/dist/seed-bootstrap.js.map +1 -1
  79. package/dist/storage-scope.d.ts +33 -17
  80. package/dist/storage-scope.js +44 -23
  81. package/dist/storage-scope.js.map +1 -1
  82. package/dist/strand-addr-protocol.d.ts +58 -21
  83. package/dist/strand-addr-protocol.js +133 -85
  84. package/dist/strand-addr-protocol.js.map +1 -1
  85. package/dist/strand-database.d.ts +20 -2
  86. package/dist/strand-database.js +42 -5
  87. package/dist/strand-database.js.map +1 -1
  88. package/dist/strand-first-sync-gate.d.ts +59 -21
  89. package/dist/strand-first-sync-gate.js +58 -19
  90. package/dist/strand-first-sync-gate.js.map +1 -1
  91. package/dist/strand-formation-deadlines.d.ts +151 -0
  92. package/dist/strand-formation-deadlines.js +171 -0
  93. package/dist/strand-formation-deadlines.js.map +1 -0
  94. package/dist/strand-formation-manager.d.ts +63 -30
  95. package/dist/strand-formation-manager.js +67 -41
  96. package/dist/strand-formation-manager.js.map +1 -1
  97. package/dist/strand-formation-protocol.d.ts +45 -46
  98. package/dist/strand-formation-protocol.js +62 -126
  99. package/dist/strand-formation-protocol.js.map +1 -1
  100. package/dist/strand-fret-addrs.d.ts +54 -0
  101. package/dist/strand-fret-addrs.js +108 -0
  102. package/dist/strand-fret-addrs.js.map +1 -0
  103. package/dist/strand-instance-manager.d.ts +122 -44
  104. package/dist/strand-instance-manager.js +270 -114
  105. package/dist/strand-instance-manager.js.map +1 -1
  106. package/dist/strand-membership-writer.d.ts +6 -0
  107. package/dist/strand-membership-writer.js +11 -0
  108. package/dist/strand-membership-writer.js.map +1 -1
  109. package/dist/strand-network-state-file.d.ts +12 -0
  110. package/dist/strand-network-state-file.js +38 -0
  111. package/dist/strand-network-state-file.js.map +1 -0
  112. package/dist/strand-network-state.d.ts +111 -0
  113. package/dist/strand-network-state.js +191 -0
  114. package/dist/strand-network-state.js.map +1 -0
  115. package/dist/strand-revocation-enforcer.js +2 -1
  116. package/dist/strand-revocation-enforcer.js.map +1 -1
  117. package/dist/strand-solicitation.d.ts +36 -20
  118. package/dist/strand-solicitation.js +4 -4
  119. package/dist/strand-solicitation.js.map +1 -1
  120. package/dist/strand-wake-protocol.d.ts +80 -23
  121. package/dist/strand-wake-protocol.js +89 -64
  122. package/dist/strand-wake-protocol.js.map +1 -1
  123. package/dist/types.d.ts +148 -61
  124. package/dist/types.js +4 -3
  125. package/dist/types.js.map +1 -1
  126. package/package.json +11 -10
  127. package/src/cadre-node.ts +8400 -7776
  128. package/src/control-authorization.ts +2 -1
  129. package/src/control-cohort.ts +6 -0
  130. package/src/control-database.ts +122 -18
  131. package/src/control-formation-recorder.ts +354 -305
  132. package/src/control-read-retry.ts +14 -12
  133. package/src/control-schema.ts +67 -5
  134. package/src/control-stream.ts +25 -1
  135. package/src/control-write-retry.ts +13 -2
  136. package/src/delegate-admission.ts +23 -13
  137. package/src/diagnostics/connection-path.ts +0 -7
  138. package/src/formation-approval.ts +610 -604
  139. package/src/fs-atomic.ts +14 -4
  140. package/src/hibernation-manager.ts +638 -437
  141. package/src/index.ts +43 -65
  142. package/src/joined-strand-store.ts +247 -57
  143. package/src/key-store-file.ts +14 -5
  144. package/src/link-budget.ts +393 -74
  145. package/src/membership-connection-gater.ts +31 -45
  146. package/src/node-local-snapshot.ts +12 -7
  147. package/src/peer-dial.ts +81 -7
  148. package/src/peer-join-backfill.ts +10 -9
  149. package/src/push-fanout.ts +6 -0
  150. package/src/relay-reservation.ts +3 -1
  151. package/src/relay-server.ts +98 -0
  152. package/src/seed-bootstrap.ts +133 -91
  153. package/src/storage-scope.ts +44 -23
  154. package/src/strand-addr-protocol.ts +547 -458
  155. package/src/strand-database.ts +287 -242
  156. package/src/strand-first-sync-gate.ts +58 -19
  157. package/src/strand-formation-deadlines.ts +227 -0
  158. package/src/strand-formation-manager.ts +700 -647
  159. package/src/strand-formation-protocol.ts +106 -154
  160. package/src/strand-fret-addrs.ts +145 -0
  161. package/src/strand-instance-manager.ts +307 -140
  162. package/src/strand-membership-writer.ts +12 -0
  163. package/src/strand-network-state-file.ts +40 -0
  164. package/src/strand-network-state.ts +243 -0
  165. package/src/strand-revocation-enforcer.ts +2 -1
  166. package/src/strand-solicitation.ts +519 -500
  167. package/src/strand-wake-protocol.ts +401 -355
  168. package/src/types.ts +150 -61
  169. package/dist/strand-peer-book-file.d.ts +0 -12
  170. package/dist/strand-peer-book-file.js +0 -37
  171. package/dist/strand-peer-book-file.js.map +0 -1
  172. package/dist/strand-peer-book-protocol.d.ts +0 -159
  173. package/dist/strand-peer-book-protocol.js +0 -360
  174. package/dist/strand-peer-book-protocol.js.map +0 -1
  175. package/dist/strand-peer-book-swap.d.ts +0 -104
  176. package/dist/strand-peer-book-swap.js +0 -284
  177. package/dist/strand-peer-book-swap.js.map +0 -1
  178. package/dist/strand-peer-book.d.ts +0 -159
  179. package/dist/strand-peer-book.js +0 -354
  180. package/dist/strand-peer-book.js.map +0 -1
  181. package/dist/strand-peer-observer.d.ts +0 -88
  182. package/dist/strand-peer-observer.js +0 -180
  183. package/dist/strand-peer-observer.js.map +0 -1
  184. package/src/strand-peer-book-file.ts +0 -39
  185. package/src/strand-peer-book-protocol.ts +0 -463
  186. package/src/strand-peer-book-swap.ts +0 -338
  187. package/src/strand-peer-book.ts +0 -468
  188. package/src/strand-peer-observer.ts +0 -235
package/src/index.ts CHANGED
@@ -117,10 +117,10 @@ 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.
120
+ // Node-local record of the strands this node joined from ANOTHER party and has not yet
121
+ // published party-wide, re-offered on every start as `strand:discovered`.
122
+ // Cross-platform: its durable form rides the KeyStore seam above (the record carries a
123
+ // closed strand's read secret), so there is no file-backed subpath.
124
124
  export {
125
125
  MemoryJoinedStrandStore,
126
126
  KeyStoreJoinedStrandStore,
@@ -128,66 +128,27 @@ export {
128
128
  type JoinedStrandRecord
129
129
  } from './joined-strand-store.js';
130
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';
131
+ // Node-local strand network state: per strand, the state Optimystic's db-p2p saves for
132
+ // the strand node (its FRET routing table with each peer's signed address record), so a
133
+ // restarted strand node re-imports the peers it was talking to. Same cross-platform
134
+ // split as the bootstrap-peer store above — interface + in-memory + slot-backed stores
135
+ // here, Node-only file backend behind '@serfab/cadre-core/strand-network-state-file'.
136
+ export {
137
+ MemoryStrandNetworkStateStore,
138
+ PersistentStrandNetworkStateStore,
139
+ strandNetworkStatePersistence,
140
+ type StrandNetworkStateStore
141
+ } from './strand-network-state.js';
142
+ // The signed address records a running strand node's FRET table holds, as per-peer dial
143
+ // addresses: what the periodic refresh re-merges into the node's address book, and a
144
+ // diagnostic for "whom could this strand node redial right now?".
145
+ export { strandFretPeerAddrs, type FretAddrHost, type FretPeerAddrs } from './strand-fret-addrs.js';
186
146
 
187
147
  // Storage scope keys — what `CadreNodeConfig.storage.provider` is called with. A
188
148
  // strand's key is its strand id; the control database's key carries the party id,
189
149
  // so two parties on one device never share a control store. Every key stays within
190
- // `[A-Za-z0-9._-]`: the control key by base64url encoding, a strand's by
150
+ // `[a-z0-9._-]` — lowercase, so distinct keys stay distinct on a case-insensitive
151
+ // filesystem: the control key by lowercase hex encoding, a strand's by
191
152
  // `assertStrandScopeKey`, which every strand launch runs.
192
153
  export {
193
154
  controlStorageScope,
@@ -305,6 +266,8 @@ export {
305
266
  createDefaultFormationResponseValidator,
306
267
  type DisclosureValidator,
307
268
  type FormationUsageRecorder,
269
+ type FormationUsageParams,
270
+ type AuthorizedFormationUsage,
308
271
  type ResolvedHostStrand,
309
272
  type StrandProvisioner,
310
273
  type FormationResponseValidator,
@@ -430,14 +393,16 @@ export {
430
393
  } from './strand-wake-protocol.js';
431
394
 
432
395
  // Strand Address (control-network strand-address RPC) — wire types come via
433
- // `export * from './types.js'` (StrandAddrRequest / StrandAddrResponse).
396
+ // `export * from './types.js'` (StrandAddrRequest / StrandAddrResponse / StrandAddrStatus).
434
397
  export {
435
398
  StrandAddrService,
436
399
  collectStrandAddrs,
437
400
  STRAND_ADDR_PROTOCOL,
438
401
  type StrandAddrServiceOptions,
439
402
  type StrandAddrPeer,
440
- type CollectStrandAddrsOptions
403
+ type CollectStrandAddrsOptions,
404
+ type StrandAddrOutcome,
405
+ type StrandAddrCollection
441
406
  } from './strand-addr-protocol.js';
442
407
 
443
408
  // Peer-address record (self-published, signed, freshness-stamped CadrePeer row)
@@ -504,6 +469,7 @@ export {
504
469
  // limit, so an address that never answers cannot starve the rest.
505
470
  export {
506
471
  dialPeerAddrs,
472
+ SelfRelayOnlyError,
507
473
  tryAddrsInTurn,
508
474
  directBeforeRelayed,
509
475
  DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS,
@@ -521,12 +487,14 @@ export {
521
487
  RELAYED_DIAL_ROUND_TRIPS,
522
488
  RELAY_RESERVATION_ROUND_TRIPS,
523
489
  CIRCUIT_REQUEST_ROUND_TRIPS,
490
+ RELAYED_REQUEST_ROUND_TRIPS,
524
491
  PUSH_TRANSFER_ALLOWANCE_MS,
492
+ ADMISSION_DECISION_TIMEOUT_MS,
525
493
  resolveLinkRoundTripMs,
526
494
  relayedDialBudgetMs,
527
- connectionManagerTimeouts,
528
495
  relayReservationBudgetMs,
529
496
  circuitRequestBudgetMs,
497
+ relayedRequestBudgetMs,
530
498
  peerJoinPushBudget,
531
499
  type PeerJoinPushBudget
532
500
  } from './link-budget.js';
@@ -568,6 +536,7 @@ export {
568
536
  type FormationDialResult,
569
537
  type ResponderProvisionOutcome
570
538
  } from './strand-formation-protocol.js';
539
+ export { formationDeadlines, type FormationDeadlines } from './strand-formation-deadlines.js';
571
540
 
572
541
  // Strand Formation manager (drives the native transport)
573
542
  export {
@@ -576,6 +545,7 @@ export {
576
545
  MEMBERSHIP_INVITE_TTL_MS,
577
546
  MEMBERSHIP_INVITE_UNAVAILABLE_REASON,
578
547
  HOST_STRAND_MUST_BE_RECREATED_REASON,
548
+ type MembershipInviteIssuer,
579
549
  type StrandFormationManagerConfig,
580
550
  type StrandFormationManagerOptions
581
551
  } from './strand-formation-manager.js';
@@ -585,11 +555,9 @@ export {
585
555
  createMembershipConnectionGater,
586
556
  STRANGER_OPEN_PROTOCOLS,
587
557
  DEFAULT_ENROLLMENT_WINDOW_MS,
588
- ADMISSION_DECISION_TIMEOUT_MS,
589
558
  RELAY_ADMISSION_RESERVE_DEADLINE_MS,
590
559
  RELAY_ADMISSION_CLOSE_TIMEOUT_MS,
591
560
  MAX_UNAUTHORIZED_RELAY_RESERVATIONS,
592
- UNAUTHORIZED_RESERVATION_TTL_MS,
593
561
  UnauthorizedReservationBudget,
594
562
  type InboundAdmissionPolicy,
595
563
  type InboundConnectionVerdict
@@ -615,6 +583,16 @@ export {
615
583
  resolveListenAddrs
616
584
  } from './relay-addrs.js';
617
585
  export { strandNodeAddrs, type StrandNodeAddrs } from './strand-network-config.js';
586
+ // The circuit-relay SERVER a node runs, and with which init — one resolution shared by the
587
+ // control node and every strand node. Exported alongside the listen derivations above for the
588
+ // same reason: an embedder can assert on what its `network` block resolves to.
589
+ export {
590
+ resolveRelayServer,
591
+ PARTY_RELAY_MAX_RESERVATIONS,
592
+ PARTY_RELAY_RESERVATION_TTL_MS,
593
+ type ResolvedRelayServer,
594
+ type ResolvedRelayReservations
595
+ } from './relay-server.js';
618
596
 
619
597
  // Relay reservation via the bare `/p2p-circuit` search listener — the one route
620
598
  // every control node takes; `network.relayAddrs` is its fail-fast posture
@@ -1,18 +1,25 @@
1
1
  /**
2
- * Node-local record of the strands this node joined from ANOTHER party.
2
+ * The strands this party joined from ANOTHER party, held in two records.
3
3
  *
4
4
  * A party's own strands come back after a restart through its control database: the
5
5
  * `Strand` table holds their rows, the strand watcher polls it, and each row no sApp
6
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).
7
+ * another party (`CadreNode.formStrand`) has its `Strand` row in the OTHER party's control
8
+ * database, so it needs a record of its own (gotchoices/sereus#18):
10
9
  *
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.
10
+ * - the PARTY-WIDE record, a `CadreControl.JoinedStrand` row ({@link PartyJoinedStrandLedger}).
11
+ * Once it exists it is the authority: every machine of the party offers it, a storage
12
+ * replica host launches it, and removing it is how the party leaves the strand;
13
+ * - the MACHINE-LOCAL record ({@link JoinedStrandStore}, below), a queue of joins this
14
+ * machine made that the party does not know about yet. A join is recorded here first,
15
+ * published by an owner machine's connected reconcile pass, and forgotten as soon as the
16
+ * party-wide row is visible, so a local record always means "joined here, not yet
17
+ * published". A machine that is not an owner never publishes; its joins stay here.
14
18
  *
15
- * Two implementations:
19
+ * {@link JoinedStrandSession} moves joins between the two and is what the strand watcher
20
+ * polls beside the control rows.
21
+ *
22
+ * Two store implementations:
16
23
  * - {@link MemoryJoinedStrandStore} — joins die with the process. The default only for a
17
24
  * node configured with no `keyStore`.
18
25
  * - {@link KeyStoreJoinedStrandStore} — one {@link KeyStore} slot per join. A record holds
@@ -35,7 +42,7 @@ import type { StrandRow } from './types.js';
35
42
 
36
43
  const log = debug('sereus:cadre:joined-strand-store');
37
44
 
38
- /** One strand this node joined from ANOTHER party — nothing in this party's control DB names it. */
45
+ /** One strand this node joined from ANOTHER party and has not yet published party-wide. */
39
46
  export interface JoinedStrandRecord {
40
47
  /** The strand id (a valid scope key, see storage-scope.ts). */
41
48
  Id: string;
@@ -222,85 +229,268 @@ function parseRecord(strandId: string, bytes: Uint8Array): JoinedStrandRecord |
222
229
  }
223
230
 
224
231
  /**
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.
232
+ * The party-wide joined-strand table (`CadreControl.JoinedStrand`), as the node's control
233
+ * database exposes it. `CadreNode` builds it over `ControlDatabase` and the owner signing
234
+ * key; this module sees only the seam, so it stays free of the runtime.
235
+ */
236
+ export interface PartyJoinedStrandLedger {
237
+ /** Every party-wide join, as offerable rows (`FounderOwnerKey: null`). */
238
+ list(): Promise<StrandRow[]>;
239
+ /** Whether this party's control database names `strandId`, as its own strand or as a party-wide join. */
240
+ names(strandId: string): Promise<boolean>;
241
+ /** Whether this machine can sign {@link publish} and {@link remove}: it holds an owner key the party enrolls. */
242
+ canSign(): Promise<boolean>;
243
+ /** Owner-signed insert. Resolves when the row landed or a sibling's join already holds the id. */
244
+ publish(record: JoinedStrandRecord): Promise<void>;
245
+ /** Owner-signed delete + tombstone; false when there is no row. Throws when a row exists and this machine cannot sign. */
246
+ remove(strandId: string): Promise<boolean>;
247
+ }
248
+
249
+ /**
250
+ * One node session's joined strands: what the strand watcher sees of the two records, and
251
+ * the moves between them. Rebuilt at every start, which is what ends the per-session state
252
+ * below.
228
253
  *
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.
254
+ * The watcher reads a row missing from a poll as a removal and detaches the strand, so this
255
+ * holds the state a plain union would get wrong:
256
+ * - the last list each record answered, reused when a later read fails, so a failed read
257
+ * is not "every join was forgotten";
258
+ * - joins this party was removed from ({@link forgetAfterThisSession}): offered until this
259
+ * session ends, because `strand:revoked` promises that nothing is torn down for the app,
260
+ * and queued for party-wide removal so the next start does not re-attach them.
238
261
  */
239
- export class JoinedStrandRows {
240
- private lastListed: JoinedStrandRecord[] = [];
241
- private readonly keptForSession = new Map<string, JoinedStrandRecord>();
262
+ export class JoinedStrandSession {
263
+ private lastUnpublished: JoinedStrandRecord[] = [];
264
+ private lastPartyWide: StrandRow[] = [];
265
+ private readonly keptForSession = new Map<string, StrandRow>();
266
+ /** Strands whose party-wide row {@link syncWithParty} removes, queued by a self-revocation. */
267
+ private readonly pendingRemovals = new Set<string>();
268
+ /**
269
+ * Tail of the moves that must not interleave ({@link serialized}): a publish that found no
270
+ * party-wide row, then a {@link leave} that removed nothing, then the publish's insert
271
+ * would leave the party holding a strand the app just left.
272
+ */
273
+ private exclusive: Promise<unknown> = Promise.resolve();
242
274
 
243
- constructor(private readonly store: JoinedStrandStore) {}
275
+ constructor(private readonly store: JoinedStrandStore, private readonly ledger: PartyJoinedStrandLedger) {}
244
276
 
245
277
  /**
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.
278
+ * `control` plus every join, one row per id, in this precedence: the control rows (the
279
+ * party's own strands), then the party-wide joins, then this machine's unpublished joins
280
+ * and the joins kept for this session. A local record named by either of the first two in
281
+ * a read that succeeded this poll is stale — published, or the strand is the party's own
282
+ * now — so it is forgotten here; this also cleans up after a crash between a publish and
283
+ * its local forget. One named only by the last-good party-wide list is kept: that list may
284
+ * predate a leave the record re-joined after.
249
285
  */
250
286
  async withControlRows(control: readonly StrandRow[]): Promise<StrandRow[]> {
251
287
  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);
288
+ const named = new Set(controlIds);
289
+ const offered = new Map<string, StrandRow>(control.map((row) => [row.Id, row]));
290
+ const partyWide = await this.partyWide();
291
+ // NOTE: a party-wide join whose id the party's own `Strand` table also holds (the party
292
+ // founded AND joined it — contrived) is shadowed here, not deleted: a watcher poll must
293
+ // not make owner-signed writes. If the party later unpublishes its own row the join row
294
+ // resurfaces; if that is ever seen, remove the join row inside `unpublishStrand`.
295
+ for (const row of partyWide.rows) {
296
+ if (!offered.has(row.Id)) {
297
+ offered.set(row.Id, row);
298
+ }
299
+ if (partyWide.fresh) {
300
+ named.add(row.Id);
301
+ }
255
302
  }
256
- const rows = [...control];
257
- for (const record of joined.values()) {
258
- if (controlIds.has(record.Id)) {
303
+ for (const record of await this.unpublished()) {
304
+ if (named.has(record.Id)) {
259
305
  await this.forgetSuperseded(record.Id);
260
- } else {
261
- rows.push(joinedStrandRow(record));
306
+ } else if (!offered.has(record.Id)) {
307
+ offered.set(record.Id, joinedStrandRow(record));
308
+ }
309
+ }
310
+ for (const [strandId, row] of this.keptForSession) {
311
+ if (controlIds.has(strandId)) {
312
+ this.keptForSession.delete(strandId);
313
+ } else if (!offered.has(strandId)) {
314
+ offered.set(strandId, row);
262
315
  }
263
316
  }
264
- return rows;
317
+ return [...offered.values()];
265
318
  }
266
319
 
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);
320
+ /** Record a join `formStrand` just made. A re-join also cancels a removal a revocation queued. */
321
+ async remember(record: JoinedStrandRecord): Promise<void> {
322
+ this.pendingRemovals.delete(record.Id);
323
+ await this.store.record(record);
271
324
  }
272
325
 
273
326
  /**
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.
327
+ * Record `row`, joined by `addStrand`, unless this party's control database already names
328
+ * it (its own strand, or a join already published) or an identical record is already here
329
+ * (an app re-claiming a remembered join on every start). Keeps the first `joinedAt`. Like
330
+ * {@link remember}, cancels a queued removal.
277
331
  */
278
- async forgetAfterThisSession(strandId: string): Promise<void> {
279
- const record = (await this.listed()).find((listed) => listed.Id === strandId);
280
- if (!record) {
332
+ async rememberForeign(row: StrandRow): Promise<void> {
333
+ this.pendingRemovals.delete(row.Id);
334
+ if (await this.namedByParty(row.Id)) {
281
335
  return;
282
336
  }
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);
337
+ const existing = (await this.store.list()).find((record) => record.Id === row.Id);
338
+ if (existing?.Type === row.Type && existing.MemberPrivateKey === row.MemberPrivateKey) {
339
+ return;
340
+ }
341
+ await this.store.record({
342
+ Id: row.Id,
343
+ Type: row.Type,
344
+ MemberPrivateKey: row.MemberPrivateKey,
345
+ joinedAt: existing?.joinedAt ?? Date.now()
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Leave a join for the whole party: remove the party-wide row, then this machine's record.
351
+ * Throws, keeping both, when a party-wide row exists and this machine cannot sign.
352
+ */
353
+ leave(strandId: string): Promise<void> {
354
+ return this.serialized(async () => {
355
+ await this.ledger.remove(strandId);
356
+ this.pendingRemovals.delete(strandId);
357
+ this.keptForSession.delete(strandId);
358
+ this.lastPartyWide = this.lastPartyWide.filter((row) => row.Id !== strandId);
359
+ await this.store.forget(strandId);
360
+ });
361
+ }
362
+
363
+ /**
364
+ * For a strand this party was removed from: keep offering it until this session ends, and
365
+ * queue its party-wide row for removal by {@link syncWithParty}, so it does not re-attach
366
+ * on every start of every machine. A no-op for a strand with neither record (one of the
367
+ * party's own). The queue is in memory: if the process dies first, the next start
368
+ * relaunches the strand, the revoked-peer gate raises `strand:revoked` again, and this
369
+ * runs again.
370
+ */
371
+ forgetAfterThisSession(strandId: string): Promise<void> {
372
+ return this.serialized(async () => {
373
+ const local = (await this.unpublished()).find((record) => record.Id === strandId);
374
+ const row = local ? joinedStrandRow(local) : this.lastPartyWide.find((listed) => listed.Id === strandId);
375
+ if (!row) {
376
+ return;
377
+ }
378
+ // Kept BEFORE the store forgets, so no poll can union without it. Queued even for a
379
+ // local-only join, which a sibling may have published already.
380
+ this.keptForSession.set(strandId, row);
381
+ this.pendingRemovals.add(strandId);
382
+ if (local) {
383
+ await this.store.forget(strandId);
384
+ }
385
+ });
386
+ }
387
+
388
+ /**
389
+ * The owner machine's half, run by the node's connected reconcile pass: publish every
390
+ * unpublished join party-wide, then remove the party-wide rows queued by
391
+ * {@link forgetAfterThisSession}. Does nothing on a machine that cannot sign, whose joins
392
+ * stay machine-local. Per strand, a failure is logged and kept for the next pass; a
393
+ * failure to list the store or to check the signer throws.
394
+ */
395
+ syncWithParty(): Promise<void> {
396
+ return this.serialized(async () => {
397
+ // Straight from the store, never the last-good list: a stale list could republish a
398
+ // join that was left since.
399
+ const unpublished = await this.store.list();
400
+ if (unpublished.length === 0 && this.pendingRemovals.size === 0) {
401
+ return;
402
+ }
403
+ if (!(await this.ledger.canSign())) {
404
+ return;
405
+ }
406
+ for (const record of unpublished) {
407
+ await this.publish(record);
408
+ }
409
+ for (const strandId of [...this.pendingRemovals]) {
410
+ await this.removeQueued(strandId);
411
+ }
412
+ });
413
+ }
414
+
415
+ // NOTE: a sibling that joined the same strand and has not yet published publishes it after
416
+ // a leave, bringing the strand back party-wide. Needs two devices joining one strand within
417
+ // a reconcile interval of the leave; if it is ever seen, check the strand's `Revocation`
418
+ // tombstone before publishing.
419
+ private async publish(record: JoinedStrandRecord): Promise<void> {
420
+ try {
421
+ if (!(await this.ledger.names(record.Id))) {
422
+ await this.ledger.publish(record);
423
+ // Into the last-good list too: its local record goes next, so a party-wide read that
424
+ // fails before one succeeds would otherwise drop the strand from the poll and hide it
425
+ // from a self-revocation.
426
+ this.lastPartyWide = [...this.lastPartyWide.filter((row) => row.Id !== record.Id), joinedStrandRow(record)];
427
+ log('joined strand %s published party-wide (party=%s)', record.Id, this.store.partyId);
428
+ }
429
+ await this.store.forget(record.Id);
430
+ } catch (error) {
431
+ log('joined strand %s: publishing party-wide failed; kept for the next pass: %o', record.Id, error);
432
+ }
433
+ }
434
+
435
+ private async removeQueued(strandId: string): Promise<void> {
436
+ try {
437
+ const removed = await this.ledger.remove(strandId);
438
+ this.pendingRemovals.delete(strandId);
439
+ log('joined strand %s: party-wide row %s after this party was removed from it',
440
+ strandId, removed ? 'removed' : 'already absent');
441
+ } catch (error) {
442
+ log('joined strand %s: removing the party-wide row failed; queued for the next pass: %o', strandId, error);
443
+ }
444
+ }
445
+
446
+ /** The party-wide joins; `fresh` is false when this read failed and the last list it answered stands in. */
447
+ private async partyWide(): Promise<{ rows: StrandRow[]; fresh: boolean }> {
448
+ try {
449
+ this.lastPartyWide = await this.ledger.list();
450
+ return { rows: this.lastPartyWide, fresh: true };
451
+ } catch (error) {
452
+ log('party-wide joined-strand read failed; offering the last list it answered (%d join(s)): %o',
453
+ this.lastPartyWide.length, error);
454
+ return { rows: this.lastPartyWide, fresh: false };
455
+ }
456
+ }
457
+
458
+ /**
459
+ * {@link PartyJoinedStrandLedger.names}, answering false when the control database cannot
460
+ * be read (a machine cut off from its party before it received the `JoinedStrand` block).
461
+ * Recording is the safe side: a local record the party turns out to name is forgotten by
462
+ * the next poll that reads it, while a join with no record is not offered after a restart.
463
+ */
464
+ private async namedByParty(strandId: string): Promise<boolean> {
465
+ try {
466
+ return await this.ledger.names(strandId);
467
+ } catch (error) {
468
+ log('joined strand %s: could not read whether the control database names it; recording it locally: %o', strandId, error);
469
+ return false;
470
+ }
471
+ }
472
+
473
+ /** Run `work` after every earlier serialized move settles; see {@link exclusive}. */
474
+ private serialized<T>(work: () => Promise<T>): Promise<T> {
475
+ const result = this.exclusive.then(work);
476
+ this.exclusive = result.catch(() => undefined);
477
+ return result;
287
478
  }
288
479
 
289
- private async listed(): Promise<JoinedStrandRecord[]> {
480
+ private async unpublished(): Promise<JoinedStrandRecord[]> {
290
481
  try {
291
- this.lastListed = await this.store.list();
482
+ this.lastUnpublished = await this.store.list();
292
483
  } catch (error) {
293
484
  log('joined-strand store list failed; offering the last list it answered (%d join(s)): %o',
294
- this.lastListed.length, error);
485
+ this.lastUnpublished.length, error);
295
486
  }
296
- return this.lastListed;
487
+ return this.lastUnpublished;
297
488
  }
298
489
 
299
490
  private async forgetSuperseded(strandId: string): Promise<void> {
300
- this.keptForSession.delete(strandId);
301
491
  try {
302
492
  await this.store.forget(strandId);
303
- log('joined strand %s now has a control row — record forgotten', strandId);
493
+ log('joined strand %s is named by this party\'s control database — local record forgotten', strandId);
304
494
  } catch (error) {
305
495
  log('forgetting superseded joined strand %s failed; the next poll retries: %o', strandId, error);
306
496
  }
@@ -45,19 +45,28 @@ function encodeKeyId(keyId: KeyId): string {
45
45
 
46
46
  /**
47
47
  * Reverse {@link encodeKeyId}: strip the slot suffix and percent-decode. Returns
48
- * `undefined` for a `.key` filename this store did not write (a foreign file
49
- * with an invalid percent-sequence), so {@link FileKeyStore.list} can skip it
50
- * rather than letting one undecodable entry throw and break enumeration of every
51
- * real slot.
48
+ * `undefined` for a `.key` filename this store did not write, so
49
+ * {@link FileKeyStore.list} can skip it rather than letting one undecodable
50
+ * entry throw and break enumeration of every real slot. That includes a name
51
+ * that decodes but is not the one {@link encodeKeyId} produces for the result
52
+ * (e.g. a slot written before uppercase letters were escaped):
53
+ * {@link FileKeyStore.get} looks only at the canonical name, so listing it
54
+ * would name a key id that reads back as absent.
52
55
  */
53
56
  function decodeKeyId(fileName: string): KeyId | undefined {
54
57
  const encoded = fileName.slice(0, -SLOT_SUFFIX.length);
58
+ let keyId: KeyId;
55
59
  try {
56
- return decodeURIComponent(encoded);
60
+ keyId = decodeURIComponent(encoded);
57
61
  } catch (error) {
58
62
  log('FileKeyStore: skipping undecodable slot filename %s: %o', fileName, error);
59
63
  return undefined;
60
64
  }
65
+ if (encodeKeyId(keyId) !== encoded) {
66
+ log('FileKeyStore: skipping non-canonical slot filename %s', fileName);
67
+ return undefined;
68
+ }
69
+ return keyId;
61
70
  }
62
71
 
63
72
  /**