@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
@@ -0,0 +1,151 @@
1
+ /**
2
+ * What an app supplies to run the edge node (`createEdgeNode`): its key store, its storage, and
3
+ * the platform choices (transports, listen addresses, Noise crypto) that cadre-core cannot make
4
+ * for it. Fixed for the app's lifetime.
5
+ */
6
+
7
+ import type { IRawStorage, Libp2pTransports, NoiseCryptoInterface } from '@optimystic/db-p2p';
8
+ import type { KeyStore } from '../key-store.js';
9
+ import type { DurableSlot } from '../node-local-snapshot.js';
10
+ import type { CadreNodeConfig } from '../types.js';
11
+ import type { NodeLocalKv } from './node-local.js';
12
+ import type { NoiseCryptoMode } from './options.js';
13
+
14
+ export interface EdgeNodePlatform {
15
+ /**
16
+ * Everything secret: the identity (`DEFAULT_IDENTITY_KEY_ID`), the owner key in its own slot
17
+ * (`DEFAULT_OWNER_KEY_ID`), the joined-strand records (`KeyStoreJoinedStrandStore`) and the
18
+ * strand-move records. The phone's is the platform secure store; a browser's or a desktop
19
+ * app's may be a plaintext database, which is then where its identity already lives.
20
+ */
21
+ keyStore: KeyStore;
22
+ storage: EdgeNodeStorage;
23
+ /**
24
+ * libp2p transports, given the relays the node reserves on. Default: WebSockets and circuit
25
+ * relay. An app with WebRTC adds `webRTC({ rtcConfiguration: { iceServers } })`, `iceServers`
26
+ * from `resolveStunServers(relayAddrs)`.
27
+ */
28
+ transports?: (relayAddrs: readonly string[]) => Libp2pTransports;
29
+ /**
30
+ * Listen addresses besides the bare `/p2p-circuit` entry cadre-core adds for a named relay
31
+ * (`relay-addrs.ts` → `resolveListenAddrs`). Default `[]`: an edge device cannot accept a
32
+ * connection, so the only address it has is the one a relay reservation earns it. A browser
33
+ * tab returns `['/webrtc']` when it has relays, so a relayed connection can upgrade to a direct
34
+ * one.
35
+ */
36
+ listenAddrs?: (relayAddrs: readonly string[]) => string[];
37
+ /**
38
+ * Noise crypto built outside libp2p-noise: `build` returns the implementation for a mode,
39
+ * `defaultMode` the mode used when the start options name none. Absent: libp2p-noise's own
40
+ * crypto, pure JavaScript on a phone (the React Native kit's `/noise-crypto` is the native one).
41
+ */
42
+ noiseCrypto?: {
43
+ build: (mode: NoiseCryptoMode) => NoiseCryptoInterface | undefined;
44
+ defaultMode: NoiseCryptoMode;
45
+ };
46
+ /**
47
+ * The keys inside the node-local store. Each is a persistence contract: renaming one orphans
48
+ * every installed app's records. The defaults are the reference apps'; an app that names its
49
+ * own should pin them in a test.
50
+ */
51
+ names?: Partial<EdgeNodeNames>;
52
+ /**
53
+ * Dial loopback, private and plain `ws://` addresses. Default `true`: see the
54
+ * `connectionGater` comment in `buildEdgeNodeConfig`.
55
+ */
56
+ allowPrivateDial?: boolean;
57
+ /** Bound on owner genesis; default 60 s. See `runOwnerGenesis`. */
58
+ ownerGenesisTimeoutMs?: number;
59
+ /**
60
+ * A version naming the format of the data this build writes, for example the stack version it
61
+ * was built against. Saved with each successful start and reported by `loadSavedStart` as
62
+ * `writtenBy`, so a later build can tell data it cannot use from data it can.
63
+ */
64
+ dataVersion?: string;
65
+ /**
66
+ * The last word on the config the edge node built: strand filter, schema-signing policy,
67
+ * hibernation, `linkRoundTripMs`, or anything the parameters above do not cover.
68
+ */
69
+ configure?: (config: CadreNodeConfig) => CadreNodeConfig;
70
+ }
71
+
72
+ /** The keys inside the node-local store (`EdgeNodeStorage.nodeLocal`) that are the edge node's. */
73
+ export interface EdgeNodeNames {
74
+ /**
75
+ * Key of the saved-start record. One per app, not per party: it is what names the party every
76
+ * other record is filed under. Dot-free, so it cannot collide with the `<record>.<partyId>`
77
+ * keys of `node-local.ts`.
78
+ */
79
+ savedStartKey: string;
80
+ /**
81
+ * Key of the record naming a party whose dissolve has begun. Dot-free and one per app, as
82
+ * {@link EdgeNodeNames.savedStartKey} is.
83
+ */
84
+ dissolveMarkerKey: string;
85
+ /**
86
+ * Key of the join request this app opened as a link to another app (`LinkRequestStore` in
87
+ * `@serfab/cadre-rn/app-link`). Dot-free and one per app, as {@link EdgeNodeNames.savedStartKey} is.
88
+ */
89
+ linkRequestKey: string;
90
+ /**
91
+ * Key of the apps the user allowed to join before they asked (`AppRecommendationStore` in
92
+ * `@serfab/cadre-rn/app-link`). Dot-free and one per app, as {@link EdgeNodeNames.savedStartKey}
93
+ * is; each entry names its party.
94
+ */
95
+ recommendationsKey: string;
96
+ }
97
+
98
+ export const DEFAULT_EDGE_NODE_NAMES: EdgeNodeNames = {
99
+ savedStartKey: 'start-options',
100
+ dissolveMarkerKey: 'dissolving-party',
101
+ linkRequestKey: 'link-join-request',
102
+ recommendationsKey: 'recommended-apps',
103
+ };
104
+
105
+ /**
106
+ * The app's storage, as the edge node opens, closes and deletes it: one raw block store per
107
+ * cadre-core storage scope, and one node-local key-value store beside them. The platform owns
108
+ * every handle; the edge node only says when it is done with them (`close`).
109
+ */
110
+ export interface EdgeNodeStorage {
111
+ /**
112
+ * Names the storage. `createEdgeNode` refuses a second starting, joining or running edge node
113
+ * over the same id in one process, and replaces a stopped one: two over the same storage
114
+ * would share databases and an identity.
115
+ */
116
+ readonly id: string;
117
+ /**
118
+ * One storage scope's raw block storage: `controlStorageScope(partyId)` for the control
119
+ * network, the strand id for each strand. The key is already safe as a database-name segment;
120
+ * use it verbatim, do not parse it. Synchronous, as `CadreNodeConfig.storage.provider` is; a
121
+ * platform whose opener is async returns `lazyRawStorage(...)`. The same handle for the same
122
+ * scope until {@link close}: cadre-core asks once per scope per runtime, and again after a
123
+ * strand stops.
124
+ */
125
+ rawStorage(scope: string): IRawStorage;
126
+ /** Delete a scope's storage. No handle on it may be open. Resolves when it is gone or was absent. */
127
+ destroyScope(scope: string): Promise<void>;
128
+ /**
129
+ * The node-local key-value store, holding the saved start, the dissolve marker, the app-link
130
+ * records and the per-party records of `node-local.ts`. Usable with no node running: reopens
131
+ * after {@link close}.
132
+ */
133
+ nodeLocal(): NodeLocalKv;
134
+ /**
135
+ * Flush and close the node-local store, then close every handle {@link rawStorage} opened. A
136
+ * failure closing one is logged and the rest still close; the call does not reject.
137
+ */
138
+ close(): Promise<void>;
139
+ /**
140
+ * Per-party slot for the trusted-owner anchor (`PersistentTrustedOwnerStore`). Absent: the
141
+ * node-local key `trustedOwnersKvKey(partyId)`. The anchor is not secret but it is
142
+ * trust-bearing, so the phone puts it in its secure store, beside the identity it qualifies.
143
+ */
144
+ trustedOwnerSlot?(partyId: string): RemovableSlot;
145
+ }
146
+
147
+ /** A {@link DurableSlot} a removal can delete: the trusted-owner anchor of a party being removed. */
148
+ export interface RemovableSlot extends DurableSlot {
149
+ /** Delete the slot's record, durably. Resolves when it is gone or was absent. */
150
+ remove(): Promise<void>;
151
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Carrying the strands of the party this app founded into the party it joins
3
+ * (docs/architecture.md → "An app joins an existing cadre" → "Moving the app's strands").
4
+ *
5
+ * The app runs one node, so cadre-core's two ends of a move run on two nodes, one after the
6
+ * other: `exportStrandBundle` on the left party's node before the switch, `adoptStrandBundle` on
7
+ * the joined party's node once the app's key is wired as an owner there. Between them the bundle
8
+ * is kept here, one record per left party, so a join killed partway, or an owner key wired only
9
+ * on a later launch, finishes the move on a later start. The bundle carries read secrets, party
10
+ * keys and bearer invitations, so the record lives in the key store beside the identity.
11
+ */
12
+
13
+ import { adoptStrandBundle, StrandMoveNotOwnerError } from '../strand-move.js';
14
+ import type { StrandBundle, StrandMoveReport } from '../strand-move.js';
15
+ import type { CadreNode } from '../cadre-node.js';
16
+ import type { KeyId, KeyStore } from '../key-store.js';
17
+ import { isOwnerGrant, type JoinCredential } from './join-credential.js';
18
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
19
+
20
+ /** How a join's strand move settled: `JoinCadreResult.strandMove`. */
21
+ export type StrandMoveOutcome =
22
+ /** Adopted. `partial`: `report.failed` lists what the next start, or `finishStrandMove`, retries. */
23
+ | { state: 'done' | 'partial'; report: StrandMoveReport }
24
+ /** Not adopted yet: the app's key is not wired as an owner of the joined party. A start after it is adopts. */
25
+ | { state: 'deferred'; reason: 'owner-key-not-wired' }
26
+ /** Not adopted yet: the adoption threw (logged). The next start, or `finishStrandMove`, retries. */
27
+ | { state: 'deferred'; reason: 'adoption-failed'; error: Error };
28
+
29
+ /** A move still waiting: `EdgeNode.pendingStrandMove`. */
30
+ export interface PendingStrandMove {
31
+ fromPartyId: string;
32
+ /** The party the strands move into; the record is adopted there and nowhere else. */
33
+ toPartyId: string;
34
+ /** Strands of `fromPartyId` not seated in `toPartyId` yet. */
35
+ strandIds: string[];
36
+ /** Strands `toPartyId` refused permanently so far: it participates in them under its own identity. */
37
+ refused: StrandMoveReport['refused'];
38
+ }
39
+
40
+ /**
41
+ * `EdgeNode.joinCadre` refused before anything stopped: the party this app founded holds strands,
42
+ * pending joins or invitations, and a machine invitation does not make the app an owner of the
43
+ * party it joins. Every row a move seats there is owner-signed, so a member's join would leave
44
+ * them in a party the app no longer runs. Joining with an owner grant moves them.
45
+ */
46
+ export class StrandsNeedOwnerJoinError extends Error {
47
+ readonly fromPartyId: string;
48
+ readonly toPartyId: string;
49
+ readonly strandCount: number;
50
+
51
+ constructor(bundle: StrandBundle, toPartyId: string) {
52
+ super(
53
+ `Cannot join party ${toPartyId} as a member: ${describeContent(bundle)} of party ${bundle.partyId} ` +
54
+ 'would be left behind, and only an owner can move them. Join with an owner grant instead, made under the owner app\'s Owner keys.',
55
+ );
56
+ this.name = 'StrandsNeedOwnerJoinError';
57
+ this.fromPartyId = bundle.partyId;
58
+ this.toPartyId = toPartyId;
59
+ this.strandCount = bundle.strands.length;
60
+ }
61
+ }
62
+
63
+ /** What the key store holds for one left party between the export and a complete adoption. */
64
+ interface StrandMoveRecord {
65
+ v: typeof RECORD_VERSION;
66
+ targetPartyId: string;
67
+ bundle: StrandBundle;
68
+ /** Strand ids an earlier adoption seated. Never left out of a retry: an invitation bound to one needs it under `moved`. */
69
+ moved: string[];
70
+ /** Permanent refusals from earlier adoptions, left out of a retry and reported each time. */
71
+ refused: StrandMoveReport['refused'];
72
+ }
73
+
74
+ const RECORD_VERSION = 1;
75
+
76
+ /** Key-store id prefix of every move record; the left party's id follows, base64url. */
77
+ const SLOT_PREFIX = 'cadre/strand-move/';
78
+
79
+ /**
80
+ * A persistence contract: `cadre/strand-move/<base64url of the party id's UTF-8 bytes>`, the
81
+ * id the React Native kit wrote before the records moved here (its `secureStoreKeySegment`
82
+ * encodes the same way), so an installed phone's record is read back.
83
+ */
84
+ function slotId(fromPartyId: string): KeyId {
85
+ return SLOT_PREFIX + uint8ArrayToString(uint8ArrayFromString(fromPartyId, 'utf8'), 'base64url');
86
+ }
87
+
88
+ /** The move records, one key-store slot per left party. A read the key store refuses throws; it is never read as "no record". */
89
+ export class StrandMoveRecords {
90
+ constructor(private readonly keyStore: KeyStore) {}
91
+
92
+ /**
93
+ * Replaces the left party's record: a newer export is the fuller one.
94
+ *
95
+ * NOTE: on a phone, a bundle with a routing table per strand runs past expo-secure-store's
96
+ * 2048-byte value guideline; version 14 warns and stores it, and the iOS Keychain and Android's
97
+ * encrypted preferences hold more. If a later SDK throws instead, keep the routing tables
98
+ * (not secret) in the node-local store and only the secrets here.
99
+ */
100
+ save(record: StrandMoveRecord): Promise<void> {
101
+ return this.keyStore.set(slotId(record.bundle.partyId), uint8ArrayFromString(JSON.stringify(record), 'utf8'));
102
+ }
103
+
104
+ load(fromPartyId: string): Promise<StrandMoveRecord | undefined> {
105
+ return this.read(slotId(fromPartyId));
106
+ }
107
+
108
+ remove(fromPartyId: string): Promise<void> {
109
+ return this.keyStore.delete(slotId(fromPartyId));
110
+ }
111
+
112
+ /** Every record naming `toPartyId`, in slot order. */
113
+ async naming(toPartyId: string): Promise<StrandMoveRecord[]> {
114
+ const slots = (await this.keyStore.list()).filter((slot) => slot.startsWith(SLOT_PREFIX)).sort();
115
+ const records: StrandMoveRecord[] = [];
116
+ for (const slot of slots) {
117
+ const record = await this.read(slot);
118
+ if (record?.targetPartyId === toPartyId) records.push(record);
119
+ }
120
+ return records;
121
+ }
122
+
123
+ /**
124
+ * The record in `slot`. One that does not parse is deleted with a warning: nothing can adopt
125
+ * it, and the strands are still in the left party's storage. Its absence then reads as
126
+ * "nothing waiting", so code that deletes that storage must not take a missing record alone
127
+ * as proof that every strand reached the joined party.
128
+ */
129
+ private async read(slot: KeyId): Promise<StrandMoveRecord | undefined> {
130
+ const bytes = await this.keyStore.get(slot);
131
+ if (bytes === undefined) return undefined;
132
+ const record = parseRecord(uint8ArrayToString(bytes, 'utf8'));
133
+ if (record !== undefined && slotId(record.bundle.partyId) === slot) return record;
134
+ console.warn(`[cadre-core/edge-node] deleting the strand move record in ${slot}: it is not one this kit wrote`);
135
+ try {
136
+ await this.keyStore.delete(slot);
137
+ } catch (err) {
138
+ // Read as absent either way; failing every start over a record nothing can use would be worse.
139
+ console.warn(`[cadre-core/edge-node] could not delete the strand move record in ${slot}:`, err);
140
+ }
141
+ return undefined;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * The record a join writes before the switch, or `undefined` when the bundle holds nothing to
147
+ * move. Throws {@link StrandsNeedOwnerJoinError} when it does and `credential` is a machine
148
+ * invitation, which does not make the app an owner of the party it joins.
149
+ */
150
+ export function joinMoveRecord(bundle: StrandBundle, credential: JoinCredential): StrandMoveRecord | undefined {
151
+ if (bundle.strands.length + bundle.pendingJoins.length + bundle.formationInvites.length === 0) return undefined;
152
+ if (!isOwnerGrant(credential)) throw new StrandsNeedOwnerJoinError(bundle, credential.partyId);
153
+ return { v: RECORD_VERSION, targetPartyId: credential.partyId, bundle, moved: [], refused: [] };
154
+ }
155
+
156
+ /**
157
+ * Adopt `record` on `node`, running in the record's target party, without the strands an earlier
158
+ * attempt had refused; then delete the record when nothing failed, or save this attempt's
159
+ * progress. The report lists every refusal so far. Throws what `adoptStrandBundle` throws. A
160
+ * failure updating the record is only logged: the record left behind is adopted again, without
161
+ * harm, by the next start.
162
+ */
163
+ export async function adoptMoveRecord(node: CadreNode, records: StrandMoveRecords, record: StrandMoveRecord): Promise<StrandMoveReport> {
164
+ const refused = new Set(record.refused.map((entry) => entry.strandId));
165
+ const strands = record.bundle.strands.filter((entry) => !refused.has(entry.row.Id));
166
+ const adopted = await adoptStrandBundle(node, { ...record.bundle, strands });
167
+ const report: StrandMoveReport = { ...adopted, refused: [...record.refused, ...adopted.refused] };
168
+ await keepWhatIsLeft(records, record, report);
169
+ return report;
170
+ }
171
+
172
+ async function keepWhatIsLeft(records: StrandMoveRecords, record: StrandMoveRecord, report: StrandMoveReport): Promise<void> {
173
+ const fromPartyId = record.bundle.partyId;
174
+ try {
175
+ if (report.failed.length === 0) {
176
+ await records.remove(fromPartyId);
177
+ } else {
178
+ const moved = [...new Set([...record.moved, ...report.moved])];
179
+ await records.save({ ...record, moved, refused: report.refused });
180
+ }
181
+ } catch (err) {
182
+ console.warn(`[cadre-core/edge-node] could not update the strand move record of party ${fromPartyId}; the next start adopts it again:`, err);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Adopt every record naming the node's party, by left party. A record the key store refuses to
188
+ * read throws; an adoption that throws is logged and reported as deferred, and its record kept.
189
+ */
190
+ export async function adoptMovesInto(node: CadreNode, records: StrandMoveRecords): Promise<Map<string, StrandMoveOutcome>> {
191
+ const outcomes = new Map<string, StrandMoveOutcome>();
192
+ for (const record of await records.naming(node.partyId)) {
193
+ outcomes.set(record.bundle.partyId, await adoptOrDefer(node, records, record));
194
+ }
195
+ return outcomes;
196
+ }
197
+
198
+ async function adoptOrDefer(node: CadreNode, records: StrandMoveRecords, record: StrandMoveRecord): Promise<StrandMoveOutcome> {
199
+ try {
200
+ const report = await adoptMoveRecord(node, records, record);
201
+ return { state: report.failed.length === 0 ? 'done' : 'partial', report };
202
+ } catch (err) {
203
+ console.warn(`[cadre-core/edge-node] moving the strands of party ${record.bundle.partyId} failed; the next start retries:`, err);
204
+ return deferredOutcome(err);
205
+ }
206
+ }
207
+
208
+ export function deferredOutcome(err: unknown): StrandMoveOutcome {
209
+ if (err instanceof StrandMoveNotOwnerError) return { state: 'deferred', reason: 'owner-key-not-wired' };
210
+ return { state: 'deferred', reason: 'adoption-failed', error: toError(err) };
211
+ }
212
+
213
+ /** A thrown value as an `Error`, for a status or outcome that carries one. */
214
+ export function toError(err: unknown): Error {
215
+ return err instanceof Error ? err : new Error(String(err));
216
+ }
217
+
218
+ export function pendingMove(record: StrandMoveRecord): PendingStrandMove {
219
+ const settled = new Set([...record.moved, ...record.refused.map((entry) => entry.strandId)]);
220
+ return {
221
+ fromPartyId: record.bundle.partyId,
222
+ toPartyId: record.targetPartyId,
223
+ strandIds: record.bundle.strands.map((entry) => entry.row.Id).filter((strandId) => !settled.has(strandId)),
224
+ refused: record.refused,
225
+ };
226
+ }
227
+
228
+ /** The record in `text`, or `undefined` (logged) when it is not one. */
229
+ function parseRecord(text: string): StrandMoveRecord | undefined {
230
+ let raw: unknown;
231
+ try {
232
+ raw = JSON.parse(text);
233
+ } catch (err) {
234
+ console.warn('[cadre-core/edge-node] a strand move record is not JSON:', err);
235
+ return undefined;
236
+ }
237
+ return isRecord(raw) ? raw : undefined;
238
+ }
239
+
240
+ /** The record's shape down to the bundle's lists; their entries are cadre-core's export, written by this module. */
241
+ function isRecord(value: unknown): value is StrandMoveRecord {
242
+ if (!isObject(value) || value.v !== RECORD_VERSION || typeof value.targetPartyId !== 'string') return false;
243
+ if (!Array.isArray(value.moved) || !Array.isArray(value.refused)) return false;
244
+ const bundle = value.bundle;
245
+ return isObject(bundle)
246
+ && bundle.v === 1
247
+ && typeof bundle.partyId === 'string'
248
+ && Array.isArray(bundle.strands)
249
+ && Array.isArray(bundle.pendingJoins)
250
+ && Array.isArray(bundle.formationInvites);
251
+ }
252
+
253
+ function isObject(value: unknown): value is Record<string, unknown> {
254
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
255
+ }
256
+
257
+ /** "2 strands and 1 invitation": the bundle's non-empty lists. */
258
+ function describeContent(bundle: StrandBundle): string {
259
+ const parts = [
260
+ count(bundle.strands.length, 'strand'),
261
+ count(bundle.pendingJoins.length, 'pending join'),
262
+ count(bundle.formationInvites.length, 'invitation'),
263
+ ].filter((part): part is string => part !== null);
264
+ return parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}` : parts.join('');
265
+ }
266
+
267
+ function count(n: number, noun: string): string | null {
268
+ if (n === 0) return null;
269
+ return `${n} ${noun}${n === 1 ? '' : 's'}`;
270
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Strand helpers every edge app needs around a running node, from sereus-chat's bring-up.
3
+ */
4
+
5
+ import { StrandAwaitingFirstSyncError } from '../strand-first-sync-gate.js';
6
+ import type { CadreNode } from '../cadre-node.js';
7
+ import type { StrandConfig, StrandInstance } from '../types.js';
8
+
9
+ /** Default patience for {@link attachStrandWhenWritable}: generous enough that running out means something is wrong. */
10
+ export const DEFAULT_STRAND_PATIENCE_MS = 240_000;
11
+
12
+ /**
13
+ * `addStrand`, then keep waiting when the first sync outlasts its budget.
14
+ *
15
+ * `addStrand` rejects with `StrandAwaitingFirstSyncError` when the first sync has not finished,
16
+ * and leaves the strand launched and syncing: the timeout is a progress report, not an outcome.
17
+ * Measured on a link with a 1.8 s round trip, a first sync completed at about 150 s against a
18
+ * 120 s budget. So this waits for the strand to become writable, and rejects only when it has not
19
+ * within `patienceMs`.
20
+ */
21
+ export async function attachStrandWhenWritable(
22
+ node: CadreNode,
23
+ config: StrandConfig,
24
+ options: { patienceMs?: number } = {},
25
+ ): Promise<StrandInstance> {
26
+ try {
27
+ return await node.addStrand(config);
28
+ } catch (err) {
29
+ if (!(err instanceof StrandAwaitingFirstSyncError)) throw err;
30
+ const patienceMs = options.patienceMs ?? DEFAULT_STRAND_PATIENCE_MS;
31
+ console.info(
32
+ `[cadre-core/edge-node] first sync of ${config.strandRow.Id} still arriving; ` +
33
+ `waiting up to ${patienceMs / 1000} s for it to become writable`,
34
+ );
35
+ return await node.whenStrandWritable(config.strandRow.Id, { timeoutMs: patienceMs });
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Optimystic's cluster coordinator reports a write that could not gather enough approvals this
41
+ * way. It is transient right after a node restarts alone: its cohort has not reconnected yet.
42
+ *
43
+ * NOTE: matched on the message because Optimystic throws a plain `Error` here. If it gains a typed
44
+ * error, match on that instead — a reworded message would silently stop the retries.
45
+ */
46
+ const SUPER_MAJORITY_FAILURE = /Failed to get super-majority/i;
47
+
48
+ /**
49
+ * Run a write, retrying only on the transient super-majority failure the first writes after a lone
50
+ * restart hit (v1.9 release notes): `attempts` tries, `delayMs` apart. Any other error, and the
51
+ * last attempt's, propagates.
52
+ */
53
+ export async function retryAfterRestart<T>(
54
+ write: () => Promise<T>,
55
+ options: { attempts?: number; delayMs?: number } = {},
56
+ ): Promise<T> {
57
+ const attempts = options.attempts ?? 5;
58
+ const delayMs = options.delayMs ?? 3_000;
59
+ for (let attempt = 1; ; attempt++) {
60
+ try {
61
+ return await write();
62
+ } catch (err) {
63
+ if (!isTransientWriteFailure(err) || attempt >= attempts) throw err;
64
+ console.info(`[cadre-core/edge-node] write attempt ${attempt} lacked a super-majority; retrying in ${delayMs} ms`);
65
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
66
+ }
67
+ }
68
+ }
69
+
70
+ function isTransientWriteFailure(err: unknown): boolean {
71
+ return SUPER_MAJORITY_FAILURE.test(err instanceof Error ? err.message : String(err));
72
+ }
@@ -172,9 +172,9 @@ export class PersistentEnrolledMachineStore implements EnrolledMachineStore {
172
172
  * Record the count: visible via {@link count} synchronously, then persisted.
173
173
  *
174
174
  * Writes are SKIPPED when the slot already holds this number, because the sole
175
- * caller runs on every committed membership write *and* every timed reconcile
176
- * pass — a party whose membership is stable would otherwise rewrite the same
177
- * integer forever. The skip is keyed off {@link persisted} rather than
175
+ * caller runs on every membership refresh — each committed membership write, each
176
+ * replicated membership change, and each reconcile pass down to its backstop — so a
177
+ * party whose membership is stable would otherwise rewrite the same integer forever. The skip is keyed off {@link persisted} rather than
178
178
  * {@link current}, so a write that FAILED is retried by the next refresh instead
179
179
  * of being suppressed as "unchanged".
180
180
  */
@@ -0,0 +1,63 @@
1
+ import type { FormationRejectionCode } from './strand-formation-rejection.js';
2
+
3
+ /** The `FormationInvite` columns a formation session acts on. */
4
+ export interface FormationInviteFacts {
5
+ sAppId: string;
6
+ /** Epoch ms of the stored `ExpiresAt`; null when the invitation never expires. */
7
+ expiresAtMs: number | null;
8
+ /** Null is unlimited. */
9
+ totalUses: number | null;
10
+ /** A bound invitation's host strand; null on the responder-provisions path. */
11
+ strandId: string | null;
12
+ /** The outside approval hook; null when no approval is required. */
13
+ validationUrl: string | null;
14
+ }
15
+
16
+ /**
17
+ * One formation token as read once at the start of a responder session. Every later step of
18
+ * the session takes this value instead of reading the invite again.
19
+ */
20
+ export interface FormationTokenRead {
21
+ token: string;
22
+ /** Null: no invite row here (it may not have replicated to this machine yet). */
23
+ invite: FormationInviteFacts | null;
24
+ /** Uses recorded so far; counted only for a metered, unexpired invite, else null. */
25
+ usesRecorded: number | null;
26
+ }
27
+
28
+ /**
29
+ * What the responder's token check yields; an invalid token names which way it failed. The
30
+ * valid arm carries the session's {@link FormationTokenRead}, absent only when the manager has
31
+ * no recorder and so accepts every token with nothing read.
32
+ */
33
+ export type FormationTokenCheck =
34
+ | { valid: true; read?: FormationTokenRead }
35
+ | { valid: false; code: Extract<FormationRejectionCode, 'token-unknown' | 'token-spent'> };
36
+
37
+ /**
38
+ * `expiresAtMs <= nowMs` is expired: the same boundary `hasOutstandingFormationInvite` applies,
39
+ * so an invite the formation handler refuses can never hold the connection gate open.
40
+ */
41
+ export function isInviteExpired(invite: FormationInviteFacts, nowMs: number): boolean {
42
+ return invite.expiresAtMs !== null && invite.expiresAtMs <= nowMs;
43
+ }
44
+
45
+ /** Used up: a metered invite whose recorded uses reached `totalUses`. An unknown token is not used up. */
46
+ export function isTokenReadSpent(read: FormationTokenRead): boolean {
47
+ const totalUses = read.invite?.totalUses ?? null;
48
+ return totalUses !== null && read.usesRecorded !== null && read.usesRecorded >= totalUses;
49
+ }
50
+
51
+ /**
52
+ * No invite row is `'token-unknown'` (retryable: the row may not have replicated to this
53
+ * machine yet); an expired or used-up invite is `'token-spent'` (final).
54
+ */
55
+ export function classifyTokenRead(read: FormationTokenRead, nowMs: number): FormationTokenCheck {
56
+ if (read.invite === null) {
57
+ return { valid: false, code: 'token-unknown' };
58
+ }
59
+ if (isInviteExpired(read.invite, nowMs) || isTokenReadSpent(read)) {
60
+ return { valid: false, code: 'token-spent' };
61
+ }
62
+ return { valid: true, read };
63
+ }