@serfab/cadre-core 1.16.0 → 1.18.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 (268) hide show
  1. package/README.md +4 -3
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +81 -142
  6. package/dist/cadre-invite-protocol.js +168 -458
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +1089 -475
  12. package/dist/cadre-node.js +2170 -1037
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +88 -32
  18. package/dist/control-cohort.js +91 -33
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +856 -318
  21. package/dist/control-database.js +1422 -701
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-formation-recorder.d.ts +46 -43
  24. package/dist/control-formation-recorder.js +77 -78
  25. package/dist/control-formation-recorder.js.map +1 -1
  26. package/dist/control-protocol-guard.d.ts +11 -4
  27. package/dist/control-protocol-guard.js +20 -5
  28. package/dist/control-protocol-guard.js.map +1 -1
  29. package/dist/control-schema-version.d.ts +97 -0
  30. package/dist/control-schema-version.js +225 -0
  31. package/dist/control-schema-version.js.map +1 -0
  32. package/dist/control-schema.d.ts +45 -5
  33. package/dist/control-schema.js +262 -78
  34. package/dist/control-schema.js.map +1 -1
  35. package/dist/control-write-retry.d.ts +45 -10
  36. package/dist/control-write-retry.js +92 -22
  37. package/dist/control-write-retry.js.map +1 -1
  38. package/dist/delegate-admission.d.ts +13 -0
  39. package/dist/delegate-admission.js +27 -0
  40. package/dist/delegate-admission.js.map +1 -1
  41. package/dist/ed25519-key.d.ts +10 -7
  42. package/dist/ed25519-key.js +23 -20
  43. package/dist/ed25519-key.js.map +1 -1
  44. package/dist/edge-node/config.d.ts +122 -0
  45. package/dist/edge-node/config.js +199 -0
  46. package/dist/edge-node/config.js.map +1 -0
  47. package/dist/edge-node/dissolve.d.ts +95 -0
  48. package/dist/edge-node/dissolve.js +184 -0
  49. package/dist/edge-node/dissolve.js.map +1 -0
  50. package/dist/edge-node/edge-join.d.ts +44 -0
  51. package/dist/edge-node/edge-join.js +227 -0
  52. package/dist/edge-node/edge-join.js.map +1 -0
  53. package/dist/edge-node/edge-node.d.ts +295 -0
  54. package/dist/edge-node/edge-node.js +704 -0
  55. package/dist/edge-node/edge-node.js.map +1 -0
  56. package/dist/edge-node/index.d.ts +18 -0
  57. package/dist/edge-node/index.js +18 -0
  58. package/dist/edge-node/index.js.map +1 -0
  59. package/dist/edge-node/join-credential.d.ts +11 -0
  60. package/dist/edge-node/join-credential.js +11 -0
  61. package/dist/edge-node/join-credential.js.map +1 -0
  62. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  63. package/dist/edge-node/lazy-raw-storage.js +134 -0
  64. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  65. package/dist/edge-node/node-local.d.ts +60 -0
  66. package/dist/edge-node/node-local.js +58 -0
  67. package/dist/edge-node/node-local.js.map +1 -0
  68. package/dist/edge-node/options.d.ts +83 -0
  69. package/dist/edge-node/options.js +110 -0
  70. package/dist/edge-node/options.js.map +1 -0
  71. package/dist/edge-node/owner-grant.d.ts +41 -0
  72. package/dist/edge-node/owner-grant.js +38 -0
  73. package/dist/edge-node/owner-grant.js.map +1 -0
  74. package/dist/edge-node/platform.d.ts +140 -0
  75. package/dist/edge-node/platform.js +12 -0
  76. package/dist/edge-node/platform.js.map +1 -0
  77. package/dist/edge-node/strand-move.d.ts +115 -0
  78. package/dist/edge-node/strand-move.js +223 -0
  79. package/dist/edge-node/strand-move.js.map +1 -0
  80. package/dist/edge-node/strands.d.ts +28 -0
  81. package/dist/edge-node/strands.js +60 -0
  82. package/dist/edge-node/strands.js.map +1 -0
  83. package/dist/enrolled-machine-store.d.ts +3 -3
  84. package/dist/enrolled-machine-store.js +3 -3
  85. package/dist/formation-token.d.ts +48 -0
  86. package/dist/formation-token.js +26 -0
  87. package/dist/formation-token.js.map +1 -0
  88. package/dist/index.d.ts +27 -17
  89. package/dist/index.js +39 -17
  90. package/dist/index.js.map +1 -1
  91. package/dist/joined-strand-store.d.ts +29 -12
  92. package/dist/joined-strand-store.js +66 -27
  93. package/dist/joined-strand-store.js.map +1 -1
  94. package/dist/key-store.d.ts +9 -4
  95. package/dist/key-store.js +9 -4
  96. package/dist/key-store.js.map +1 -1
  97. package/dist/link-budget.d.ts +9 -7
  98. package/dist/link-budget.js +9 -7
  99. package/dist/link-budget.js.map +1 -1
  100. package/dist/machine-removal.d.ts +19 -39
  101. package/dist/machine-removal.js +24 -98
  102. package/dist/machine-removal.js.map +1 -1
  103. package/dist/member-redemption.d.ts +256 -0
  104. package/dist/member-redemption.js +490 -0
  105. package/dist/member-redemption.js.map +1 -0
  106. package/dist/membership-view.d.ts +91 -0
  107. package/dist/membership-view.js +110 -0
  108. package/dist/membership-view.js.map +1 -0
  109. package/dist/owner-anchor-sync.d.ts +4 -4
  110. package/dist/owner-anchor-sync.js +16 -15
  111. package/dist/owner-anchor-sync.js.map +1 -1
  112. package/dist/owner-grant-protocol.d.ts +251 -0
  113. package/dist/owner-grant-protocol.js +344 -0
  114. package/dist/owner-grant-protocol.js.map +1 -0
  115. package/dist/owner-key-export.d.ts +70 -0
  116. package/dist/owner-key-export.js +279 -0
  117. package/dist/owner-key-export.js.map +1 -0
  118. package/dist/owner-key-flows.d.ts +55 -0
  119. package/dist/owner-key-flows.js +60 -0
  120. package/dist/owner-key-flows.js.map +1 -0
  121. package/dist/owner-key-store.d.ts +31 -0
  122. package/dist/owner-key-store.js +81 -0
  123. package/dist/owner-key-store.js.map +1 -0
  124. package/dist/owner-keys.d.ts +78 -0
  125. package/dist/owner-keys.js +133 -0
  126. package/dist/owner-keys.js.map +1 -0
  127. package/dist/owner-signer.d.ts +54 -0
  128. package/dist/owner-signer.js +51 -0
  129. package/dist/owner-signer.js.map +1 -0
  130. package/dist/peer-addr-book.d.ts +10 -1
  131. package/dist/peer-addr-book.js +4 -3
  132. package/dist/peer-addr-book.js.map +1 -1
  133. package/dist/peer-authorization.d.ts +83 -18
  134. package/dist/peer-authorization.js +123 -53
  135. package/dist/peer-authorization.js.map +1 -1
  136. package/dist/peer-join-backfill.d.ts +89 -20
  137. package/dist/peer-join-backfill.js +265 -108
  138. package/dist/peer-join-backfill.js.map +1 -1
  139. package/dist/pending-join-runner.d.ts +48 -18
  140. package/dist/pending-join-runner.js +74 -56
  141. package/dist/pending-join-runner.js.map +1 -1
  142. package/dist/provider/rent-node.js +1 -1
  143. package/dist/provider/rent-node.js.map +1 -1
  144. package/dist/seed-bootstrap.d.ts +162 -133
  145. package/dist/seed-bootstrap.js +300 -283
  146. package/dist/seed-bootstrap.js.map +1 -1
  147. package/dist/seed-trust-policy.js +4 -4
  148. package/dist/strand-addr-protocol.d.ts +4 -1
  149. package/dist/strand-addr-protocol.js +8 -2
  150. package/dist/strand-addr-protocol.js.map +1 -1
  151. package/dist/strand-cohort.d.ts +3 -0
  152. package/dist/strand-cohort.js.map +1 -1
  153. package/dist/strand-database.d.ts +24 -2
  154. package/dist/strand-database.js +29 -6
  155. package/dist/strand-database.js.map +1 -1
  156. package/dist/strand-first-sync-gate.d.ts +176 -88
  157. package/dist/strand-first-sync-gate.js +288 -121
  158. package/dist/strand-first-sync-gate.js.map +1 -1
  159. package/dist/strand-formation-deadlines.d.ts +9 -9
  160. package/dist/strand-formation-deadlines.js +9 -9
  161. package/dist/strand-formation-manager.d.ts +23 -17
  162. package/dist/strand-formation-manager.js +46 -44
  163. package/dist/strand-formation-manager.js.map +1 -1
  164. package/dist/strand-formation-protocol.d.ts +5 -8
  165. package/dist/strand-formation-protocol.js +101 -16
  166. package/dist/strand-formation-protocol.js.map +1 -1
  167. package/dist/strand-instance-manager.d.ts +27 -18
  168. package/dist/strand-instance-manager.js +119 -50
  169. package/dist/strand-instance-manager.js.map +1 -1
  170. package/dist/strand-membership-reconciler.d.ts +16 -13
  171. package/dist/strand-membership-reconciler.js +30 -22
  172. package/dist/strand-membership-reconciler.js.map +1 -1
  173. package/dist/strand-membership-writer.d.ts +45 -22
  174. package/dist/strand-membership-writer.js +226 -131
  175. package/dist/strand-membership-writer.js.map +1 -1
  176. package/dist/strand-move.d.ts +6 -5
  177. package/dist/strand-move.js +36 -32
  178. package/dist/strand-move.js.map +1 -1
  179. package/dist/strand-peer-redial.js +2 -3
  180. package/dist/strand-peer-redial.js.map +1 -1
  181. package/dist/strand-revocation-enforcer.d.ts +134 -60
  182. package/dist/strand-revocation-enforcer.js +144 -71
  183. package/dist/strand-revocation-enforcer.js.map +1 -1
  184. package/dist/strand-solicitation.d.ts +50 -39
  185. package/dist/strand-solicitation.js +10 -3
  186. package/dist/strand-solicitation.js.map +1 -1
  187. package/dist/strand-watcher.d.ts +80 -13
  188. package/dist/strand-watcher.js +152 -48
  189. package/dist/strand-watcher.js.map +1 -1
  190. package/dist/table-change-watch.d.ts +63 -0
  191. package/dist/table-change-watch.js +209 -0
  192. package/dist/table-change-watch.js.map +1 -0
  193. package/dist/timeout-scheduler.d.ts +4 -3
  194. package/dist/timeout-scheduler.js +4 -3
  195. package/dist/timeout-scheduler.js.map +1 -1
  196. package/dist/types.d.ts +374 -150
  197. package/dist/types.js +4 -4
  198. package/dist/types.js.map +1 -1
  199. package/package.json +17 -8
  200. package/src/bootstrap-peer-store-file.ts +44 -44
  201. package/src/bootstrap-peer-store.ts +214 -214
  202. package/src/cadre-invite-protocol.ts +740 -1052
  203. package/src/cadre-join-request.ts +24 -3
  204. package/src/cadre-node.ts +2402 -1079
  205. package/src/control-authorization.ts +47 -14
  206. package/src/control-cohort.ts +204 -128
  207. package/src/control-database.ts +1749 -820
  208. package/src/control-formation-recorder.ts +359 -353
  209. package/src/control-protocol-guard.ts +23 -6
  210. package/src/control-schema-version.ts +301 -0
  211. package/src/control-schema.ts +273 -78
  212. package/src/control-write-retry.ts +641 -567
  213. package/src/delegate-admission.ts +351 -314
  214. package/src/ed25519-key.ts +174 -171
  215. package/src/edge-node/config.ts +277 -0
  216. package/src/edge-node/dissolve.ts +251 -0
  217. package/src/edge-node/edge-join.ts +319 -0
  218. package/src/edge-node/edge-node.ts +1045 -0
  219. package/src/edge-node/index.ts +59 -0
  220. package/src/edge-node/join-credential.ts +16 -0
  221. package/src/edge-node/lazy-raw-storage.ts +178 -0
  222. package/src/edge-node/node-local.ts +84 -0
  223. package/src/edge-node/options.ts +179 -0
  224. package/src/edge-node/owner-grant.ts +63 -0
  225. package/src/edge-node/platform.ts +151 -0
  226. package/src/edge-node/strand-move.ts +270 -0
  227. package/src/edge-node/strands.ts +72 -0
  228. package/src/enrolled-machine-store.ts +3 -3
  229. package/src/formation-token.ts +63 -0
  230. package/src/index.ts +899 -789
  231. package/src/joined-strand-store.ts +73 -28
  232. package/src/key-store.ts +10 -4
  233. package/src/link-budget.ts +9 -7
  234. package/src/machine-removal.ts +26 -111
  235. package/src/member-redemption.ts +649 -0
  236. package/src/membership-view.ts +144 -0
  237. package/src/owner-anchor-sync.ts +19 -18
  238. package/src/owner-grant-protocol.ts +547 -0
  239. package/src/owner-key-export.ts +348 -0
  240. package/src/owner-key-flows.ts +109 -0
  241. package/src/owner-key-store.ts +85 -0
  242. package/src/owner-keys.ts +178 -0
  243. package/src/owner-signer.ts +94 -0
  244. package/src/peer-addr-book.ts +4 -3
  245. package/src/peer-authorization.ts +558 -488
  246. package/src/peer-join-backfill.ts +971 -761
  247. package/src/pending-join-runner.ts +76 -53
  248. package/src/provider/rent-node.ts +1 -1
  249. package/src/seed-bootstrap.ts +343 -349
  250. package/src/seed-trust-policy.ts +4 -4
  251. package/src/strand-addr-protocol.ts +547 -538
  252. package/src/strand-cohort.ts +4 -0
  253. package/src/strand-database.ts +352 -319
  254. package/src/strand-first-sync-gate.ts +334 -133
  255. package/src/strand-formation-deadlines.ts +227 -227
  256. package/src/strand-formation-manager.ts +731 -725
  257. package/src/strand-formation-protocol.ts +122 -24
  258. package/src/strand-instance-manager.ts +2092 -2026
  259. package/src/strand-membership-reconciler.ts +33 -25
  260. package/src/strand-membership-writer.ts +2093 -1974
  261. package/src/strand-move.ts +50 -38
  262. package/src/strand-peer-redial.ts +2 -3
  263. package/src/strand-revocation-enforcer.ts +740 -654
  264. package/src/strand-solicitation.ts +61 -34
  265. package/src/strand-watcher.ts +175 -56
  266. package/src/table-change-watch.ts +285 -0
  267. package/src/timeout-scheduler.ts +29 -28
  268. package/src/types.ts +391 -152
@@ -2,7 +2,7 @@
2
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
- * `Strand` table holds their rows, the strand watcher polls it, and each row no sApp
5
+ * `Strand` table holds their rows, the strand watcher reads it, and each row no sApp
6
6
  * config claims is offered as `strand:discovered`. A strand joined through formation with
7
7
  * another party (`CadreNode.formStrand`) has its `Strand` row in the OTHER party's control
8
8
  * database, so it needs a record of its own (gotchoices/sereus#18):
@@ -17,7 +17,7 @@
17
17
  * published". A machine that is not an owner never publishes; its joins stay here.
18
18
  *
19
19
  * {@link JoinedStrandSession} moves joins between the two and is what the strand watcher
20
- * polls beside the control rows.
20
+ * reads beside the control rows.
21
21
  *
22
22
  * Two store implementations:
23
23
  * - {@link MemoryJoinedStrandStore} — joins die with the process. The default only for a
@@ -69,11 +69,15 @@ export interface JoinedStrandStore {
69
69
 
70
70
  /**
71
71
  * The {@link StrandRow} a record stands for — the shape `addStrand` and `strand:discovered`
72
- * carry. `FounderOwnerKey` is null because a joiner never founds; `CadreNode.launchStrand`
73
- * reads null as "not mine" and joins.
72
+ * carry. The founder columns are null because a joiner never founds; `CadreNode.launchStrand`
73
+ * reads a null `FounderPeerId` as "not mine" and joins.
74
74
  */
75
75
  export function joinedStrandRow(record: JoinedStrandRecord): StrandRow {
76
- return { Id: record.Id, Type: record.Type, MemberPrivateKey: record.MemberPrivateKey, FounderOwnerKey: null };
76
+ return { Id: record.Id, Type: record.Type, MemberPrivateKey: record.MemberPrivateKey, FounderOwnerKey: null, FounderPeerId: null };
77
+ }
78
+
79
+ function isSameJoin(record: JoinedStrandRecord, row: StrandRow): boolean {
80
+ return record.Type === row.Type && record.MemberPrivateKey === row.MemberPrivateKey;
77
81
  }
78
82
 
79
83
  /**
@@ -124,9 +128,9 @@ const SLOT_PREFIX = 'cadre/joined-strand/';
124
128
  * arbitrary text, and base64url never contains the `/` that ends the segment.
125
129
  *
126
130
  * Loads every slot once, at the first {@link list}, then answers from memory and writes
127
- * through, so the strand watcher's five-second poll does not read the enclave on every
128
- * pass. A load that fails — `KeyStoreAccessError` from a refused unlock prompt, say —
129
- * is rethrown rather than read as "no joins" and retried at the next call, exactly as
131
+ * through, so a strand watcher run does not read the enclave each time. A load that
132
+ * fails — `KeyStoreAccessError` from a refused unlock prompt, say — is rethrown rather
133
+ * than read as "no joins" and retried at the next call, exactly as
130
134
  * `loadOrCreateIdentityKey` treats the identity slot. A slot that does not parse as a
131
135
  * record for its own id is dropped with a log and its siblings kept: refusing to start
132
136
  * repairs nothing, and the other joins are still good.
@@ -176,9 +180,10 @@ export class KeyStoreJoinedStrandStore implements JoinedStrandStore {
176
180
  return result;
177
181
  }
178
182
 
179
- // NOTE: a failed load is retried at the next list(), which is every strand-watcher poll;
180
- // over a backend that gates reads behind an unlock prompt the user keeps declining, that
181
- // is a prompt every five seconds. Every KeyStore shipped today is ungated for this reason
183
+ // NOTE: a failed load is retried at the next list(), which is every strand-watcher run (a
184
+ // change to `Strand` or `JoinedStrand`, a local join, a retry, the backstop); over a backend
185
+ // that gates reads behind an unlock prompt the user keeps declining, that is a prompt per
186
+ // run. Every KeyStore shipped today is ungated for this reason
182
187
  // (see `SecureStoreKeyStoreOptions.requireAuthentication`); if one is ever gated, back
183
188
  // off the retry here.
184
189
  private async loaded(): Promise<Map<string, JoinedStrandRecord>> {
@@ -234,7 +239,7 @@ function parseRecord(strandId: string, bytes: Uint8Array): JoinedStrandRecord |
234
239
  * key; this module sees only the seam, so it stays free of the runtime.
235
240
  */
236
241
  export interface PartyJoinedStrandLedger {
237
- /** Every party-wide join, as offerable rows (`FounderOwnerKey: null`). */
242
+ /** Every party-wide join, as offerable rows (null founder columns). */
238
243
  list(): Promise<StrandRow[]>;
239
244
  /** Whether this party's control database names `strandId`, as its own strand or as a party-wide join. */
240
245
  names(strandId: string): Promise<boolean>;
@@ -251,7 +256,7 @@ export interface PartyJoinedStrandLedger {
251
256
  * the moves between them. Rebuilt at every start, which is what ends the per-session state
252
257
  * below.
253
258
  *
254
- * The watcher reads a row missing from a poll as a removal and detaches the strand, so this
259
+ * The watcher reads a row missing from a run's read as a removal and detaches the strand, so this
255
260
  * holds the state a plain union would get wrong:
256
261
  * - the last list each record answered, reused when a later read fails, so a failed read
257
262
  * is not "every join was forgotten";
@@ -262,6 +267,8 @@ export interface PartyJoinedStrandLedger {
262
267
  export class JoinedStrandSession {
263
268
  private lastUnpublished: JoinedStrandRecord[] = [];
264
269
  private lastPartyWide: StrandRow[] = [];
270
+ /** Whether the last party-wide read in {@link withControlRows} succeeded; see {@link lastReadPartial}. */
271
+ private lastPartyWideFresh = true;
265
272
  private readonly keptForSession = new Map<string, StrandRow>();
266
273
  /** Strands whose party-wide row {@link syncWithParty} removes, queued by a self-revocation. */
267
274
  private readonly pendingRemovals = new Set<string>();
@@ -272,13 +279,23 @@ export class JoinedStrandSession {
272
279
  */
273
280
  private exclusive: Promise<unknown> = Promise.resolve();
274
281
 
275
- constructor(private readonly store: JoinedStrandStore, private readonly ledger: PartyJoinedStrandLedger) {}
282
+ /**
283
+ * @param onLocalChange - Called after a move changes what {@link withControlRows} merges in
284
+ * from outside the control database (this machine's unpublished joins, the joins kept for
285
+ * this session), which no control-table change event reports. `CadreNode` wakes its strand
286
+ * watcher with it.
287
+ */
288
+ constructor(
289
+ private readonly store: JoinedStrandStore,
290
+ private readonly ledger: PartyJoinedStrandLedger,
291
+ private readonly onLocalChange: () => void = () => {}
292
+ ) {}
276
293
 
277
294
  /**
278
295
  * `control` plus every join, one row per id, in this precedence: the control rows (the
279
296
  * party's own strands), then the party-wide joins, then this machine's unpublished joins
280
297
  * 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
298
+ * a read that succeeded this run is stale — published, or the strand is the party's own
282
299
  * now — so it is forgotten here; this also cleans up after a crash between a publish and
283
300
  * its local forget. One named only by the last-good party-wide list is kept: that list may
284
301
  * predate a leave the record re-joined after.
@@ -289,7 +306,7 @@ export class JoinedStrandSession {
289
306
  const offered = new Map<string, StrandRow>(control.map((row) => [row.Id, row]));
290
307
  const partyWide = await this.partyWide();
291
308
  // 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
309
+ // founded AND joined it — contrived) is shadowed here, not deleted: a watcher run must
293
310
  // not make owner-signed writes. If the party later unpublishes its own row the join row
294
311
  // resurfaces; if that is ever seen, remove the join row inside `unpublishStrand`.
295
312
  for (const row of partyWide.rows) {
@@ -317,10 +334,18 @@ export class JoinedStrandSession {
317
334
  return [...offered.values()];
318
335
  }
319
336
 
337
+ /**
338
+ * True when the last {@link withControlRows} offered the party-wide joins from the last
339
+ * list that answered, because its read of `JoinedStrand` failed.
340
+ */
341
+ lastReadPartial(): boolean {
342
+ return !this.lastPartyWideFresh;
343
+ }
344
+
320
345
  /**
321
346
  * This machine's unpublished joins, straight from the store (never the last-good list a
322
347
  * failed read stands in with). What a strand move exports beside the `JoinedStrand`
323
- * table: a machine publishes only while it holds a control connection, so until then its
348
+ * table: a machine publishes only while a sibling is reachable, so until then its
324
349
  * joins live only here.
325
350
  */
326
351
  unpublishedRecords(): Promise<JoinedStrandRecord[]> {
@@ -331,21 +356,32 @@ export class JoinedStrandSession {
331
356
  async remember(record: JoinedStrandRecord): Promise<void> {
332
357
  this.pendingRemovals.delete(record.Id);
333
358
  await this.store.record(record);
359
+ this.onLocalChange();
334
360
  }
335
361
 
336
362
  /**
337
363
  * Record `row`, joined by `addStrand`, unless this party's control database already names
338
364
  * it (its own strand, or a join already published) or an identical record is already here
339
- * (an app re-claiming a remembered join on every start). Keeps the first `joinedAt`. Like
365
+ * (an app re-claiming a remembered join on every start, or `addStrand` right after
366
+ * `formStrand`). The store is asked first: it answers from memory once loaded, so the
367
+ * common re-claim reads no control row. Keeps the first `joinedAt`. Like
340
368
  * {@link remember}, cancels a queued removal.
341
369
  */
342
370
  async rememberForeign(row: StrandRow): Promise<void> {
343
371
  this.pendingRemovals.delete(row.Id);
372
+ const early = await this.storedRecord(row.Id).catch((error: unknown) => {
373
+ // Asked again below only when the party does not name the strand; a second failure throws.
374
+ log('joined strand %s: store lookup failed; asking the control database first: %o', row.Id, error);
375
+ return null;
376
+ });
377
+ if (early && isSameJoin(early, row)) {
378
+ return;
379
+ }
344
380
  if (await this.namedByParty(row.Id)) {
345
381
  return;
346
382
  }
347
- const existing = (await this.store.list()).find((record) => record.Id === row.Id);
348
- if (existing?.Type === row.Type && existing.MemberPrivateKey === row.MemberPrivateKey) {
383
+ const existing = early === null ? await this.storedRecord(row.Id) : early;
384
+ if (existing && isSameJoin(existing, row)) {
349
385
  return;
350
386
  }
351
387
  await this.store.record({
@@ -354,6 +390,7 @@ export class JoinedStrandSession {
354
390
  MemberPrivateKey: row.MemberPrivateKey,
355
391
  joinedAt: existing?.joinedAt ?? Date.now()
356
392
  });
393
+ this.onLocalChange();
357
394
  }
358
395
 
359
396
  /**
@@ -367,6 +404,7 @@ export class JoinedStrandSession {
367
404
  this.keptForSession.delete(strandId);
368
405
  this.lastPartyWide = this.lastPartyWide.filter((row) => row.Id !== strandId);
369
406
  await this.store.forget(strandId);
407
+ this.onLocalChange();
370
408
  });
371
409
  }
372
410
 
@@ -385,13 +423,14 @@ export class JoinedStrandSession {
385
423
  if (!row) {
386
424
  return;
387
425
  }
388
- // Kept BEFORE the store forgets, so no poll can union without it. Queued even for a
426
+ // Kept BEFORE the store forgets, so no watcher run can union without it. Queued even for a
389
427
  // local-only join, which a sibling may have published already.
390
428
  this.keptForSession.set(strandId, row);
391
429
  this.pendingRemovals.add(strandId);
392
430
  if (local) {
393
431
  await this.store.forget(strandId);
394
432
  }
433
+ this.onLocalChange();
395
434
  });
396
435
  }
397
436
 
@@ -423,15 +462,16 @@ export class JoinedStrandSession {
423
462
  }
424
463
 
425
464
  // NOTE: a sibling that joined the same strand and has not yet published publishes it after
426
- // a leave, bringing the strand back party-wide. Needs two devices joining one strand within
427
- // a reconcile interval of the leave; if it is ever seen, check the strand's `Revocation`
465
+ // a leave, bringing the strand back party-wide. Needs a second device to have joined the
466
+ // strand and not yet run a connected reconcile pass when the leave lands (a join starts no
467
+ // pass, so up to the 2-minute backstop); if it is ever seen, check the strand's `Revocation`
428
468
  // tombstone before publishing.
429
469
  private async publish(record: JoinedStrandRecord): Promise<void> {
430
470
  try {
431
471
  if (!(await this.ledger.names(record.Id))) {
432
472
  await this.ledger.publish(record);
433
473
  // Into the last-good list too: its local record goes next, so a party-wide read that
434
- // fails before one succeeds would otherwise drop the strand from the poll and hide it
474
+ // fails before one succeeds would otherwise drop the strand from the watcher's read and hide it
435
475
  // from a self-revocation.
436
476
  this.lastPartyWide = [...this.lastPartyWide.filter((row) => row.Id !== record.Id), joinedStrandRow(record)];
437
477
  log('joined strand %s published party-wide (party=%s)', record.Id, this.store.partyId);
@@ -457,19 +497,20 @@ export class JoinedStrandSession {
457
497
  private async partyWide(): Promise<{ rows: StrandRow[]; fresh: boolean }> {
458
498
  try {
459
499
  this.lastPartyWide = await this.ledger.list();
460
- return { rows: this.lastPartyWide, fresh: true };
500
+ this.lastPartyWideFresh = true;
461
501
  } catch (error) {
462
502
  log('party-wide joined-strand read failed; offering the last list it answered (%d join(s)): %o',
463
503
  this.lastPartyWide.length, error);
464
- return { rows: this.lastPartyWide, fresh: false };
504
+ this.lastPartyWideFresh = false;
465
505
  }
506
+ return { rows: this.lastPartyWide, fresh: this.lastPartyWideFresh };
466
507
  }
467
508
 
468
509
  /**
469
510
  * {@link PartyJoinedStrandLedger.names}, answering false when the control database cannot
470
511
  * be read (a machine cut off from its party before it received the `JoinedStrand` block).
471
512
  * Recording is the safe side: a local record the party turns out to name is forgotten by
472
- * the next poll that reads it, while a join with no record is not offered after a restart.
513
+ * the next watcher run that reads it, while a join with no record is not offered after a restart.
473
514
  */
474
515
  private async namedByParty(strandId: string): Promise<boolean> {
475
516
  try {
@@ -480,6 +521,10 @@ export class JoinedStrandSession {
480
521
  }
481
522
  }
482
523
 
524
+ private async storedRecord(strandId: string): Promise<JoinedStrandRecord | undefined> {
525
+ return (await this.store.list()).find((record) => record.Id === strandId);
526
+ }
527
+
483
528
  /** Run `work` after every earlier serialized move settles; see {@link exclusive}. */
484
529
  private serialized<T>(work: () => Promise<T>): Promise<T> {
485
530
  const result = this.exclusive.then(work);
@@ -502,7 +547,7 @@ export class JoinedStrandSession {
502
547
  await this.store.forget(strandId);
503
548
  log('joined strand %s is named by this party\'s control database — local record forgotten', strandId);
504
549
  } catch (error) {
505
- log('forgetting superseded joined strand %s failed; the next poll retries: %o', strandId, error);
550
+ log('forgetting superseded joined strand %s failed; the next watcher run retries: %o', strandId, error);
506
551
  }
507
552
  }
508
553
  }
package/src/key-store.ts CHANGED
@@ -2,10 +2,10 @@
2
2
  * Backend-agnostic store for raw private key material.
3
3
  *
4
4
  * Mobile cadre nodes hold sensitive key material at rest: the libp2p
5
- * peer/node identity key and (in the single-key reference model) the owner
6
- * signing key derived from it. This module defines the seam those keys flow
7
- * through so a platform-secure backend (iOS Keychain / Android Keystore) can be
8
- * plugged in without `@serfab/cadre-core` taking any platform dependency.
5
+ * peer/node identity key and, on a device the user made an owner, an owner key
6
+ * in a slot of its own (`owner-key-store.ts`). This module defines the seam those
7
+ * keys flow through so a platform-secure backend (iOS Keychain / Android Keystore)
8
+ * can be plugged in without `@serfab/cadre-core` taking any platform dependency.
9
9
  *
10
10
  * This file is dependency-free (no node:/RN imports) so it is safe in every
11
11
  * entry graph. The Node reference {@link FileKeyStore} lives in a separate
@@ -23,6 +23,12 @@ export type KeyId = string;
23
23
  */
24
24
  export const DEFAULT_IDENTITY_KEY_ID: KeyId = 'cadre/identity';
25
25
 
26
+ /**
27
+ * Default slot id for an owner key (`owner-key-store.ts`). Not party-scoped: one
28
+ * owner key may own several cadres, each owner signature binding its party id.
29
+ */
30
+ export const DEFAULT_OWNER_KEY_ID: KeyId = 'cadre/owner';
31
+
26
32
  /**
27
33
  * Backend-agnostic store for raw private key material. Implementations may be
28
34
  * file-based, OS-keyring, or platform secure-enclave backed (iOS Keychain /
@@ -392,13 +392,15 @@ export const PUSH_TRANSFER_ALLOWANCE_MS = 6000;
392
392
  * every members-only protocol from the materialized snapshot, and unplaced
393
393
  * relay reservations stay capped (`MAX_UNAUTHORIZED_RELAY_RESERVATIONS`).
394
394
  * - **The slow case is bring-up, not steady state.** A membership read consults
395
- * the cohort only for a block this node does not hold, or before the
396
- * `Revocation` ledger marker exists; once the marker exists every block the
397
- * decision reads is held (`control-founding-consult-budget.spec.ts` pins both
398
- * states). So the gate always fails open only on a node whose membership reads
399
- * still consult: one consult costs about two link round trips, and asking a
400
- * silent peer costs the per-peer read deadline ({@link cohortReadDeadlineMs}, 7 s
401
- * at the default declaration).
395
+ * the cohort only for a block this node does not hold: before a joining
396
+ * machine has read it from the cohort, or for a table nobody has written on a
397
+ * party founded before genesis created every control collection. Otherwise
398
+ * every block the decision reads is held
399
+ * (`control-founding-consult-budget.spec.ts` pins that). So the gate always
400
+ * fails open only on a node whose membership reads still consult: one consult
401
+ * costs about two link round trips, and asking a silent peer costs the
402
+ * per-peer read deadline ({@link cohortReadDeadlineMs}, 7 s at the default
403
+ * declaration).
402
404
  * - **The decision is spent on the dialing machine's clock.** libp2p's listener
403
405
  * runs this gate before it answers the multiplexer negotiation the dialer is
404
406
  * waiting on (`libp2p/dist/src/upgrader.js`), and the listener's own
@@ -1,17 +1,15 @@
1
1
  /**
2
- * Removing a machine from a cadre: the listing an app shows (`CadreNode.listMachines`), the
3
- * read-only plan behind its confirmation step (`CadreNode.planMachineRemoval`), and the
4
- * check that a requested re-vouch stays inside that plan. Pure projections over rows the
5
- * node has already read; the reads and writes themselves are `CadreNode.removeMachine`'s and
6
- * `ControlDatabase.removeMachine`'s.
2
+ * Removing a machine from a cadre: the listing an app shows (`CadreNode.listMachines`) and the
3
+ * read-only plan behind its confirmation step (`CadreNode.planMachineRemoval`). Pure
4
+ * projections over rows the node has already read; the reads and writes themselves are
5
+ * `CadreNode.removeMachine`'s and `ControlDatabase.removeMachine`'s.
7
6
  *
8
- * A machine's owner key is its own identity key: an owner-granting invitation seats
9
- * `OwnerKey.Key = CadrePeer.PublicKey`, and `addOwner` takes the key behind a peer id. So
10
- * "this machine is an owner" is "the ed25519 key behind its peer id is a live `OwnerKey`",
11
- * and an owner key with no machine row is still listed, as the machine it names.
7
+ * A machine is a `CadrePeer` row and nothing more: an owner key is the person's, so removing a
8
+ * machine leaves every owner key standing, the one the machine's user signed with included
9
+ * (removing one is `CadreNode.removeOwner`, `owner-keys.ts`).
12
10
  */
13
11
 
14
- import { ed25519PublicKeyB64FromPeerId, peerIdFromEd25519PublicKeyB64 } from './ed25519-key.js';
12
+ import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
15
13
  import { verifyPeerRecordSignature } from './peer-record.js';
16
14
  import type {
17
15
  CadreInviteStatus,
@@ -20,8 +18,6 @@ import type {
20
18
  CadrePeerRow,
21
19
  MachineRemovalPlan,
22
20
  MachineRemovalRefusal,
23
- OwnerKeyRow,
24
- RemoveMachineOptions,
25
21
  } from './types.js';
26
22
 
27
23
  /**
@@ -42,8 +38,7 @@ export class MachineRemovalRefusedError extends Error {
42
38
 
43
39
  const MACHINE_REMOVAL_REFUSAL_REASON: Record<MachineRemovalRefusal, string> = {
44
40
  'not-an-owner': 'this node holds no enrolled owner key',
45
- 'self': 'an owner cannot remove its own machine; ask another owner',
46
- 'last-owner': 'it holds the only live owner key',
41
+ 'self': 'it is this node; leaving a cadre is stopping the node and deleting its data',
47
42
  };
48
43
 
49
44
  /** What {@link describeMachines} projects from: one node's live rows and its own predicate. */
@@ -52,45 +47,21 @@ export interface MachineListingInput {
52
47
  selfPeerId: string | null;
53
48
  /** Live `CadrePeer` rows (`ControlDatabase.queryCadrePeers`). */
54
49
  rows: readonly CadrePeerRow[];
55
- /** Live `OwnerKey` rows (`ControlDatabase.queryOwnerKeyRows`). */
56
- owners: readonly OwnerKeyRow[];
57
50
  /** This node's authorized-membership predicate for one row (`CadreNode.listAuthorizedMembers`'s). */
58
51
  trusted: (row: CadrePeerRow) => boolean;
59
52
  }
60
53
 
61
54
  /**
62
- * One entry per live `CadrePeer` row, then one per live `OwnerKey` whose key derives a peer
63
- * id with no row. `profile` and `lastPublishedAt` are reported only when the row's
64
- * self-signature verifies under the key behind its peer id, as `CadreNode.resolvePeerAddrs`
65
- * judges the same record: no insert rule constrains `Profile`, and an owner re-vouch may
66
- * bump `UpdatedAt` without re-signing.
55
+ * One entry per live `CadrePeer` row. `profile` and `lastPublishedAt` are reported only when
56
+ * the row's self-signature verifies under the key behind its peer id, as
57
+ * `CadreNode.resolvePeerAddrs` judges the same record: no insert rule constrains `Profile`, and
58
+ * an owner re-vouch may bump `UpdatedAt` without re-signing.
67
59
  */
68
60
  export function describeMachines(input: MachineListingInput): CadreMachine[] {
69
- const ownerKeys = new Set(input.owners.map((owner) => owner.key));
70
- const machines = input.rows.map((row) => describeMemberRow(input, row, ownerKeys));
71
- const listed = new Set(machines.map((machine) => machine.peerId));
72
- for (const owner of input.owners) {
73
- const peerId = peerIdFromEd25519PublicKeyB64(owner.key);
74
- if (peerId === null || listed.has(peerId)) {
75
- continue;
76
- }
77
- listed.add(peerId);
78
- machines.push({
79
- peerId,
80
- publicKey: owner.key,
81
- isSelf: peerId === input.selfPeerId,
82
- isOwner: true,
83
- isMember: false,
84
- trusted: false,
85
- profile: null,
86
- lastPublishedAt: null,
87
- vouchedBy: owner.vouchOwner,
88
- });
89
- }
90
- return machines;
61
+ return input.rows.map((row) => describeMemberRow(input, row));
91
62
  }
92
63
 
93
- function describeMemberRow(input: MachineListingInput, row: CadrePeerRow, ownerKeys: ReadonlySet<string>): CadreMachine {
64
+ function describeMemberRow(input: MachineListingInput, row: CadrePeerRow): CadreMachine {
94
65
  const publicKey = ed25519PublicKeyB64FromPeerId(row.peerId);
95
66
  const selfPublished = publicKey !== null && row.publicKey === publicKey && row.sig !== null
96
67
  && verifyPeerRecordSignature(input.partyId, {
@@ -105,8 +76,6 @@ function describeMemberRow(input: MachineListingInput, row: CadrePeerRow, ownerK
105
76
  peerId: row.peerId,
106
77
  publicKey,
107
78
  isSelf: row.peerId === input.selfPeerId,
108
- isOwner: publicKey !== null && ownerKeys.has(publicKey),
109
- isMember: true,
110
79
  trusted: input.trusted(row),
111
80
  profile: selfPublished ? row.profile : null,
112
81
  lastPublishedAt: selfPublished ? row.updatedAt : null,
@@ -118,99 +87,45 @@ function describeMemberRow(input: MachineListingInput, row: CadrePeerRow, ownerK
118
87
  export interface MachineRemovalPlanInput {
119
88
  peerId: string;
120
89
  machines: readonly CadreMachine[];
121
- rows: readonly CadrePeerRow[];
122
- owners: readonly OwnerKeyRow[];
123
90
  selfPeerId: string | null;
124
- /** This node's enrolled owner key (`CadreNode.enrolledOwnerSigningKey`), or null when it cannot sign owner writes. */
125
- selfOwnerKey: string | null;
91
+ /** Whether this node's owner signer is an enrolled owner (`CadreNode.ownerSignerEnrolledIn`), so it can sign the removal. */
92
+ canSign: boolean;
126
93
  invites: readonly CadreInviteStatus[];
127
94
  usages: readonly CadreInviteUsageRow[];
128
95
  }
129
96
 
130
97
  /**
131
- * The plan for removing `peerId` from these rows. The refusals come first and by name: a
132
- * node that cannot sign, the node itself (an owner cannot sign its own `OwnerKey` removal;
133
- * refusing the whole self-removal keeps one rule for owners and non-owners), and a machine
134
- * holding the only live owner key. The last cannot coexist with this node being a different
135
- * owner, but the local count can be wrong under concurrency, so the name stays.
136
- *
137
- * `dependents` are the rows the machine's owner key vouched, which every reader judges
138
- * against its anchor through `VouchOwner`: once the key is removed they stop passing the
139
- * membership predicate on every machine unless re-vouched first. This node's own rows are
140
- * reported as `ownDependency` instead, since it cannot re-vouch them itself.
98
+ * The plan for removing `peerId` from these rows. The refusals come first and by name: a node
99
+ * that cannot sign, and the node itself (leaving a cadre is the embedder's flow, which stops
100
+ * the node and deletes its data; the removal notice a machine acts on is built around another
101
+ * machine removing it).
141
102
  *
142
103
  * `invitations` are the live ones the machine could redeem to come back: targeted at it,
143
104
  * or already redeemed by it and still redeemable (a multi-use invitation, or an open
144
- * owner invitation inside its lifetime). The schema's rule: keeping a device out is removing
105
+ * invitation inside its lifetime). The schema's rule: keeping a device out is removing
145
106
  * its row and withdrawing the invitation.
146
107
  */
147
108
  export function planMachineRemoval(input: MachineRemovalPlanInput): MachineRemovalPlan {
148
- const machine = input.machines.find((candidate) => candidate.peerId === input.peerId) ?? null;
149
- const key = machine?.isOwner ? machine.publicKey : null;
150
- const refusal = refusalFor(input, machine);
151
- const removesOwnerKey = key !== null;
152
109
  return {
153
- machine,
154
- refusal,
155
- removesOwnerKey,
156
- dependents: removesOwnerKey ? dependentsOf(input, key) : { owners: [], peers: [] },
110
+ machine: input.machines.find((candidate) => candidate.peerId === input.peerId) ?? null,
111
+ refusal: refusalFor(input),
157
112
  invitations: invitationsOpenTo(input),
158
- ownDependency: removesOwnerKey && isOwnDependency(input, key),
159
113
  };
160
114
  }
161
115
 
162
- function refusalFor(input: MachineRemovalPlanInput, machine: CadreMachine | null): MachineRemovalRefusal | null {
163
- if (input.selfOwnerKey === null) {
116
+ function refusalFor(input: MachineRemovalPlanInput): MachineRemovalRefusal | null {
117
+ if (!input.canSign) {
164
118
  return 'not-an-owner';
165
119
  }
166
120
  if (input.peerId === input.selfPeerId) {
167
121
  return 'self';
168
122
  }
169
- if (machine?.isOwner && input.owners.length <= 1) {
170
- return 'last-owner';
171
- }
172
123
  return null;
173
124
  }
174
125
 
175
- function dependentsOf(input: MachineRemovalPlanInput, key: string): MachineRemovalPlan['dependents'] {
176
- return {
177
- owners: input.owners
178
- .filter((owner) => owner.vouchOwner === key && owner.key !== key && owner.key !== input.selfOwnerKey)
179
- .map((owner) => owner.key),
180
- peers: input.rows
181
- .filter((row) => row.vouchOwner === key && row.peerId !== input.peerId && row.peerId !== input.selfPeerId)
182
- .map((row) => row.peerId),
183
- };
184
- }
185
-
186
- function isOwnDependency(input: MachineRemovalPlanInput, key: string): boolean {
187
- const ownRow = input.rows.find((row) => row.peerId === input.selfPeerId);
188
- const ownOwner = input.owners.find((owner) => owner.key === input.selfOwnerKey);
189
- return ownRow?.vouchOwner === key || ownOwner?.vouchOwner === key;
190
- }
191
-
192
126
  function invitationsOpenTo(input: MachineRemovalPlanInput): string[] {
193
127
  const redeemed = new Set(input.usages.filter((usage) => usage.peerId === input.peerId).map((usage) => usage.inviteKey));
194
128
  return input.invites
195
129
  .filter((status) => status.live && (status.invite.peerId === input.peerId || redeemed.has(status.invite.key)))
196
130
  .map((status) => status.invite.key);
197
131
  }
198
-
199
- /**
200
- * The re-vouch a caller asked for, checked against the plan: every key and peer must be in
201
- * `plan.dependents`, so a caller cannot re-vouch a row the removed owner never vouched (nor
202
- * the machine itself, which `dependents.peers` excludes). Throws naming the offenders, before
203
- * `CadreNode.removeMachine` writes anything. Duplicates are dropped.
204
- */
205
- export function revouchWithinPlan(plan: MachineRemovalPlan, revouch: RemoveMachineOptions['revouch']): MachineRemovalPlan['dependents'] {
206
- const owners = [...new Set(revouch?.owners ?? [])];
207
- const peers = [...new Set(revouch?.peers ?? [])];
208
- const strayOwners = owners.filter((key) => !plan.dependents.owners.includes(key));
209
- const strayPeers = peers.filter((peerId) => !plan.dependents.peers.includes(peerId));
210
- if (strayOwners.length > 0 || strayPeers.length > 0) {
211
- throw new Error(
212
- `removeMachine: re-vouch names rows the removed machine did not vouch: ${[...strayOwners, ...strayPeers].join(', ')}`,
213
- );
214
- }
215
- return { owners, peers };
216
- }