@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
@@ -1,171 +1,174 @@
1
- import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
2
- import { getPublicKey } from '@optimystic/quereus-plugin-crypto';
3
- import { peerIdFromPublicKey, peerIdFromString } from '@libp2p/peer-id';
4
- import { publicKeyFromRaw } from '@libp2p/crypto/keys';
5
- import type { PrivateKey } from '@libp2p/interface';
6
-
7
- /**
8
- * An Ed25519 keypair expressed in the base64url form that
9
- * `@optimystic/quereus-plugin-crypto` (`sign`/`verify`/`getPublicKey`) consumes.
10
- * Generic bridge type — reused for owner keys, member keys, and any other
11
- * seed→public keypair the control/strand databases sign with.
12
- */
13
- export interface Ed25519KeyPair {
14
- /** 32-byte Ed25519 seed, base64url-encoded — the crypto-plugin private key. */
15
- privateKeyB64: string;
16
- /** 32-byte Ed25519 public key, base64url-encoded. */
17
- publicKeyB64: string;
18
- }
19
-
20
- /**
21
- * Bridge a libp2p Ed25519 private key into the base64url keypair used by the
22
- * control- and strand-database signing constraints.
23
- *
24
- * libp2p stores an Ed25519 private key as 64 raw bytes: the first 32 are the
25
- * seed (the actual scalar source), the last 32 are the public key — see
26
- * `@libp2p/crypto`'s `Ed25519PrivateKey`. `@optimystic/quereus-plugin-crypto`
27
- * (via `@noble/curves`) treats the 32-byte seed *as* the private key and
28
- * derives the public key from it with standard Ed25519. The two derivations
29
- * agree, so the node's peer identity and its signing key are one keypair:
30
- * `getPublicKey(privateKeyB64)` === `publicKeyB64`.
31
- *
32
- * @param privateKey - The node's libp2p Ed25519 private key.
33
- * @returns The base64url seed/public-key pair for signing operations.
34
- * @throws If the key is not Ed25519 or the raw bytes aren't the expected length.
35
- */
36
- export function ed25519KeyPairFromLibp2p(privateKey: PrivateKey): Ed25519KeyPair {
37
- if (privateKey.type !== 'Ed25519') {
38
- throw new Error(`ed25519KeyPairFromLibp2p requires an Ed25519 key, got ${privateKey.type}`);
39
- }
40
-
41
- const raw = privateKey.raw;
42
- if (raw.length !== 64) {
43
- throw new Error(`Expected a 64-byte Ed25519 raw private key (seed||public), got ${raw.length} bytes`);
44
- }
45
-
46
- const seed = raw.subarray(0, 32);
47
- const publicKeyRaw = privateKey.publicKey.raw;
48
- if (publicKeyRaw.length !== 32) {
49
- throw new Error(`Expected a 32-byte Ed25519 public key, got ${publicKeyRaw.length} bytes`);
50
- }
51
-
52
- return {
53
- privateKeyB64: uint8ArrayToString(seed, 'base64url'),
54
- publicKeyB64: uint8ArrayToString(publicKeyRaw, 'base64url'),
55
- };
56
- }
57
-
58
- /**
59
- * Derive the base64url Ed25519 public key from a base64url 32-byte private seed
60
- * — the same derivation the seed-bootstrap signer uses internally
61
- * (`SeedBootstrapService` constructor). Use this to enroll a standalone
62
- * (non-libp2p) key into the control DB before minting an invite, when the
63
- * key is *not* the node's peer identity (so `ed25519KeyPairFromLibp2p`,
64
- * which needs a libp2p key object, does not apply).
65
- *
66
- * @param privateKeyB64 - The base64url-encoded 32-byte Ed25519 seed.
67
- * @returns The base64url-encoded Ed25519 public key.
68
- */
69
- export function ed25519PublicKeyFromPrivate(privateKeyB64: string): string {
70
- return getPublicKey(privateKeyB64, 'ed25519', 'base64url', 'base64url') as string;
71
- }
72
-
73
- /**
74
- * Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
75
- *
76
- * An Ed25519 PeerId is an identity multihash of the public key, so
77
- * `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
78
- * base64url form matches the `OwnerKey.Key` representation (and
79
- * `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
80
- * id, a missing embedded key, or any parse failure — callers treat null as
81
- * "not an owner" rather than throwing.
82
- *
83
- * Lives here, beside the other key bridges, so the lightweight verifiers in
84
- * `peer-authorization.ts` can check a stored `PeerKey` against a row's `PeerId`
85
- * without importing the seed-bootstrap runtime.
86
- */
87
- export function ed25519PublicKeyB64FromPeerId(peerId: string): string | null {
88
- try {
89
- const parsed = peerIdFromString(peerId);
90
- if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
91
- return null;
92
- }
93
- return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
94
- } catch {
95
- return null;
96
- }
97
- }
98
-
99
- /**
100
- * The inverse of {@link ed25519PublicKeyB64FromPeerId}: the libp2p peer id whose identity
101
- * is this ed25519 public key (base64url), or null when the value does not decode to a
102
- * 32-byte key. How an `OwnerKey` row is matched to the machine it names
103
- * (`machine-removal.ts`), since a machine's owner key is the key behind its peer id.
104
- */
105
- export function peerIdFromEd25519PublicKeyB64(publicKeyB64: string): string | null {
106
- try {
107
- const raw = uint8ArrayFromString(publicKeyB64, 'base64url');
108
- if (raw.length !== 32) {
109
- return null;
110
- }
111
- return peerIdFromPublicKey(publicKeyFromRaw(raw)).toString();
112
- } catch {
113
- return null;
114
- }
115
- }
116
-
117
- /** Longest rejected key value echoed back in an error — a real key is 43 base64url chars. */
118
- const REJECTED_VALUE_ECHO_LIMIT = 64;
119
-
120
- /**
121
- * Render a rejected value for an error message, capped: owner keys now reach this check
122
- * from remote-supplied fields (a cadre invitation's `ownerKeys`, a donation request's
123
- * `ownerKeys`), so an unbounded echo would let a peer turn one junk string into a
124
- * megabyte of log line.
125
- */
126
- function describeRejected(value: string): string {
127
- return value.length <= REJECTED_VALUE_ECHO_LIMIT
128
- ? value
129
- : `${value.slice(0, REJECTED_VALUE_ECHO_LIMIT)}… (${value.length} chars)`;
130
- }
131
-
132
- /**
133
- * Reject a value that isn't shaped like a base64url-encoded 32-byte Ed25519 public key
134
- * before it reaches the control database, where a malformed key would otherwise sit
135
- * indistinguishable from a real one until some later, unrelated signature check fails.
136
- *
137
- * Trims first, reusing the blank-value message for an empty result so that case keeps its
138
- * existing wording instead of a confusing base64url error. Every rejection names the offending
139
- * value (capped, see {@link describeRejected}) — with several keys in one batch, the message is
140
- * the only thing that says WHICH one. Does not check the decoded bytes
141
- * are a valid point on the Ed25519 curve — a well-formed-but-off-curve key still fails
142
- * signature verification later exactly like any other wrong key.
143
- *
144
- * @param value - Candidate base64url-encoded Ed25519 public key.
145
- * @param label - Human-readable name of the field, used in error messages.
146
- * @returns The trimmed value, so callers write the same bytes they validated.
147
- */
148
- export function requireEd25519PublicKeyB64(value: string, label: string): string {
149
- const trimmed = value.trim();
150
- if (trimmed.length === 0) {
151
- throw new Error(`A ${label} is required (received an empty or whitespace-only value)`);
152
- }
153
-
154
- let decoded: Uint8Array;
155
- try {
156
- decoded = uint8ArrayFromString(trimmed, 'base64url');
157
- } catch (error) {
158
- throw new Error(
159
- `A ${label} must be a base64url-encoded Ed25519 public key (could not decode "${describeRejected(trimmed)}" as base64url)`,
160
- { cause: error },
161
- );
162
- }
163
-
164
- if (decoded.length !== 32) {
165
- throw new Error(
166
- `A ${label} must be a base64url-encoded 32-byte Ed25519 public key ("${describeRejected(trimmed)}" decoded to ${decoded.length} bytes)`,
167
- );
168
- }
169
-
170
- return trimmed;
171
- }
1
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
2
+ import { getPublicKey } from '@optimystic/quereus-plugin-crypto';
3
+ import { peerIdFromString } from '@libp2p/peer-id';
4
+ import type { PrivateKey } from '@libp2p/interface';
5
+
6
+ /**
7
+ * An Ed25519 keypair expressed in the base64url form that
8
+ * `@optimystic/quereus-plugin-crypto` (`sign`/`verify`/`getPublicKey`) consumes.
9
+ * Generic bridge type — reused for owner keys, member keys, and any other
10
+ * seed→public keypair the control/strand databases sign with.
11
+ */
12
+ export interface Ed25519KeyPair {
13
+ /** 32-byte Ed25519 seed, base64url-encoded — the crypto-plugin private key. */
14
+ privateKeyB64: string;
15
+ /** 32-byte Ed25519 public key, base64url-encoded. */
16
+ publicKeyB64: string;
17
+ }
18
+
19
+ /**
20
+ * Bridge a libp2p Ed25519 private key into the base64url keypair used by the
21
+ * control- and strand-database signing constraints.
22
+ *
23
+ * libp2p stores an Ed25519 private key as 64 raw bytes: the first 32 are the
24
+ * seed (the actual scalar source), the last 32 are the public key — see
25
+ * `@libp2p/crypto`'s `Ed25519PrivateKey`. `@optimystic/quereus-plugin-crypto`
26
+ * (via `@noble/curves`) treats the 32-byte seed *as* the private key and
27
+ * derives the public key from it with standard Ed25519. The two derivations
28
+ * agree, so the node's peer identity and its signing key are one keypair:
29
+ * `getPublicKey(privateKeyB64)` === `publicKeyB64`.
30
+ *
31
+ * @param privateKey - The node's libp2p Ed25519 private key.
32
+ * @returns The base64url seed/public-key pair for signing operations.
33
+ * @throws If the key is not Ed25519 or the raw bytes aren't the expected length.
34
+ */
35
+ export function ed25519KeyPairFromLibp2p(privateKey: PrivateKey): Ed25519KeyPair {
36
+ if (privateKey.type !== 'Ed25519') {
37
+ throw new Error(`ed25519KeyPairFromLibp2p requires an Ed25519 key, got ${privateKey.type}`);
38
+ }
39
+
40
+ const raw = privateKey.raw;
41
+ if (raw.length !== 64) {
42
+ throw new Error(`Expected a 64-byte Ed25519 raw private key (seed||public), got ${raw.length} bytes`);
43
+ }
44
+
45
+ const seed = raw.subarray(0, 32);
46
+ const publicKeyRaw = privateKey.publicKey.raw;
47
+ if (publicKeyRaw.length !== 32) {
48
+ throw new Error(`Expected a 32-byte Ed25519 public key, got ${publicKeyRaw.length} bytes`);
49
+ }
50
+
51
+ return {
52
+ privateKeyB64: uint8ArrayToString(seed, 'base64url'),
53
+ publicKeyB64: uint8ArrayToString(publicKeyRaw, 'base64url'),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Derive the base64url Ed25519 public key from a base64url 32-byte private seed
59
+ * — the same derivation the seed-bootstrap signer uses internally
60
+ * (`SeedBootstrapService` constructor). Use this to enroll a standalone
61
+ * (non-libp2p) key into the control DB before minting an invite, when the
62
+ * key is *not* the node's peer identity (so `ed25519KeyPairFromLibp2p`,
63
+ * which needs a libp2p key object, does not apply).
64
+ *
65
+ * @param privateKeyB64 - The base64url-encoded 32-byte Ed25519 seed.
66
+ * @returns The base64url-encoded Ed25519 public key.
67
+ */
68
+ export function ed25519PublicKeyFromPrivate(privateKeyB64: string): string {
69
+ return getPublicKey(privateKeyB64, 'ed25519', 'base64url', 'base64url') as string;
70
+ }
71
+
72
+ /**
73
+ * Reject a value that isn't a base64url-encoded 32-byte Ed25519 seed, the private-key form
74
+ * {@link ed25519PublicKeyFromPrivate} and the crypto plugin's `sign` take. Unlike
75
+ * {@link requireEd25519PublicKeyB64}, no message names the value: it is a private key.
76
+ *
77
+ * @param value - Candidate base64url-encoded Ed25519 seed; surrounding whitespace is ignored.
78
+ * @param label - Human-readable name of the field, used in error messages.
79
+ * @returns The seed re-encoded as canonical base64url, so two spellings of one key compare equal.
80
+ */
81
+ export function requireEd25519PrivateKeyB64(value: string, label: string): string {
82
+ let decoded: Uint8Array;
83
+ try {
84
+ decoded = uint8ArrayFromString(value.trim(), 'base64url');
85
+ } catch (error) {
86
+ throw new Error(`The ${label} must be a base64url-encoded Ed25519 private key (it does not decode as base64url)`, { cause: error });
87
+ }
88
+ if (decoded.length !== 32) {
89
+ throw new Error(`The ${label} must be a base64url-encoded 32-byte Ed25519 private key (it decoded to ${decoded.length} bytes)`);
90
+ }
91
+ return uint8ArrayToString(decoded, 'base64url');
92
+ }
93
+
94
+ /**
95
+ * Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
96
+ *
97
+ * An Ed25519 PeerId is an identity multihash of the public key, so
98
+ * `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
99
+ * base64url form matches the `OwnerKey.Key` representation (and
100
+ * `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
101
+ * id, a missing embedded key, or any parse failure — callers treat null as
102
+ * "not an owner" rather than throwing.
103
+ *
104
+ * Lives here, beside the other key bridges, so the lightweight verifiers in
105
+ * `peer-authorization.ts` can check a stored `PeerKey` against a row's `PeerId`
106
+ * without importing the seed-bootstrap runtime.
107
+ */
108
+ export function ed25519PublicKeyB64FromPeerId(peerId: string): string | null {
109
+ try {
110
+ const parsed = peerIdFromString(peerId);
111
+ if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
112
+ return null;
113
+ }
114
+ return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /** Longest rejected key value echoed back in an error — a real key is 43 base64url chars. */
121
+ const REJECTED_VALUE_ECHO_LIMIT = 64;
122
+
123
+ /**
124
+ * Render a rejected value for an error message, capped: owner keys now reach this check
125
+ * from remote-supplied fields (a cadre invitation's `ownerKeys`, a donation request's
126
+ * `ownerKeys`), so an unbounded echo would let a peer turn one junk string into a
127
+ * megabyte of log line.
128
+ */
129
+ function describeRejected(value: string): string {
130
+ return value.length <= REJECTED_VALUE_ECHO_LIMIT
131
+ ? value
132
+ : `${value.slice(0, REJECTED_VALUE_ECHO_LIMIT)}… (${value.length} chars)`;
133
+ }
134
+
135
+ /**
136
+ * Reject a value that isn't shaped like a base64url-encoded 32-byte Ed25519 public key
137
+ * before it reaches the control database, where a malformed key would otherwise sit
138
+ * indistinguishable from a real one until some later, unrelated signature check fails.
139
+ *
140
+ * Trims first, reusing the blank-value message for an empty result so that case keeps its
141
+ * existing wording instead of a confusing base64url error. Every rejection names the offending
142
+ * value (capped, see {@link describeRejected}) — with several keys in one batch, the message is
143
+ * the only thing that says WHICH one. Does not check the decoded bytes
144
+ * are a valid point on the Ed25519 curve — a well-formed-but-off-curve key still fails
145
+ * signature verification later exactly like any other wrong key.
146
+ *
147
+ * @param value - Candidate base64url-encoded Ed25519 public key.
148
+ * @param label - Human-readable name of the field, used in error messages.
149
+ * @returns The trimmed value, so callers write the same bytes they validated.
150
+ */
151
+ export function requireEd25519PublicKeyB64(value: string, label: string): string {
152
+ const trimmed = value.trim();
153
+ if (trimmed.length === 0) {
154
+ throw new Error(`A ${label} is required (received an empty or whitespace-only value)`);
155
+ }
156
+
157
+ let decoded: Uint8Array;
158
+ try {
159
+ decoded = uint8ArrayFromString(trimmed, 'base64url');
160
+ } catch (error) {
161
+ throw new Error(
162
+ `A ${label} must be a base64url-encoded Ed25519 public key (could not decode "${describeRejected(trimmed)}" as base64url)`,
163
+ { cause: error },
164
+ );
165
+ }
166
+
167
+ if (decoded.length !== 32) {
168
+ throw new Error(
169
+ `A ${label} must be a base64url-encoded 32-byte Ed25519 public key ("${describeRejected(trimmed)}" decoded to ${decoded.length} bytes)`,
170
+ );
171
+ }
172
+
173
+ return trimmed;
174
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * The edge node's `CadreNodeConfig` and how a start settles its owner key. Nothing here needs a
3
+ * node started, so a test can build the node shape an edge device runs.
4
+ */
5
+
6
+ import { DEFAULT_IDENTITY_KEY_ID, type KeyStore } from '../key-store.js';
7
+ import { createOwnerKey, loadOwnerSigner } from '../owner-key-store.js';
8
+ import { OWNER_KEY_MAX_LABEL } from '../owner-key-export.js';
9
+ import type { OwnerSigner } from '../owner-signer.js';
10
+ import type { CadreNode } from '../cadre-node.js';
11
+ import type { CadreNodeConfig } from '../types.js';
12
+ import type { BootstrapPeerStore } from '../bootstrap-peer-store.js';
13
+ import type { EnrolledMachineStore } from '../enrolled-machine-store.js';
14
+ import type { StrandNetworkStateStore } from '../strand-network-state.js';
15
+ import type { TrustedOwnerStore } from '../trusted-owner-store.js';
16
+ import type { IRawStorage, Libp2pTransports, NoiseCryptoInterface } from '@optimystic/db-p2p';
17
+ import type { EdgeNodeOptions } from './options.js';
18
+
19
+ /** What {@link buildEdgeNodeConfig} takes: one start's options plus what the platform built. */
20
+ export interface EdgeNodeConfigInputs extends EdgeNodeOptions {
21
+ /** `undefined` keeps libp2p-noise's own crypto. */
22
+ noiseCrypto?: NoiseCryptoInterface;
23
+ /**
24
+ * Holds the node identity, which the node loads on start, generating it on first run, and the
25
+ * owner key in its own slot (`DEFAULT_OWNER_KEY_ID`), which only {@link runOwnerGenesis} creates.
26
+ */
27
+ keyStore: KeyStore;
28
+ /**
29
+ * Raw block storage per cadre-core storage scope: `controlStorageScope(partyId)` for the
30
+ * control network, the strand id for each strand. The key is already safe as a database-name
31
+ * segment — use it verbatim, do not parse it.
32
+ */
33
+ storageProvider: (scope: string) => IRawStorage;
34
+ /** libp2p transport factories: what the node dials with, and what listens on `listenAddrs`. */
35
+ transports: Libp2pTransports;
36
+ /**
37
+ * Listen addresses besides the bare `/p2p-circuit` entry cadre-core adds for a named relay.
38
+ * Default `[]`: an edge device accepts no inbound connection. See `EdgeNodePlatform.listenAddrs`.
39
+ */
40
+ listenAddrs?: string[];
41
+ /** See `EdgeNodePlatform.allowPrivateDial`. */
42
+ allowPrivateDial: boolean;
43
+ trustedOwnerStore: TrustedOwnerStore;
44
+ bootstrapPeerStore: BootstrapPeerStore;
45
+ enrolledMachineStore: EnrolledMachineStore;
46
+ strandNetworkStateStore: StrandNetworkStateStore;
47
+ }
48
+
49
+ /**
50
+ * The edge node's config: transaction profile (Ring Zulu only, intermittent connectivity), no
51
+ * listen address unless the platform names one, reachability through the configured relays (if
52
+ * any) but never a hard requirement for one, every strand the control network lists, and no
53
+ * automatic hibernation (the app's lifecycle runner hibernates on background). Schema signing is
54
+ * left at cadre-core's fail-closed default; an app with unsigned schemas relaxes it in
55
+ * `EdgeNodePlatform.configure`.
56
+ */
57
+ export function buildEdgeNodeConfig(inputs: EdgeNodeConfigInputs): CadreNodeConfig {
58
+ return {
59
+ // Identity comes from the key store (loaded when present, generated and persisted on first
60
+ // run). Mutually exclusive with `privateKey`, which would take it out of the key store.
61
+ keyStore: inputs.keyStore,
62
+ identityKeyId: DEFAULT_IDENTITY_KEY_ID,
63
+ controlNetwork: {
64
+ partyId: inputs.partyId,
65
+ bootstrapNodes: inputs.bootstrapAddrs,
66
+ },
67
+ profile: 'transaction',
68
+ storage: {
69
+ provider: inputs.storageProvider,
70
+ },
71
+ network: {
72
+ transports: inputs.transports,
73
+ // An edge device accepts no inbound connection, so by default it listens on nothing:
74
+ // the only address it ever has is the `/p2p-circuit` address a relay reservation earns
75
+ // it. An explicitly empty `listenAddrs` stays empty through cadre-core's derivation, and
76
+ // naming a relay adds the bare `/p2p-circuit` search entry to it
77
+ // (`cadre-core/src/relay-addrs.ts` → `resolveListenAddrs`). A browser adds `/webrtc`.
78
+ listenAddrs: inputs.listenAddrs ?? [],
79
+ // Through this config field rather than `CadreNode.reserveRelays()`, which reaches the
80
+ // control node only: formation has the invitee dial the control node and then the strand
81
+ // nodes, so both need a circuit address.
82
+ //
83
+ // NOTE: naming a relay starts a reservation supervisor that runs for as long as the
84
+ // control node does, including while backgrounded (hibernation stops strands, not the
85
+ // control node): a 5 s local check while the reservation holds, a dial every backoff
86
+ // interval (2 s → 60 s) while it does not. If background battery use becomes a complaint,
87
+ // raise `checkMs`/`maxBackoffMs` in cadre-core rather than stopping the supervisor here,
88
+ // which would leave a device that silently stopped being invitable.
89
+ relayAddrs: inputs.relayAddrs,
90
+ // An edge device must come up with its relay down — on a plane, on dead Wi-Fi, or with
91
+ // none configured. A first reservation attempt that lands nothing is logged and retried in
92
+ // the background instead of failing `start()`. A malformed entry still throws.
93
+ requireRelay: false,
94
+ // The platform's Noise primitives (native SHA-256 / ChaCha20-Poly1305, and X25519 in
95
+ // `full` mode, on a phone); only local primitives change, not the wire protocol.
96
+ noiseCrypto: inputs.noiseCrypto,
97
+ // libp2p's `connection-gater` points its `react-native` and `browser` fields at a build
98
+ // that refuses to dial plain `ws://`, loopback and private addresses. A cadre-host node
99
+ // claimed by its code on the same Wi-Fi, an emulator reaching `10.0.2.2` and a self-run
100
+ // relay without TLS are all of those. Only the dial is permitted: the connection is still
101
+ // Noise, the pinned `/p2p/<id>` decides which machine answers, and membership is still
102
+ // gated by `denyDialPeer` and the inbound hooks. cadre-core hands this gater to strand nodes too.
103
+ ...(inputs.allowPrivateDial ? { connectionGater: { denyDialMultiaddr: () => false } } : {}),
104
+ },
105
+ strandFilter: { mode: 'all' },
106
+ hibernation: { enabled: false },
107
+ trustedOwners: { store: inputs.trustedOwnerStore },
108
+ bootstrapPeers: { store: inputs.bootstrapPeerStore },
109
+ enrolledMachines: { store: inputs.enrolledMachineStore },
110
+ strandNetworkState: { store: inputs.strandNetworkStateStore },
111
+ };
112
+ }
113
+
114
+ /**
115
+ * How the start settled this node's owner key: `EdgeNodeStatus.owner`. `'enrolled'`: a key is
116
+ * an owner and wired for signing. `'member'`: the party lists no key this app holds (a party it
117
+ * joined, or one it founded whose owner slot was lost). `'failed'` and `'timed-out'`: see
118
+ * {@link runOwnerGenesis}.
119
+ */
120
+ export type OwnerGenesisResult = 'enrolled' | 'member' | 'failed' | 'timed-out';
121
+
122
+ /**
123
+ * Default bound on {@link runOwnerGenesis} and {@link wireJoinedOwnerKey}: what a start can wait
124
+ * for its owner step, which runs control reads and one control write, before it goes on without
125
+ * it. Not derived from the link, since a start that waited out a stalled write (about 84 s, see
126
+ * `docs/cadre-consistency.md`) would be a start the user gave up on.
127
+ */
128
+ // eslint-disable-next-line no-restricted-syntax -- cuts off by design: a start goes on without its owner step rather than wait out a stalled control write; see docs/cadre-consistency.md → "Deadlines Over Optimystic's Reads and Commits"
129
+ export const DEFAULT_OWNER_GENESIS_TIMEOUT_MS = 60_000;
130
+
131
+ /** Default `EdgeNodeOptions.ownerKeyLabel`: the founding owner key's name on its `OwnerKey` row. */
132
+ export const DEFAULT_OWNER_KEY_LABEL = 'phone';
133
+
134
+ /**
135
+ * Refuses an `ownerKeyLabel` the founding `OwnerKey` row would refuse, so a start naming one
136
+ * fails outright: fail-soft genesis would only log it, on that start and every later one.
137
+ */
138
+ export function requireOwnerKeyLabel(label: string | undefined): void {
139
+ const length = label?.trim().length ?? 0;
140
+ if (length > OWNER_KEY_MAX_LABEL) {
141
+ throw new Error(`ownerKeyLabel must be at most ${OWNER_KEY_MAX_LABEL} characters (this one has ${length})`);
142
+ }
143
+ }
144
+
145
+ /** What {@link runOwnerGenesis} takes. */
146
+ export interface OwnerGenesisOptions {
147
+ /** The key store whose owner slot holds this app's owner key: the one the identity is in. */
148
+ keyStore: KeyStore;
149
+ /** The founding row's label; default {@link DEFAULT_OWNER_KEY_LABEL}. */
150
+ label?: string;
151
+ /** Default {@link DEFAULT_OWNER_GENESIS_TIMEOUT_MS}. */
152
+ timeoutMs?: number;
153
+ }
154
+
155
+ /** What {@link wireJoinedOwnerKey} takes. */
156
+ export interface JoinedOwnerKeyOptions {
157
+ /** The key store whose owner slot holds this app's owner key, if it holds one. */
158
+ keyStore: KeyStore;
159
+ /** Default {@link DEFAULT_OWNER_GENESIS_TIMEOUT_MS}. */
160
+ timeoutMs?: number;
161
+ /** An owner grant has just seated the slot's key: keep asking until the row arrives. */
162
+ granted?: boolean;
163
+ }
164
+
165
+ /** How often {@link wireJoinedOwnerKey} asks again for an owner key an owner grant just seated. */
166
+ const GRANTED_OWNER_RECHECK_MS = 2_000;
167
+
168
+ /**
169
+ * The owner step of a party this app founded. On a party with no `OwnerKey` row yet, the owner
170
+ * slot's key (created there now unless the slot already holds one) becomes the founding owner
171
+ * under `label` and is wired for signing, which also lets the node author its own `CadrePeer` row.
172
+ * On a party that has owners, the slot's key is wired only when it is a live owner, and no key is
173
+ * created: a new one could not be seated without another owner's signature. A node must be an
174
+ * enrolled owner to publish strands, and `CadreNode.requestJoin` works only on one. A joined
175
+ * party's start runs {@link wireJoinedOwnerKey} instead.
176
+ *
177
+ * Fail-soft and bounded: a failure or a genesis that has not settled within `timeoutMs` is logged
178
+ * and returned, and the start stands — the app can still join discovered strands and sync. A
179
+ * genesis that times out keeps running; its later failure is logged.
180
+ *
181
+ * NOTE: the bound is here, in cadre-core, so every edge node gets it; a Node host runs genesis
182
+ * through the CLI's own flow instead. Since v1.10 a control write against an unresponsive member
183
+ * can hold the write lock for about 84 s, so an unbounded wait here would be a real stall. A bound
184
+ * on that write itself, in `ControlDatabase`, would make this one redundant; drop `timeoutMs` then.
185
+ */
186
+ export function runOwnerGenesis(node: CadreNode, options: OwnerGenesisOptions): Promise<OwnerGenesisResult> {
187
+ const label = options.label ?? DEFAULT_OWNER_KEY_LABEL;
188
+ return withinBound('owner self-genesis', enrollFoundedOwner(node, options.keyStore, label), options.timeoutMs ?? DEFAULT_OWNER_GENESIS_TIMEOUT_MS);
189
+ }
190
+
191
+ /**
192
+ * The owner step of a party this app joined: wire a key only when the party lists it as a live
193
+ * owner, never insert an `OwnerKey` row, so a joined app can neither seat itself as a founder nor
194
+ * anchor a key that is no owner's. Only the owner slot's key is tried: the key an owner grant
195
+ * seated, one imported here, or one an owner of the party added. The identity key is never
196
+ * tried, since a machine's network identity is never an owner key. Fail-soft and bounded as
197
+ * {@link runOwnerGenesis} is.
198
+ *
199
+ * `granted`: an owner grant has just made this app an owner, so a key not listed yet means the
200
+ * row has not reached this node, not that it is no owner. It is asked again every 2 s until it
201
+ * is wired or the bound passes; a start that timed out wires it on the next start.
202
+ */
203
+ export function wireJoinedOwnerKey(node: CadreNode, options: JoinedOwnerKeyOptions): Promise<OwnerGenesisResult> {
204
+ let expired = false;
205
+ const wiring = async (): Promise<OwnerGenesisResult> => {
206
+ const held = await loadOwnerSigner(options.keyStore);
207
+ while (!(await wireJoinedOwner(node, held))) {
208
+ if (!options.granted) return 'member';
209
+ await new Promise((resolve) => setTimeout(resolve, GRANTED_OWNER_RECHECK_MS));
210
+ // Past the bound the start has gone on as `'timed-out'`, and may have stopped the node.
211
+ if (expired) return 'member';
212
+ }
213
+ return 'enrolled';
214
+ };
215
+ return withinBound('wiring the joined owner key', wiring(), options.timeoutMs ?? DEFAULT_OWNER_GENESIS_TIMEOUT_MS)
216
+ .finally(() => {
217
+ expired = true;
218
+ });
219
+ }
220
+
221
+ /** `settling`, or `'failed'` when it rejects (logged), or `'timed-out'` when it has not settled within `timeoutMs` (logged). */
222
+ async function withinBound(
223
+ label: string,
224
+ settling: Promise<OwnerGenesisResult>,
225
+ timeoutMs: number,
226
+ ): Promise<OwnerGenesisResult> {
227
+ const settled = settling.catch((err: unknown): OwnerGenesisResult => {
228
+ console.warn(`[cadre-core/edge-node] ${label} failed:`, err);
229
+ return 'failed';
230
+ });
231
+ let timer: ReturnType<typeof setTimeout> | undefined;
232
+ const deadline = new Promise<OwnerGenesisResult>((resolve) => {
233
+ timer = setTimeout(() => resolve('timed-out'), timeoutMs);
234
+ });
235
+ try {
236
+ const result = await Promise.race([settled, deadline]);
237
+ if (result === 'timed-out') {
238
+ console.warn(`[cadre-core/edge-node] ${label} has not settled after ${timeoutMs} ms; starting without it`);
239
+ }
240
+ return result;
241
+ } finally {
242
+ clearTimeout(timer);
243
+ }
244
+ }
245
+
246
+ async function enrollFoundedOwner(node: CadreNode, keyStore: KeyStore, label: string): Promise<OwnerGenesisResult> {
247
+ const controlDb = node.getControlDatabase();
248
+ if (!controlDb) {
249
+ throw new Error('control database unavailable after start; cannot run owner genesis');
250
+ }
251
+ const held = await loadOwnerSigner(keyStore);
252
+ if (await controlDb.hasOwnerKey()) {
253
+ return held ? wiredIfOwner(node, held) : 'member';
254
+ }
255
+ // Loaded rather than created when the slot already holds a key: one imported before founding,
256
+ // or one an iOS reinstall kept in the Keychain while the app's data went.
257
+ const founding = held ?? (await createOwnerKey(keyStore));
258
+ if (!(await controlDb.ensureOwnerKey(founding.publicKey, label))) {
259
+ // A row arrived between the two reads: the party has an owner now, so judge the key as above.
260
+ return wiredIfOwner(node, founding);
261
+ }
262
+ await node.useOwnerSigner(founding);
263
+ return 'enrolled';
264
+ }
265
+
266
+ async function wiredIfOwner(node: CadreNode, signer: OwnerSigner): Promise<OwnerGenesisResult> {
267
+ return (await node.wireOwnKeyIfOwner(signer)) ? 'enrolled' : 'member';
268
+ }
269
+
270
+ /**
271
+ * Wire `held`, the owner slot's key, when the party lists it. A node takes one owner signer, so
272
+ * a signer already wired is asked about instead (is it still a live owner?) and never replaced.
273
+ */
274
+ async function wireJoinedOwner(node: CadreNode, held: OwnerSigner | undefined): Promise<boolean> {
275
+ if (node.getOwnerSigner() !== null) return await node.wireOwnKeyIfOwner();
276
+ return held !== undefined && (await node.wireOwnKeyIfOwner(held));
277
+ }