@serfab/cadre-core 1.15.0 → 1.17.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 (199) hide show
  1. package/README.md +1 -1
  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 +55 -138
  6. package/dist/cadre-invite-protocol.js +94 -449
  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 +530 -247
  12. package/dist/cadre-node.js +1046 -525
  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 +22 -23
  18. package/dist/control-cohort.js +24 -24
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +416 -136
  21. package/dist/control-database.js +757 -378
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-protocol-guard.d.ts +5 -4
  24. package/dist/control-protocol-guard.js +6 -3
  25. package/dist/control-protocol-guard.js.map +1 -1
  26. package/dist/control-schema-version.d.ts +80 -0
  27. package/dist/control-schema-version.js +203 -0
  28. package/dist/control-schema-version.js.map +1 -0
  29. package/dist/control-schema.d.ts +45 -5
  30. package/dist/control-schema.js +254 -70
  31. package/dist/control-schema.js.map +1 -1
  32. package/dist/control-write-retry.d.ts +5 -5
  33. package/dist/control-write-retry.js +10 -8
  34. package/dist/control-write-retry.js.map +1 -1
  35. package/dist/delegate-admission.d.ts +13 -0
  36. package/dist/delegate-admission.js +27 -0
  37. package/dist/delegate-admission.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +10 -7
  39. package/dist/ed25519-key.js +23 -20
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/edge-node/config.d.ts +122 -0
  42. package/dist/edge-node/config.js +199 -0
  43. package/dist/edge-node/config.js.map +1 -0
  44. package/dist/edge-node/dissolve.d.ts +95 -0
  45. package/dist/edge-node/dissolve.js +184 -0
  46. package/dist/edge-node/dissolve.js.map +1 -0
  47. package/dist/edge-node/edge-join.d.ts +44 -0
  48. package/dist/edge-node/edge-join.js +227 -0
  49. package/dist/edge-node/edge-join.js.map +1 -0
  50. package/dist/edge-node/edge-node.d.ts +295 -0
  51. package/dist/edge-node/edge-node.js +704 -0
  52. package/dist/edge-node/edge-node.js.map +1 -0
  53. package/dist/edge-node/index.d.ts +18 -0
  54. package/dist/edge-node/index.js +18 -0
  55. package/dist/edge-node/index.js.map +1 -0
  56. package/dist/edge-node/join-credential.d.ts +11 -0
  57. package/dist/edge-node/join-credential.js +11 -0
  58. package/dist/edge-node/join-credential.js.map +1 -0
  59. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  60. package/dist/edge-node/lazy-raw-storage.js +134 -0
  61. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  62. package/dist/edge-node/node-local.d.ts +60 -0
  63. package/dist/edge-node/node-local.js +58 -0
  64. package/dist/edge-node/node-local.js.map +1 -0
  65. package/dist/edge-node/options.d.ts +83 -0
  66. package/dist/edge-node/options.js +110 -0
  67. package/dist/edge-node/options.js.map +1 -0
  68. package/dist/edge-node/owner-grant.d.ts +41 -0
  69. package/dist/edge-node/owner-grant.js +38 -0
  70. package/dist/edge-node/owner-grant.js.map +1 -0
  71. package/dist/edge-node/platform.d.ts +140 -0
  72. package/dist/edge-node/platform.js +12 -0
  73. package/dist/edge-node/platform.js.map +1 -0
  74. package/dist/edge-node/strand-move.d.ts +115 -0
  75. package/dist/edge-node/strand-move.js +223 -0
  76. package/dist/edge-node/strand-move.js.map +1 -0
  77. package/dist/edge-node/strands.d.ts +28 -0
  78. package/dist/edge-node/strands.js +60 -0
  79. package/dist/edge-node/strands.js.map +1 -0
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +26 -7
  82. package/dist/index.js.map +1 -1
  83. package/dist/joined-strand-store.d.ts +3 -3
  84. package/dist/joined-strand-store.js +3 -3
  85. package/dist/joined-strand-store.js.map +1 -1
  86. package/dist/key-store.d.ts +9 -4
  87. package/dist/key-store.js +9 -4
  88. package/dist/key-store.js.map +1 -1
  89. package/dist/machine-removal.d.ts +19 -39
  90. package/dist/machine-removal.js +24 -98
  91. package/dist/machine-removal.js.map +1 -1
  92. package/dist/member-redemption.d.ts +250 -0
  93. package/dist/member-redemption.js +485 -0
  94. package/dist/member-redemption.js.map +1 -0
  95. package/dist/owner-anchor-sync.d.ts +4 -4
  96. package/dist/owner-anchor-sync.js +16 -15
  97. package/dist/owner-anchor-sync.js.map +1 -1
  98. package/dist/owner-grant-protocol.d.ts +247 -0
  99. package/dist/owner-grant-protocol.js +330 -0
  100. package/dist/owner-grant-protocol.js.map +1 -0
  101. package/dist/owner-key-export.d.ts +70 -0
  102. package/dist/owner-key-export.js +279 -0
  103. package/dist/owner-key-export.js.map +1 -0
  104. package/dist/owner-key-flows.d.ts +55 -0
  105. package/dist/owner-key-flows.js +60 -0
  106. package/dist/owner-key-flows.js.map +1 -0
  107. package/dist/owner-key-store.d.ts +31 -0
  108. package/dist/owner-key-store.js +81 -0
  109. package/dist/owner-key-store.js.map +1 -0
  110. package/dist/owner-keys.d.ts +78 -0
  111. package/dist/owner-keys.js +133 -0
  112. package/dist/owner-keys.js.map +1 -0
  113. package/dist/owner-signer.d.ts +54 -0
  114. package/dist/owner-signer.js +51 -0
  115. package/dist/owner-signer.js.map +1 -0
  116. package/dist/peer-authorization.d.ts +83 -18
  117. package/dist/peer-authorization.js +123 -53
  118. package/dist/peer-authorization.js.map +1 -1
  119. package/dist/provider/rent-node.js +1 -1
  120. package/dist/provider/rent-node.js.map +1 -1
  121. package/dist/seed-bootstrap.d.ts +131 -109
  122. package/dist/seed-bootstrap.js +260 -248
  123. package/dist/seed-bootstrap.js.map +1 -1
  124. package/dist/seed-trust-policy.js +4 -4
  125. package/dist/strand-addr-protocol.d.ts +4 -1
  126. package/dist/strand-addr-protocol.js +8 -2
  127. package/dist/strand-addr-protocol.js.map +1 -1
  128. package/dist/strand-cohort.d.ts +3 -0
  129. package/dist/strand-cohort.js.map +1 -1
  130. package/dist/strand-database.d.ts +1 -0
  131. package/dist/strand-database.js +3 -0
  132. package/dist/strand-database.js.map +1 -1
  133. package/dist/strand-first-sync-gate.d.ts +4 -3
  134. package/dist/strand-first-sync-gate.js +4 -3
  135. package/dist/strand-first-sync-gate.js.map +1 -1
  136. package/dist/strand-instance-manager.d.ts +1 -1
  137. package/dist/strand-membership-writer.d.ts +13 -2
  138. package/dist/strand-membership-writer.js +52 -16
  139. package/dist/strand-membership-writer.js.map +1 -1
  140. package/dist/strand-move.d.ts +6 -5
  141. package/dist/strand-move.js +31 -30
  142. package/dist/strand-move.js.map +1 -1
  143. package/dist/strand-peer-redial.js +2 -3
  144. package/dist/strand-peer-redial.js.map +1 -1
  145. package/dist/types.d.ts +286 -103
  146. package/dist/types.js.map +1 -1
  147. package/package.json +16 -7
  148. package/src/bootstrap-peer-store-file.ts +44 -44
  149. package/src/bootstrap-peer-store.ts +214 -214
  150. package/src/cadre-invite-protocol.ts +131 -531
  151. package/src/cadre-join-request.ts +188 -167
  152. package/src/cadre-node.ts +1166 -541
  153. package/src/control-authorization.ts +47 -14
  154. package/src/control-cohort.ts +127 -128
  155. package/src/control-database.ts +940 -456
  156. package/src/control-protocol-guard.ts +7 -4
  157. package/src/control-schema-version.ts +251 -0
  158. package/src/control-schema.ts +265 -70
  159. package/src/control-write-retry.ts +569 -567
  160. package/src/delegate-admission.ts +37 -0
  161. package/src/ed25519-key.ts +174 -171
  162. package/src/edge-node/config.ts +277 -0
  163. package/src/edge-node/dissolve.ts +251 -0
  164. package/src/edge-node/edge-join.ts +319 -0
  165. package/src/edge-node/edge-node.ts +1045 -0
  166. package/src/edge-node/index.ts +59 -0
  167. package/src/edge-node/join-credential.ts +16 -0
  168. package/src/edge-node/lazy-raw-storage.ts +178 -0
  169. package/src/edge-node/node-local.ts +84 -0
  170. package/src/edge-node/options.ts +179 -0
  171. package/src/edge-node/owner-grant.ts +63 -0
  172. package/src/edge-node/platform.ts +151 -0
  173. package/src/edge-node/strand-move.ts +270 -0
  174. package/src/edge-node/strands.ts +72 -0
  175. package/src/index.ts +102 -5
  176. package/src/joined-strand-store.ts +4 -4
  177. package/src/key-store.ts +10 -4
  178. package/src/machine-removal.ts +26 -111
  179. package/src/member-redemption.ts +642 -0
  180. package/src/owner-anchor-sync.ts +19 -18
  181. package/src/owner-grant-protocol.ts +532 -0
  182. package/src/owner-key-export.ts +348 -0
  183. package/src/owner-key-flows.ts +109 -0
  184. package/src/owner-key-store.ts +85 -0
  185. package/src/owner-keys.ts +178 -0
  186. package/src/owner-signer.ts +94 -0
  187. package/src/peer-authorization.ts +558 -488
  188. package/src/provider/rent-node.ts +1 -1
  189. package/src/seed-bootstrap.ts +301 -312
  190. package/src/seed-trust-policy.ts +4 -4
  191. package/src/strand-addr-protocol.ts +547 -538
  192. package/src/strand-cohort.ts +4 -0
  193. package/src/strand-database.ts +3 -0
  194. package/src/strand-first-sync-gate.ts +4 -3
  195. package/src/strand-instance-manager.ts +1 -1
  196. package/src/strand-membership-writer.ts +61 -16
  197. package/src/strand-move.ts +45 -36
  198. package/src/strand-peer-redial.ts +2 -3
  199. package/src/types.ts +304 -103
@@ -0,0 +1,1045 @@
1
+ /**
2
+ * `createEdgeNode` — build, start, stop and rebuild an edge device's `CadreNode` the prescribed
3
+ * way: identity and owner key in the app's key store, the four node-local stores durable and
4
+ * party-scoped, raw storage per scope from the app's storage, owner genesis, the last start
5
+ * remembered for unattended starts, and the switch to another party's cadre by machine
6
+ * invitation or owner grant.
7
+ *
8
+ * Nothing platform-specific is imported here. The app passes in its key store, its storage and
9
+ * its transports (`EdgeNodePlatform`), so a Node test runs this code over fakes, and the React
10
+ * Native kit's `createPhoneNode` is this over the secure store and rn-leveldb.
11
+ */
12
+
13
+ import { CadreNode } from '../cadre-node.js';
14
+ import { DEFAULT_IDENTITY_KEY_ID, type KeyStore } from '../key-store.js';
15
+ import { loadOrCreateIdentityKey } from '../identity-key.js';
16
+ import { exportOwnerKey, importOwnerKeyInto } from '../owner-key-flows.js';
17
+ import type { ExportedOwnerKey, ImportedOwnerKey, OwnerKeyExportOptions } from '../owner-key-flows.js';
18
+ import { createOwnerKey, loadOwnerSigner } from '../owner-key-store.js';
19
+ import type { OwnerSigner } from '../owner-signer.js';
20
+ import { PersistentBootstrapPeerStore } from '../bootstrap-peer-store.js';
21
+ import { PersistentEnrolledMachineStore } from '../enrolled-machine-store.js';
22
+ import { PersistentStrandNetworkStateStore } from '../strand-network-state.js';
23
+ import { PersistentTrustedOwnerStore } from '../trusted-owner-store.js';
24
+ import { readStrandHoldings, type StrandMoveReport } from '../strand-move.js';
25
+ import type { DurableSlot } from '../node-local-snapshot.js';
26
+ import type {
27
+ CadreJoinProgress,
28
+ CadreNodeConfig,
29
+ CadreNodeEvents,
30
+ CadreOwnerKey,
31
+ CadreRemoval,
32
+ CreateOwnerGrantOptions,
33
+ CreateOwnerGrantResult,
34
+ OwnerGrantStatus,
35
+ OwnerKeyRemovalPlan,
36
+ OwnerKeyRemovalResult,
37
+ RedeemCadreInvitationResult,
38
+ RemoveOwnerOptions,
39
+ } from '../types.js';
40
+ import type { Libp2pTransports, NoiseCryptoInterface } from '@optimystic/db-p2p';
41
+ import { circuitRelayTransport } from '@libp2p/circuit-relay-v2';
42
+ import { webSockets } from '@libp2p/websockets';
43
+ import { buildEdgeNodeConfig, requireOwnerKeyLabel, runOwnerGenesis, wireJoinedOwnerKey, type OwnerGenesisResult } from './config.js';
44
+ import {
45
+ finishMarkedRemoval,
46
+ finishRemovalAtStart,
47
+ formerPartyRead,
48
+ formerPartyRefusal,
49
+ readFormerParty,
50
+ removeParty,
51
+ StrandMovePendingError,
52
+ withoutFormerParty,
53
+ type DissolveFormerPartyOptions,
54
+ type PartyStorage,
55
+ type RemovalAtStart,
56
+ } from './dissolve.js';
57
+ import { joinCadre, type JoinRuntime } from './edge-join.js';
58
+ import type { JoinCredential } from './join-credential.js';
59
+ import {
60
+ bootstrapPeersKvKey,
61
+ enrolledMachinesKvKey,
62
+ kvStoreSlot,
63
+ strandNetworkKvKey,
64
+ trustedOwnersKvKey,
65
+ type KvStoreApi,
66
+ type NodeLocalKv,
67
+ } from './node-local.js';
68
+ import { parseSavedStart, serializeSavedStart, startAfterRemoval, type EdgeNodeOptions, type NoiseCryptoMode, type SavedStart } from './options.js';
69
+ import { redeemOwnerGrantText, type OwnerGrantRuntime, type RedeemedOwnerGrant } from './owner-grant.js';
70
+ import { DEFAULT_EDGE_NODE_NAMES, type EdgeNodeNames, type EdgeNodePlatform, type RemovableSlot } from './platform.js';
71
+ import { adoptMoveRecord, adoptMovesInto, pendingMove, StrandMoveRecords, toError, type PendingStrandMove, type StrandMoveOutcome } from './strand-move.js';
72
+
73
+ export type EdgeNodeStatus =
74
+ | { state: 'stopped' }
75
+ | { state: 'starting' }
76
+ /** Started in the party an invitation names and redeeming it there; not admitted yet, so `EdgeNode.node` is null. */
77
+ | { state: 'joining'; node: CadreNode }
78
+ | { state: 'running'; node: CadreNode; owner: OwnerGenesisResult }
79
+ /**
80
+ * An owner of `partyId` removed this app from it: the node is stopped and the party's local
81
+ * storage deleted. `formerPartyId` is the party this app founded and left, which the saved start
82
+ * now names with `autoStart: false`; absent when there is none. `start` and `joinCadre` work as
83
+ * from `stopped`.
84
+ */
85
+ | { state: 'removed'; partyId: string; removal: CadreRemoval; formerPartyId?: string }
86
+ | { state: 'failed'; error: Error };
87
+
88
+ /** A join's progress: the redemption's passes and pauses, then the steps after a member admitted the app. */
89
+ export type EdgeJoinProgress =
90
+ | CadreJoinProgress
91
+ /**
92
+ * A member admitted the app; the edge node is wiring its owner key and saving the start.
93
+ * `asOwner`: the app joined with an owner grant, so its owner slot's key is an owner of the party.
94
+ */
95
+ | { state: 'admitted'; asOwner: boolean }
96
+ /** The app's key is wired as an owner and the strands this join recorded are being adopted. */
97
+ | { state: 'moving-strands' };
98
+
99
+ /** How {@link EdgeNode.joinCadre} runs. */
100
+ export interface JoinCadreOptions {
101
+ /** Relays the joined node reserves on. Default: the running start's, else the saved one's. */
102
+ relayAddrs?: string[];
103
+ /** Default: the running start's, else the saved one's, else the platform default. */
104
+ noiseCryptoMode?: NoiseCryptoMode;
105
+ /** Cancels the join: the app goes back to the party it left, and the call throws the signal's reason. */
106
+ signal?: AbortSignal;
107
+ /**
108
+ * Each redemption pass and each pause while no member is ready, for a "waiting for an always-on
109
+ * machine" display; then `admitted`, and `moving-strands` when this join's strands are adopted.
110
+ * A listener that throws is logged and ignored.
111
+ */
112
+ onProgress?: (progress: EdgeJoinProgress) => void;
113
+ }
114
+
115
+ /** What an admitted {@link EdgeNode.joinCadre} returns. */
116
+ export interface JoinCadreResult extends RedeemCadreInvitationResult {
117
+ /** The app joined with an owner grant: its owner slot's key is an owner of the party (`status.owner` says whether it is wired). */
118
+ asOwner: boolean;
119
+ /** The party this app founded and has left, when it founded one; saved as `EdgeNodeOptions.formerPartyId`. */
120
+ formerPartyId?: string;
121
+ /** How moving that party's strands into this one settled; absent when there was nothing to move. */
122
+ strandMove?: StrandMoveOutcome;
123
+ }
124
+
125
+ /** A removal a dissolve marker names that the last start tried to finish and could not: {@link EdgeNode.unfinishedRemoval}. */
126
+ export interface UnfinishedRemoval {
127
+ partyId: string;
128
+ error: Error;
129
+ }
130
+
131
+ export interface EdgeNode {
132
+ readonly status: EdgeNodeStatus;
133
+ /** The running node, or null; null as soon as a stop or restart begins tearing it down, and while joining. */
134
+ readonly node: CadreNode | null;
135
+ /** The options the running node was started with, the Noise mode resolved; null unless running. */
136
+ readonly options: EdgeNodeOptions | null;
137
+ /**
138
+ * The removal the dissolve marker names that the last start tried to finish and could not
139
+ * (logged there too), for an app to show; null when the last start found no marker or finished
140
+ * it, or a dissolve or removal since finished it. The next start retries.
141
+ */
142
+ readonly unfinishedRemoval: UnfinishedRemoval | null;
143
+ /**
144
+ * This app's peer id, the same in every party: the node's while one is running or joining,
145
+ * answered at once; otherwise read from the key store, created there when absent, in turn
146
+ * with start, stop and join so a first start cannot generate a different key. A read the
147
+ * key store refuses throws.
148
+ */
149
+ identityPeerId(): Promise<string>;
150
+ /**
151
+ * Start, or join the start in flight; a running node is returned as is, whatever its options.
152
+ * A start called during a stop or restart runs after it. Opens the four node-local stores
153
+ * party-scoped, builds and starts the node, runs owner genesis (fail-soft, bounded), then saves
154
+ * the options with `autoStart: true`. A failed start closes everything it opened and leaves
155
+ * `status` `failed`; the next call starts afresh.
156
+ */
157
+ start(options: EdgeNodeOptions): Promise<CadreNode>;
158
+ /**
159
+ * The user's "disconnect": waits for a start in flight, saves `autoStart: false` so nothing
160
+ * starts the node unattended, stops it, and closes every store it opened — control, each
161
+ * strand, node-local — through `EdgeNodeStorage.close`. A platform that locks a store per
162
+ * name (rn-leveldb) would otherwise fail the next start on one left open.
163
+ */
164
+ stop(): Promise<void>;
165
+ /**
166
+ * Rebuild the node with new options (relays, Noise mode): libp2p reads them only when the node
167
+ * is built. `autoStart` is not cleared on the way, so a crash mid-restart still starts again.
168
+ */
169
+ restart(options: EdgeNodeOptions): Promise<CadreNode>;
170
+ /**
171
+ * Join the cadre `credential` names: this app's one node moves to that party, keeping its
172
+ * identity, so the peer id an owner targeted is the one that redeems. A machine invitation
173
+ * admits the app as a machine; an owner grant makes it a machine and an owner at once
174
+ * (`CadreNode.joinWithOwnerGrant`), seating the owner slot's key, created when the slot is
175
+ * empty, which is how an app brings the strands of the party it founded along. Runs in turn
176
+ * with start, stop and restart; a start called meanwhile runs after it, and a `stop` cancels it.
177
+ *
178
+ * Already running in that party: redeems on the running node (a device already admitted is
179
+ * answered as such; an owner grant is redeemed for the slot's key, `CadreNode.redeemOwnerGrant`)
180
+ * and wires its owner key. Otherwise: stops the running node, starts one for the credential's
181
+ * party with `origin: 'joined'` (status `joining`), and redeems there, waiting for a member to
182
+ * answer until one accepts, one refuses, the credential expires (`invite-spent`,
183
+ * `grant-spent`) or `options.signal` aborts. Admitted: wires the owner key when the party
184
+ * lists the slot's key (after an owner grant, asking again until its row arrives or the
185
+ * owner-step bound passes), saves the start (`autoStart: true`) and reports `running`.
186
+ * Anything else: stops that node, starts again what was running before (a node that was
187
+ * stopped stays stopped), and throws the join's error; a failure restarting is logged and
188
+ * leaves status `failed`.
189
+ *
190
+ * The start is saved only once admitted, so an app killed while waiting relaunches into the
191
+ * party it left. The left party's data stays on disk under its own names.
192
+ *
193
+ * Leaving a party this app founded moves its strands: they are exported before anything stops
194
+ * (from a node started for the export alone when none runs) and kept in the key store, and
195
+ * adopted once the app's key is wired as an owner of the joined party, before the join reports
196
+ * `running`; otherwise a later start adopts them (`result.strandMove` says which). Refused
197
+ * before anything stops, with `StrandsNeedOwnerJoinError`, when there is something to move and
198
+ * the credential is a machine invitation; and with the export's error when it fails.
199
+ */
200
+ joinCadre(credential: JoinCredential, options?: JoinCadreOptions): Promise<JoinCadreResult>;
201
+ /**
202
+ * The move from `formerPartyId` (the running start's, else the saved one's) still waiting to be
203
+ * adopted, or `undefined` when there is none. A read the key store refuses throws.
204
+ */
205
+ pendingStrandMove(): Promise<PendingStrandMove | undefined>;
206
+ /**
207
+ * Adopt the waiting move on the running node now, in turn with start, stop and join; `undefined`
208
+ * when no move from `formerPartyId` names the running party. On a joined party with no owner
209
+ * signer wired, first asks the party once more whether it lists the owner slot's key, so an
210
+ * owner grant whose row arrived after the join stopped waiting is picked up here,
211
+ * `status.owner` becoming `enrolled`. Throws what the adoption throws,
212
+ * `StrandMoveNotOwnerError` while the app's key is no owner there.
213
+ */
214
+ finishStrandMove(): Promise<StrandMoveReport | undefined>;
215
+ /**
216
+ * Delete the local storage of `formerPartyId`, the party this app founded and left, once the
217
+ * running party holds everything it holds and no other machine is in it. Runs in turn with
218
+ * start, stop, restart and join. Stops the running node, reads the former party from a node
219
+ * started for that alone, and starts the running party again, without `formerPartyId` once the
220
+ * former party is removed; a failure of that last start is the call's error.
221
+ *
222
+ * Refused before anything stops: with an `Error` unless running with a `formerPartyId` other
223
+ * than the running party, and with `StrandMovePendingError` while a move from it waits. Refused
224
+ * after the read, the running party started again and nothing deleted: with
225
+ * `FormerPartyNotEmptyError`, whose strand and pending-join ids `options.discard` may give up.
226
+ */
227
+ dissolveFormerParty(options?: DissolveFormerPartyOptions): Promise<void>;
228
+ /**
229
+ * The last start, or `undefined` when none is saved or the record is unusable. A read fault
230
+ * throws. Called during a stop, it answers after the stop has saved `autoStart: false`; during
231
+ * the edge node's response to a removal, after that has replaced the saved start.
232
+ */
233
+ loadSavedStart(): Promise<SavedStart | undefined>;
234
+ /**
235
+ * The slot `LinkRequestStore` (`@serfab/cadre-rn/app-link`) keeps the open link join request in,
236
+ * beside the saved start in the node-local store. Works with no node running, as
237
+ * `loadSavedStart` does; a read fault throws.
238
+ */
239
+ linkRequestSlot(): DurableSlot;
240
+ /**
241
+ * The slot `AppRecommendationStore` (`@serfab/cadre-rn/app-link`) keeps the apps the user allowed
242
+ * to join before they asked in, beside the saved start. Works with no node running, as
243
+ * `linkRequestSlot` does; a read fault throws.
244
+ */
245
+ recommendationSlot(): DurableSlot;
246
+ /**
247
+ * The node-local store as a key-value store, for records the app keeps beside the saved start
248
+ * under keys of its own: `rentalStore` (`@serfab/cadre-rn/provider`) files the provider rentals
249
+ * here, under `rentalsKvKey(partyId)`. Works with no node running, as `linkRequestSlot` does; a
250
+ * read fault throws. Keys of the form `<record>.<partyId>` are the edge node's; an app's own
251
+ * records take a dot-free key.
252
+ */
253
+ nodeLocalRecords(): KvStoreApi;
254
+ /**
255
+ * Subscribe to a node event for the life of this edge node, not of one `CadreNode`: the
256
+ * handler is re-applied to every node a start or restart builds, before it starts. Register the
257
+ * `strand:discovered` handler that attaches strands here, before `start` — strands joined from
258
+ * another party come back through it after every start. Returns an unsubscribe.
259
+ */
260
+ on<K extends keyof CadreNodeEvents>(event: K, handler: (data: CadreNodeEvents[K]) => void): () => void;
261
+ onStatus(listener: (status: EdgeNodeStatus) => void): () => void;
262
+ /**
263
+ * The public key (base64url) of the owner key the running node signs with, for pairing and
264
+ * approval prompts: the owner slot's key. Null unless running with it wired. Never private
265
+ * material.
266
+ */
267
+ ownerPublicKey(): string | null;
268
+ /**
269
+ * The cadre's live owner keys (cadre-core's `CadreNode.listOwnerKeys`): label, voucher, whether
270
+ * this app holds it and whether this device trusts it. In turn with start, stop and join, as are
271
+ * the calls below; this one and every other that names the node throws while none is running.
272
+ */
273
+ listOwnerKeys(): Promise<CadreOwnerKey[]>;
274
+ /**
275
+ * This app's own owner key, from the key store's owner slot, which is the key the node signs
276
+ * with, as export text, with the key it holds (`publicKey`). Works with no node running. Throws
277
+ * when the slot is empty: a member holds no owner key of its own.
278
+ */
279
+ exportOwnerKey(options?: OwnerKeyExportOptions): Promise<ExportedOwnerKey>;
280
+ /**
281
+ * Import owner-key text into the owner slot, unlocking it with `passphrase` when it is locked.
282
+ * With a node running, the key is wired when the party lists it as a live owner (`wired`, and
283
+ * `status.owner` becomes `enrolled`); stored otherwise, and wired by a later start once an owner
284
+ * adds it. Works with no node running. Refused with `OwnerKeySlotOccupiedError` when the slot
285
+ * holds a different key (one owner key per app), and with `OwnerKeyExportError` for text that
286
+ * is not an owner key, a locked one with no passphrase (`passphrase-required`) or the wrong
287
+ * passphrase. A strand move waiting on an owner key is adopted by the next start or
288
+ * `finishStrandMove()`.
289
+ */
290
+ importOwnerKey(text: string, passphrase?: string): Promise<ImportedOwnerKey>;
291
+ /** What `removeOwner(key)` would do, for the confirmation step (cadre-core's `CadreNode.planOwnerKeyRemoval`). */
292
+ planOwnerKeyRemoval(key: string): Promise<OwnerKeyRemovalPlan>;
293
+ /** Remove an owner key, re-vouching first the dependents `options.revouch` keeps (cadre-core's `CadreNode.removeOwner`). */
294
+ removeOwner(key: string, options?: RemoveOwnerOptions): Promise<OwnerKeyRemovalResult>;
295
+ /**
296
+ * Mint an owner grant (cadre-core's `CadreNode.createOwnerGrant`): whoever redeems the
297
+ * returned text first becomes an owner of the cadre. 15 minutes and one use by default;
298
+ * `expiresInMs: null` is a backup grant, with no expiry.
299
+ */
300
+ createOwnerGrant(options?: CreateOwnerGrantOptions): Promise<CreateOwnerGrantResult>;
301
+ /** Every owner grant the running node holds, with its standing (cadre-core's `CadreNode.listOwnerGrants`). */
302
+ listOwnerGrants(): Promise<OwnerGrantStatus[]>;
303
+ /** Withdraw an owner grant by its key (cadre-core's `CadreNode.withdrawOwnerGrant`); false when it is not held here or was already withdrawn. */
304
+ withdrawOwnerGrant(key: string): Promise<boolean>;
305
+ /**
306
+ * Become an owner of the running party with owner-grant text: the owner slot's key, created
307
+ * when the slot is empty, is seated (cadre-core's `CadreNode.redeemOwnerGrant`) and wired, and
308
+ * `status.owner` becomes `enrolled`. A slot key that is already a live owner of the party is
309
+ * answered `alreadyOwner` before anything is signed or written, connected or not. A member
310
+ * machine writes the row in its own store and wires the key at once; any other node redeems
311
+ * at the grant's members and asks for the row until the owner-step bound passes (`wired:
312
+ * false` then, and a later start wires it). A strand move waiting on an owner key is adopted
313
+ * by the next start or `finishStrandMove()`.
314
+ *
315
+ * Refused with the decoder's error for text that is not an owner grant (an owner key, an
316
+ * invitation and a join request are named), with `OwnerGrantRejectedError` `party-mismatch`
317
+ * for a grant of another party, and with what `CadreNode.redeemOwnerGrant` throws:
318
+ * `OwnerKeyAddRefusedError` `alone` on a member with no connection to the party's other
319
+ * machines, `OwnerGrantRejectedError` (`grant-spent` for an expired, withdrawn or used grant),
320
+ * `OwnerGrantIssuerUnknownError`, `OwnerGrantUnreachableError`.
321
+ */
322
+ redeemOwnerGrant(text: string): Promise<RedeemedOwnerGrant>;
323
+ }
324
+
325
+ /** One handler, closed over its event, so re-applying it to a new node needs no cast. */
326
+ interface Subscription {
327
+ attach(node: CadreNode): void;
328
+ detach(node: CadreNode): void;
329
+ }
330
+
331
+ /** Edge nodes by storage id: two over the same storage would share databases and an identity. */
332
+ const edgeNodes = new Map<string, EdgeNode>();
333
+
334
+ /**
335
+ * Create the app's edge node. Call once per app. Throws when an edge node over the same storage
336
+ * (`platform.storage.id`) is starting or running in this process; a stopped or failed one is
337
+ * replaced (a development reload re-runs the module that calls this).
338
+ */
339
+ export function createEdgeNode(platform: EdgeNodePlatform): EdgeNode {
340
+ const id = platform.storage.id;
341
+ const existing = edgeNodes.get(id);
342
+ if (existing && ['starting', 'joining', 'running'].includes(existing.status.state)) {
343
+ throw new Error(`an edge node over storage '${id}' is already ${existing.status.state}; create one per app and share it`);
344
+ }
345
+ const created = new EdgeNodeRuntime(platform, { ...DEFAULT_EDGE_NODE_NAMES, ...platform.names });
346
+ edgeNodes.set(id, created);
347
+ return created;
348
+ }
349
+
350
+ class EdgeNodeRuntime implements EdgeNode, JoinRuntime, OwnerGrantRuntime {
351
+ private readonly keyStore: KeyStore;
352
+ readonly moves: StrandMoveRecords;
353
+ private current: CadreNode | null = null;
354
+ runningOptions: EdgeNodeOptions | null = null;
355
+ private starting: Promise<CadreNode> | null = null;
356
+ private stopping: Promise<void> | null = null;
357
+ /** One per join called and not yet settled, so a stop can cancel them. */
358
+ private readonly joins = new Set<AbortController>();
359
+ /**
360
+ * Start, stop, restart, join and dissolve run one at a time, in call order: each opens or closes
361
+ * the stores the others use, so a start overlapping a stop would build on handles the stop then closes.
362
+ */
363
+ private transitions: Promise<unknown> = Promise.resolve();
364
+ private currentStatus: EdgeNodeStatus = { state: 'stopped' };
365
+ private lastUnfinishedRemoval: UnfinishedRemoval | null = null;
366
+ private readonly subscriptions = new Set<Subscription>();
367
+ private readonly statusListeners = new Set<(status: EdgeNodeStatus) => void>();
368
+
369
+ constructor(
370
+ private readonly platform: EdgeNodePlatform,
371
+ private readonly names: EdgeNodeNames,
372
+ ) {
373
+ this.keyStore = platform.keyStore;
374
+ this.moves = new StrandMoveRecords(this.keyStore);
375
+ }
376
+
377
+ get status(): EdgeNodeStatus {
378
+ return this.currentStatus;
379
+ }
380
+
381
+ get node(): CadreNode | null {
382
+ return this.currentStatus.state === 'running' ? this.current : null;
383
+ }
384
+
385
+ get options(): EdgeNodeOptions | null {
386
+ return this.currentStatus.state === 'running' ? this.runningOptions : null;
387
+ }
388
+
389
+ get unfinishedRemoval(): UnfinishedRemoval | null {
390
+ return this.lastUnfinishedRemoval;
391
+ }
392
+
393
+ identityPeerId(): Promise<string> {
394
+ const status = this.currentStatus;
395
+ // A join may wait minutes for a member; the screen showing the join request must not wait with it.
396
+ const peerId = status.state === 'running' || status.state === 'joining' ? status.node.peerId : undefined;
397
+ if (peerId) return Promise.resolve(peerId.toString());
398
+ return this.inTurn(async () => {
399
+ const key = await loadOrCreateIdentityKey(this.keyStore, DEFAULT_IDENTITY_KEY_ID);
400
+ // A public key's string form is its peer id's.
401
+ return key.publicKey.toString();
402
+ });
403
+ }
404
+
405
+ start(options: EdgeNodeOptions): Promise<CadreNode> {
406
+ if (this.starting) return this.starting;
407
+ return this.trackStart(() => (this.current?.isRunning ? Promise.resolve(this.current) : this.buildAndStart(options)));
408
+ }
409
+
410
+ stop(): Promise<void> {
411
+ // A join can wait for a member until its invitation expires; the user's disconnect ends it.
412
+ // NOTE: the cancelled join starts the party it left again, and only then does this stop
413
+ // stop it. If that restart makes Disconnect noticeably slow, let a join cancelled by a stop
414
+ // save the left start with `autoStart: false` and stay stopped instead.
415
+ for (const join of this.joins) join.abort(new Error('the edge node was stopped during a join'));
416
+ return this.trackStop(() => this.stopAndClearAutoStart());
417
+ }
418
+
419
+ restart(options: EdgeNodeOptions): Promise<CadreNode> {
420
+ this.starting = null;
421
+ return this.trackStart(async () => {
422
+ await this.shutDown();
423
+ return this.buildAndStart(options);
424
+ });
425
+ }
426
+
427
+ joinCadre(credential: JoinCredential, options: JoinCadreOptions = {}): Promise<JoinCadreResult> {
428
+ // A start called from here on runs after this join rather than joining a start it replaces.
429
+ this.starting = null;
430
+ const cancel = new AbortController();
431
+ const release = relayAbort(options.signal, cancel);
432
+ this.joins.add(cancel);
433
+ const joining = this.inTurn(() => joinCadre(this, credential, options, cancel.signal));
434
+ const settled = (): void => {
435
+ this.joins.delete(cancel);
436
+ release();
437
+ };
438
+ joining.then(settled, settled);
439
+ return joining;
440
+ }
441
+
442
+ async loadSavedStart(): Promise<SavedStart | undefined> {
443
+ // A stop in progress decides `autoStart`: answer after it, so an unattended start does
444
+ // not act on the value the stop is replacing. Its failure is its caller's.
445
+ await this.stopping?.catch(() => undefined);
446
+ return this.readSavedStart();
447
+ }
448
+
449
+ linkRequestSlot(): DurableSlot {
450
+ return kvStoreSlot(this.nodeLocalRecords(), this.names.linkRequestKey);
451
+ }
452
+
453
+ recommendationSlot(): DurableSlot {
454
+ return kvStoreSlot(this.nodeLocalRecords(), this.names.recommendationsKey);
455
+ }
456
+
457
+ /** The node-local store for a record the app keeps across stops and starts. */
458
+ nodeLocalRecords(): KvStoreApi {
459
+ // Each get and set asks the platform for the store again: a stop closes it, and the record outlives the stop.
460
+ const kv = async (): Promise<NodeLocalKv> => {
461
+ // Opening it while a stop is closing it would take a second lock on the same name.
462
+ // NOTE: a restart, join or dissolve closes it too, with no `stopping` to wait on, so a
463
+ // read in that short window can fail on the lock. If that is ever seen, track each shutdown as `stopping` is tracked.
464
+ await this.stopping?.catch(() => undefined);
465
+ return this.nodeLocalKv();
466
+ };
467
+ return {
468
+ get: async (key) => (await kv()).get(key),
469
+ set: async (key, value) => (await kv()).set(key, value),
470
+ flush: async () => (await kv()).flush(),
471
+ };
472
+ }
473
+
474
+ async pendingStrandMove(): Promise<PendingStrandMove | undefined> {
475
+ const formerPartyId = (this.runningOptions ?? (await this.readSavedStart())?.options)?.formerPartyId;
476
+ if (formerPartyId === undefined) return undefined;
477
+ const record = await this.moves.load(formerPartyId);
478
+ return record && pendingMove(record);
479
+ }
480
+
481
+ finishStrandMove(): Promise<StrandMoveReport | undefined> {
482
+ return this.inTurn(async () => {
483
+ const node = this.node;
484
+ const running = this.runningOptions;
485
+ const formerPartyId = running?.formerPartyId;
486
+ if (!node?.isRunning || !running || formerPartyId === undefined) return undefined;
487
+ const record = await this.moves.load(formerPartyId);
488
+ if (record?.targetPartyId !== node.partyId) return undefined;
489
+ // A joined start that settled as a member stopped asking for the owner row after its
490
+ // bound; one that arrived since makes this adoption an owner's rather than a refusal.
491
+ // A founded party never re-asks here: genesis is its owner step, and a signer already
492
+ // wired is never replaced.
493
+ if (running.origin === 'joined' && node.getOwnerSigner() === null && (await this.wireJoinedOwner(node, false)) === 'enrolled') {
494
+ this.reportEnrolled(node);
495
+ }
496
+ const report = await adoptMoveRecord(node, this.moves, record);
497
+ // The adoption wires the key or throws, so a start that settled as a member is an owner now.
498
+ this.reportEnrolled(node);
499
+ return report;
500
+ });
501
+ }
502
+
503
+ dissolveFormerParty(options: DissolveFormerPartyOptions = {}): Promise<void> {
504
+ // A start called from here on runs after this dissolve rather than joining a start whose node it stops.
505
+ this.starting = null;
506
+ return this.inTurn(() => this.dissolve(options.discard ?? []));
507
+ }
508
+
509
+ on<K extends keyof CadreNodeEvents>(event: K, handler: (data: CadreNodeEvents[K]) => void): () => void {
510
+ const subscription: Subscription = {
511
+ attach: (node) => node.on(event, handler),
512
+ detach: (node) => node.off(event, handler),
513
+ };
514
+ this.subscriptions.add(subscription);
515
+ if (this.current) subscription.attach(this.current);
516
+ return () => {
517
+ this.subscriptions.delete(subscription);
518
+ if (this.current) subscription.detach(this.current);
519
+ };
520
+ }
521
+
522
+ onStatus(listener: (status: EdgeNodeStatus) => void): () => void {
523
+ this.statusListeners.add(listener);
524
+ return () => {
525
+ this.statusListeners.delete(listener);
526
+ };
527
+ }
528
+
529
+ ownerPublicKey(): string | null {
530
+ return this.node?.getOwnerSigner()?.publicKey ?? null;
531
+ }
532
+
533
+ // ── owner keys ───────────────────────────────────────────────────────────
534
+ // In turn with the transitions: genesis creates the owner slot's key inside a start, and the
535
+ // slot's check-then-set must not race an import.
536
+
537
+ listOwnerKeys(): Promise<CadreOwnerKey[]> {
538
+ return this.onRunningNode((node) => node.listOwnerKeys());
539
+ }
540
+
541
+ exportOwnerKey(options: OwnerKeyExportOptions = {}): Promise<ExportedOwnerKey> {
542
+ return this.inTurn(() => exportOwnerKey(this.keyStore, options));
543
+ }
544
+
545
+ importOwnerKey(text: string, passphrase?: string): Promise<ImportedOwnerKey> {
546
+ return this.inTurn(async () => {
547
+ const node = this.node?.isRunning ? this.node : null;
548
+ const imported = await importOwnerKeyInto(this.keyStore, node, text, passphrase);
549
+ if (imported.wired && node) this.reportEnrolled(node);
550
+ return imported;
551
+ });
552
+ }
553
+
554
+ planOwnerKeyRemoval(key: string): Promise<OwnerKeyRemovalPlan> {
555
+ return this.onRunningNode((node) => node.planOwnerKeyRemoval(key));
556
+ }
557
+
558
+ removeOwner(key: string, options: RemoveOwnerOptions = {}): Promise<OwnerKeyRemovalResult> {
559
+ return this.onRunningNode((node) => node.removeOwner(key, options));
560
+ }
561
+
562
+ createOwnerGrant(options: CreateOwnerGrantOptions = {}): Promise<CreateOwnerGrantResult> {
563
+ return this.onRunningNode((node) => node.createOwnerGrant(options));
564
+ }
565
+
566
+ listOwnerGrants(): Promise<OwnerGrantStatus[]> {
567
+ return this.onRunningNode((node) => node.listOwnerGrants());
568
+ }
569
+
570
+ withdrawOwnerGrant(key: string): Promise<boolean> {
571
+ return this.onRunningNode((node) => node.withdrawOwnerGrant(key));
572
+ }
573
+
574
+ redeemOwnerGrant(text: string): Promise<RedeemedOwnerGrant> {
575
+ return this.onRunningNode(async (node) => {
576
+ const redeemed = await redeemOwnerGrantText(this, node, text);
577
+ if (redeemed.wired) this.reportEnrolled(node);
578
+ return redeemed;
579
+ });
580
+ }
581
+
582
+ /** Run `op` on the running node, in turn with start, stop and join; throws when none is running. */
583
+ private onRunningNode<T>(op: (node: CadreNode) => Promise<T>): Promise<T> {
584
+ return this.inTurn(async () => {
585
+ const node = this.node;
586
+ if (!node?.isRunning) throw new Error('The edge node is not running; start it first');
587
+ return await op(node);
588
+ });
589
+ }
590
+
591
+ /** A key was wired after the start settled: a start reported as a member is an owner now. */
592
+ private reportEnrolled(node: CadreNode): void {
593
+ if (this.currentStatus.state === 'running' && this.currentStatus.owner !== 'enrolled') {
594
+ this.setStatus({ state: 'running', node, owner: 'enrolled' });
595
+ }
596
+ }
597
+
598
+ // ── transitions ──────────────────────────────────────────────────────────
599
+
600
+ /** Run `op` after every transition called before it, whether those succeeded or failed. */
601
+ private inTurn<T>(op: () => Promise<T>): Promise<T> {
602
+ const result = this.transitions.then(op);
603
+ this.transitions = result.catch(() => undefined);
604
+ return result;
605
+ }
606
+
607
+ /**
608
+ * Queue a transition that rewrites the saved start as it takes the node down (a stop, a
609
+ * removal), and let `loadSavedStart` and the app-link slots wait for it until it settles.
610
+ */
611
+ private trackStop(op: () => Promise<void>): Promise<void> {
612
+ // A start called from here on runs after this transition rather than joining the one it ends.
613
+ this.starting = null;
614
+ const stopping = this.inTurn(op);
615
+ this.stopping = stopping;
616
+ const settled = (): void => {
617
+ if (this.stopping === stopping) this.stopping = null;
618
+ };
619
+ stopping.then(settled, settled);
620
+ return stopping;
621
+ }
622
+
623
+ /** Queue a start, and let later `start` calls join it until it settles. */
624
+ private trackStart(op: () => Promise<CadreNode>): Promise<CadreNode> {
625
+ const starting = this.inTurn(op);
626
+ this.starting = starting;
627
+ const settled = (): void => {
628
+ if (this.starting === starting) this.starting = null;
629
+ };
630
+ starting.then(settled, settled);
631
+ return starting;
632
+ }
633
+
634
+ // ── start ────────────────────────────────────────────────────────────────
635
+ // `bringUp`, `readAlone`, `buildAndStart`, `ownerSlotSigner`, `wireJoinedOwner`,
636
+ // `saveRunningStart` and `reportRunning` are public as the join's and the owner-grant
637
+ // redemption's view of this runtime (`JoinRuntime`, `OwnerGrantRuntime`); the class itself
638
+ // never leaves this module.
639
+
640
+ async buildAndStart(options: EdgeNodeOptions): Promise<CadreNode> {
641
+ this.setStatus({ state: 'starting' });
642
+ try {
643
+ const { node, resolved } = await this.bringUp(options);
644
+ const owner = await this.settleOwnerKey(node, resolved);
645
+ // Finishes a move a join left: the app was killed before adopting, or its key was wired
646
+ // only now. A move record the key store refuses to read fails the start.
647
+ // NOTE: a pending move delays `running` by its control writes, a few per strand. If a
648
+ // resumed move makes a start noticeably slow, adopt after `markRunning` instead, as a
649
+ // transition of its own.
650
+ if (owner === 'enrolled') await adoptMovesInto(node, this.moves);
651
+ await this.markRunning(node, resolved, owner);
652
+ return node;
653
+ } catch (err) {
654
+ // The start's own error is the one to report; a failure tearing down after it is only logged.
655
+ await this.shutDown().catch((cleanupErr: unknown) => {
656
+ console.warn('[cadre-core/edge-node] cleaning up after a failed start failed:', cleanupErr);
657
+ });
658
+ const error = toError(err);
659
+ this.setStatus({ state: 'failed', error });
660
+ throw error;
661
+ }
662
+ }
663
+
664
+ /** Build the node for `options`, every subscription attached, and start it, after finishing a begun dissolve. */
665
+ async bringUp(options: EdgeNodeOptions): Promise<{ node: CadreNode; resolved: EdgeNodeOptions }> {
666
+ const removal = await finishRemovalAtStart(this.partyStorage());
667
+ this.noteRemovalAtStart(removal);
668
+ const resolved = withoutFormerParty(await this.resolveOptions(options), removal.partyId);
669
+ const node = new CadreNode(await this.buildConfig(resolved));
670
+ this.current = node;
671
+ for (const subscription of this.subscriptions) subscription.attach(node);
672
+ this.watchRemoval(node);
673
+ await node.start();
674
+ return { node, resolved };
675
+ }
676
+
677
+ /** What `unfinishedRemoval` reports after a start: a marker that could not be read leaves the last report standing. */
678
+ private noteRemovalAtStart(removal: RemovalAtStart): void {
679
+ if (removal.partyId !== undefined) {
680
+ this.lastUnfinishedRemoval = removal.error ? { partyId: removal.partyId, error: removal.error } : null;
681
+ } else if (!removal.error) {
682
+ this.lastUnfinishedRemoval = null;
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Start a node for `options`' party only to run `read` on it, then stop it. Nothing subscribes,
688
+ * nothing is saved and no strand launches: the node never reports running. A join exports the
689
+ * strands of a stopped party this way, and a dissolve reads the party it removes.
690
+ */
691
+ async readAlone<T>(options: EdgeNodeOptions, read: (node: CadreNode) => Promise<T>, purpose: string): Promise<T> {
692
+ this.setStatus({ state: 'starting' });
693
+ try {
694
+ const config = await this.buildConfig(await this.resolveOptions(options));
695
+ const node = new CadreNode({ ...config, strandFilter: { mode: 'none' } });
696
+ this.current = node;
697
+ await node.start();
698
+ return await read(node);
699
+ } finally {
700
+ // The read's own error, if any, is the one to report.
701
+ await this.shutDown().catch((err: unknown) => {
702
+ console.warn(`[cadre-core/edge-node] stopping the node started to ${purpose} failed:`, err);
703
+ });
704
+ }
705
+ }
706
+
707
+ /** A founded party runs owner genesis; a joined one only wires a key the party already lists. */
708
+ private settleOwnerKey(node: CadreNode, options: EdgeNodeOptions): Promise<OwnerGenesisResult> {
709
+ if (options.origin === 'joined') return this.wireJoinedOwner(node, false);
710
+ return runOwnerGenesis(node, { keyStore: this.keyStore, label: options.ownerKeyLabel, timeoutMs: this.platform.ownerGenesisTimeoutMs });
711
+ }
712
+
713
+ /** The owner slot's key, created when the slot is empty: what an owner grant seats. Inside a transition, as the slot's check-then-set must be. */
714
+ async ownerSlotSigner(): Promise<OwnerSigner> {
715
+ return (await loadOwnerSigner(this.keyStore)) ?? (await createOwnerKey(this.keyStore));
716
+ }
717
+
718
+ /** `granted`: an owner grant has just seated the slot's key, so wait for the row. */
719
+ wireJoinedOwner(node: CadreNode, granted: boolean): Promise<OwnerGenesisResult> {
720
+ return wireJoinedOwnerKey(node, { keyStore: this.keyStore, timeoutMs: this.platform.ownerGenesisTimeoutMs, granted });
721
+ }
722
+
723
+ /** Report a node that came up, saving its options only now, so a failed start never replaces the last one that came up. */
724
+ private async markRunning(node: CadreNode, resolved: EdgeNodeOptions, owner: OwnerGenesisResult): Promise<void> {
725
+ await this.saveRunningStart(resolved);
726
+ this.reportRunning(node, resolved, owner);
727
+ }
728
+
729
+ saveRunningStart(resolved: EdgeNodeOptions): Promise<void> {
730
+ return this.saveStart({ options: resolved, autoStart: true, writtenBy: this.platform.dataVersion });
731
+ }
732
+
733
+ reportRunning(node: CadreNode, resolved: EdgeNodeOptions, owner: OwnerGenesisResult): void {
734
+ this.runningOptions = resolved;
735
+ this.setStatus({ state: 'running', node, owner });
736
+ }
737
+
738
+ /**
739
+ * The options as this start runs and saves them. The Noise mode is resolved, so a later
740
+ * build's default does not change a device that already ran. Options naming no `origin`
741
+ * take the saved start's `origin` and `formerPartyId` when it is for the same party, so a
742
+ * party this app joined, started again from a hand-typed party id, does not run owner genesis.
743
+ */
744
+ private async resolveOptions(options: EdgeNodeOptions): Promise<EdgeNodeOptions> {
745
+ requireOwnerKeyLabel(options.ownerKeyLabel);
746
+ const resolved = { ...options };
747
+ const defaultMode = this.platform.noiseCrypto?.defaultMode;
748
+ if (resolved.noiseCryptoMode === undefined && defaultMode !== undefined) resolved.noiseCryptoMode = defaultMode;
749
+ if (resolved.origin === undefined) {
750
+ const saved = (await this.readSavedStart())?.options;
751
+ if (saved?.partyId === resolved.partyId) {
752
+ if (saved.origin !== undefined) resolved.origin = saved.origin;
753
+ if (saved.formerPartyId !== undefined) resolved.formerPartyId = saved.formerPartyId;
754
+ }
755
+ }
756
+ return resolved;
757
+ }
758
+
759
+ /**
760
+ * Open the node-local stores and assemble the config. A store read failure propagates and fails
761
+ * the start: an unreadable trusted-owner anchor is a refusal to start, not a silent downgrade to
762
+ * trusting nobody, and starting empty would let the next write destroy an intact anchor. (The
763
+ * enrolled-machine store cold-starts instead; a lost repair hint must not stop a node.)
764
+ */
765
+ private async buildConfig(options: EdgeNodeOptions): Promise<CadreNodeConfig> {
766
+ const { partyId, relayAddrs } = options;
767
+ const { platform } = this;
768
+ const config = buildEdgeNodeConfig({
769
+ ...options,
770
+ noiseCrypto: this.buildNoiseCrypto(options.noiseCryptoMode),
771
+ keyStore: this.keyStore,
772
+ storageProvider: (scope) => platform.storage.rawStorage(scope),
773
+ transports: (platform.transports ?? defaultTransports)(relayAddrs),
774
+ listenAddrs: platform.listenAddrs?.(relayAddrs) ?? [],
775
+ allowPrivateDial: platform.allowPrivateDial ?? true,
776
+ trustedOwnerStore: await PersistentTrustedOwnerStore.open(this.trustedOwnerSlot(partyId), partyId),
777
+ bootstrapPeerStore: await PersistentBootstrapPeerStore.open(this.nodeLocalSlot(bootstrapPeersKvKey(partyId)), partyId),
778
+ enrolledMachineStore: await PersistentEnrolledMachineStore.open(this.nodeLocalSlot(enrolledMachinesKvKey(partyId)), partyId),
779
+ strandNetworkStateStore: await PersistentStrandNetworkStateStore.open(this.nodeLocalSlot(strandNetworkKvKey(partyId)), partyId),
780
+ });
781
+ return platform.configure ? platform.configure(config) : config;
782
+ }
783
+
784
+ private buildNoiseCrypto(mode: NoiseCryptoMode | undefined): NoiseCryptoInterface | undefined {
785
+ const noiseCrypto = this.platform.noiseCrypto;
786
+ return noiseCrypto && mode ? noiseCrypto.build(mode) : undefined;
787
+ }
788
+
789
+ // ── dissolve ─────────────────────────────────────────────────────────────
790
+ // NOTE: this file is about 1050 lines (`wc -l`), the dissolve's and the removal's transitions
791
+ // the largest part (the storage they delete is in `dissolve.ts`; the join is `edge-join.ts`,
792
+ // the owner-grant redemption `owner-grant.ts`), then the owner-key and owner-grant
793
+ // pass-throughs. If any of them grows further, move it to a module of its own over a narrow
794
+ // view of this runtime, as the join is.
795
+
796
+ /** docs/architecture.md → "Dissolving the party it left". */
797
+ private async dissolve(discard: readonly string[]): Promise<void> {
798
+ await this.finishMarkedRemoval();
799
+ const { node, running, formerPartyId } = this.runningWithFormerParty();
800
+ const record = await this.moves.load(formerPartyId);
801
+ if (record) throw new StrandMovePendingError(pendingMove(record));
802
+ const holdings = await readStrandHoldings(node);
803
+ try {
804
+ await this.shutDown();
805
+ const content = await this.readAlone(formerPartyRead(formerPartyId, running), readFormerParty, 'read the party it left');
806
+ const refusal = formerPartyRefusal(content, holdings, discard, Date.now());
807
+ if (refusal) throw refusal;
808
+ await removeParty(this.partyStorage(), formerPartyId);
809
+ } catch (err) {
810
+ await this.startAgainAfterDissolve(running);
811
+ throw err;
812
+ }
813
+ await this.buildAndStart(withoutFormerParty(running, formerPartyId));
814
+ }
815
+
816
+ private runningWithFormerParty(): { node: CadreNode; running: EdgeNodeOptions; formerPartyId: string } {
817
+ const node = this.node;
818
+ const running = this.runningOptions;
819
+ const formerPartyId = running?.formerPartyId;
820
+ if (!node?.isRunning || !running || formerPartyId === undefined) {
821
+ throw new Error('Nothing to dissolve: the edge node is not running with a former party, one this app founded and left');
822
+ }
823
+ // Only a hand-built start names its own party; dissolving it would delete the running party's control database.
824
+ if (formerPartyId === running.partyId) {
825
+ throw new Error(`Cannot dissolve party ${formerPartyId}: it is the running party, not one this app left`);
826
+ }
827
+ return { node, running, formerPartyId };
828
+ }
829
+
830
+ /** After a refused or failed dissolve. The dissolve's own error is the one its caller gets; this one is only logged. */
831
+ private async startAgainAfterDissolve(running: EdgeNodeOptions): Promise<void> {
832
+ try {
833
+ await this.buildAndStart(running);
834
+ } catch (err) {
835
+ console.warn('[cadre-core/edge-node] starting the running party again after a dissolve failed:', err);
836
+ }
837
+ }
838
+
839
+ /** Finish the removal a marker names, if any; finished, it is no longer an unfinished one to report. */
840
+ private async finishMarkedRemoval(): Promise<void> {
841
+ await finishMarkedRemoval(this.partyStorage());
842
+ this.lastUnfinishedRemoval = null;
843
+ }
844
+
845
+ // ── removal ──────────────────────────────────────────────────────────────
846
+
847
+ /**
848
+ * Answer `node` reporting that an owner removed this app from its party
849
+ * ({@link forgetRemovedParty}). Attached before the node starts, so no report comes before it
850
+ * and `removedFromCadre()` has nothing to add; never detached, so a report raised while the
851
+ * node stops, which it made while running, is still answered.
852
+ *
853
+ * NOTE: a device removed while its app was not running never sees its own row live after the
854
+ * next start, so it never reports (docs/architecture.md → "The removed machine learns it"), and
855
+ * keeps the party's data while every other machine refuses it. If users meet this, persist
856
+ * "seen live" per party so the rule survives a restart.
857
+ */
858
+ private watchRemoval(node: CadreNode): void {
859
+ node.on('cadre:removed', (removal) => {
860
+ void this.trackStop(() => this.forgetRemovedParty(removal));
861
+ });
862
+ }
863
+
864
+ /**
865
+ * In turn with start, stop and join: stop the removed party's node, point the saved start away
866
+ * from that party, delete its local storage and report `removed`. A node of another party, started
867
+ * by a join that ran before this turn, keeps running; only the removed party's storage goes.
868
+ * Nothing starts unattended afterwards: the user sees why the app left its cadre first. A
869
+ * failure is logged and leaves `status` `failed`; never rejects.
870
+ *
871
+ * NOTE: `start` does not refuse the removed party's options, so an app that cached them builds
872
+ * that party again on empty storage. If one is seen doing so, refuse a start of `partyId` while
873
+ * `status` is `removed`.
874
+ */
875
+ private async forgetRemovedParty(removal: CadreRemoval): Promise<void> {
876
+ const { partyId } = removal;
877
+ try {
878
+ if (this.current !== null && this.current.partyId !== partyId) {
879
+ await this.removeLocalParty(partyId);
880
+ if (this.runningOptions) this.runningOptions = withoutFormerParty(this.runningOptions, partyId);
881
+ return;
882
+ }
883
+ await this.shutDown().catch((err: unknown) => {
884
+ // `shutDown` has closed every store and dropped the node whether or not its stop threw.
885
+ console.warn(`[cadre-core/edge-node] stopping the node of party ${partyId}, which removed this app, failed:`, err);
886
+ });
887
+ const formerPartyId = await this.leaveRemovedStart(partyId);
888
+ await this.removeLocalParty(partyId);
889
+ this.setStatus({ state: 'removed', partyId, removal, ...(formerPartyId !== undefined && { formerPartyId }) });
890
+ } catch (err) {
891
+ console.warn(`[cadre-core/edge-node] acting on this app's removal from party ${partyId} failed:`, err);
892
+ this.setStatus({ state: 'failed', error: toError(err) });
893
+ }
894
+ }
895
+
896
+ /**
897
+ * Replace a saved start of `partyId` with {@link startAfterRemoval}'s, or delete it, before any
898
+ * of the party's storage goes, so no start builds that party again on deleted storage; returns
899
+ * the former party the new start names. A saved start of another party is left alone. Throws a
900
+ * failed read or write, and the storage then stays, matching the saved start that still names it.
901
+ */
902
+ private async leaveRemovedStart(partyId: string): Promise<string | undefined> {
903
+ const saved = await this.readSavedStart();
904
+ if (saved?.options.partyId !== partyId) return undefined;
905
+ const next = startAfterRemoval(saved);
906
+ if (next) await this.nodeLocalSlot(this.names.savedStartKey).save(serializeSavedStart(next));
907
+ else await this.deleteSavedStart();
908
+ return next?.options.partyId;
909
+ }
910
+
911
+ /**
912
+ * Delete `partyId`'s local storage with the dissolve's marked removal, after finishing one a
913
+ * failure left, so this marker does not replace it. A failure is logged: once the marker is
914
+ * written, the next start finishes the removal.
915
+ */
916
+ private async removeLocalParty(partyId: string): Promise<void> {
917
+ try {
918
+ await this.finishMarkedRemoval();
919
+ await removeParty(this.partyStorage(), partyId);
920
+ } catch (err) {
921
+ console.warn(`[cadre-core/edge-node] deleting the storage of party ${partyId}, which removed this app, failed:`, err);
922
+ }
923
+ }
924
+
925
+ // ── stop ─────────────────────────────────────────────────────────────────
926
+
927
+ private async stopAndClearAutoStart(): Promise<void> {
928
+ // NOTE: with no node running (the last start failed) the saved record is left alone, so an
929
+ // earlier `autoStart: true` survives this stop. The reference app offers Disconnect only
930
+ // while connected; if an app lets a user stop a node whose start failed, rewrite the
931
+ // saved record with `autoStart: false` here too.
932
+ if (this.runningOptions) {
933
+ await this.saveStart({ options: this.runningOptions, autoStart: false, writtenBy: this.platform.dataVersion });
934
+ }
935
+ await this.shutDown();
936
+ }
937
+
938
+ /**
939
+ * Stop the node and close every store, the node's references cleared first: a throwing
940
+ * `stop()` must not leave a node whose stores the `finally` has just closed, which the next
941
+ * `start` would hand back as running.
942
+ */
943
+ async shutDown(): Promise<void> {
944
+ const stopping = this.current;
945
+ this.current = null;
946
+ this.runningOptions = null;
947
+ try {
948
+ if (stopping) {
949
+ for (const subscription of this.subscriptions) subscription.detach(stopping);
950
+ await stopping.stop();
951
+ }
952
+ } finally {
953
+ // The node-local store first, flushed, then every raw store: the platform's contract.
954
+ await this.platform.storage.close();
955
+ this.setStatus({ state: 'stopped' });
956
+ }
957
+ }
958
+
959
+ // ── storage ──────────────────────────────────────────────────────────────
960
+
961
+ private nodeLocalKv(): NodeLocalKv {
962
+ return this.platform.storage.nodeLocal();
963
+ }
964
+
965
+ private nodeLocalSlot(key: string): DurableSlot {
966
+ return kvStoreSlot(this.nodeLocalKv(), key);
967
+ }
968
+
969
+ /** The party's trusted-owner anchor: the platform's slot, else the node-local key. */
970
+ private trustedOwnerSlot(partyId: string): RemovableSlot {
971
+ return this.platform.storage.trustedOwnerSlot?.(partyId) ?? nodeLocalRemovableSlot(this.nodeLocalKv(), trustedOwnersKvKey(partyId));
972
+ }
973
+
974
+ private partyStorage(): PartyStorage {
975
+ return {
976
+ nodeLocal: this.nodeLocalKv(),
977
+ markerKey: this.names.dissolveMarkerKey,
978
+ savedStartKey: this.names.savedStartKey,
979
+ destroyScope: (scope) => this.platform.storage.destroyScope(scope),
980
+ trustedOwnerSlot: (partyId) => this.trustedOwnerSlot(partyId),
981
+ keyStore: this.keyStore,
982
+ moves: this.moves,
983
+ };
984
+ }
985
+
986
+ /** The saved start, read directly: inside a transition, `loadSavedStart` would wait on a stop queued behind it. */
987
+ async readSavedStart(): Promise<SavedStart | undefined> {
988
+ return parseSavedStart(await this.nodeLocalSlot(this.names.savedStartKey).load());
989
+ }
990
+
991
+ /** Delete the saved start, durably; a failed write throws. */
992
+ private async deleteSavedStart(): Promise<void> {
993
+ const kv = this.nodeLocalKv();
994
+ await kv.delete(this.names.savedStartKey);
995
+ await kv.flush();
996
+ }
997
+
998
+ /** Best-effort: a failed write is logged and changes nothing about the node. */
999
+ private async saveStart(saved: SavedStart): Promise<void> {
1000
+ try {
1001
+ await this.nodeLocalSlot(this.names.savedStartKey).save(serializeSavedStart(saved));
1002
+ } catch (err) {
1003
+ console.warn('[cadre-core/edge-node] could not save the start options:', err);
1004
+ }
1005
+ }
1006
+
1007
+ setStatus(status: EdgeNodeStatus): void {
1008
+ this.currentStatus = status;
1009
+ for (const listener of this.statusListeners) {
1010
+ try {
1011
+ listener(status);
1012
+ } catch (err) {
1013
+ console.warn('[cadre-core/edge-node] status listener threw:', err);
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ /** The default anchor slot: one node-local key, whose removal is durable when it resolves, as `kvStoreSlot`'s save is. */
1020
+ function nodeLocalRemovableSlot(kv: NodeLocalKv, key: string): RemovableSlot {
1021
+ return {
1022
+ ...kvStoreSlot(kv, key),
1023
+ remove: async () => {
1024
+ await kv.delete(key);
1025
+ await kv.flush();
1026
+ },
1027
+ };
1028
+ }
1029
+
1030
+ /** Abort `target` with `source`'s reason when `source` aborts; returns the unsubscribe. */
1031
+ function relayAbort(source: AbortSignal | undefined, target: AbortController): () => void {
1032
+ if (!source) return () => {};
1033
+ const abort = (): void => target.abort(source.reason);
1034
+ if (source.aborted) {
1035
+ abort();
1036
+ return () => {};
1037
+ }
1038
+ source.addEventListener('abort', abort, { once: true });
1039
+ return () => source.removeEventListener('abort', abort);
1040
+ }
1041
+
1042
+ /** WebSockets to dial the party's machines and relays, and circuit relay to be reachable through one. */
1043
+ function defaultTransports(): Libp2pTransports {
1044
+ return [webSockets(), circuitRelayTransport()];
1045
+ }