@serfab/cadre-core 1.13.0 → 1.15.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 (230) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +383 -0
  3. package/dist/cadre-invite-protocol.js +774 -0
  4. package/dist/cadre-invite-protocol.js.map +1 -0
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +849 -210
  9. package/dist/cadre-node.js +1574 -374
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/claim-proof.d.ts +45 -0
  12. package/dist/claim-proof.js +132 -0
  13. package/dist/claim-proof.js.map +1 -0
  14. package/dist/control-authorization.d.ts +80 -19
  15. package/dist/control-authorization.js +55 -13
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.js +1 -1
  18. package/dist/control-cohort.js.map +1 -1
  19. package/dist/control-database.d.ts +605 -108
  20. package/dist/control-database.js +1299 -280
  21. package/dist/control-database.js.map +1 -1
  22. package/dist/control-protocol-guard.d.ts +142 -0
  23. package/dist/control-protocol-guard.js +293 -0
  24. package/dist/control-protocol-guard.js.map +1 -0
  25. package/dist/control-schema.d.ts +1 -1
  26. package/dist/control-schema.js +817 -1067
  27. package/dist/control-schema.js.map +1 -1
  28. package/dist/control-stream.d.ts +4 -0
  29. package/dist/control-stream.js +22 -7
  30. package/dist/control-stream.js.map +1 -1
  31. package/dist/delegate-admission.d.ts +1 -1
  32. package/dist/delegate-admission.js +1 -1
  33. package/dist/device-token.d.ts +11 -9
  34. package/dist/device-token.js +14 -12
  35. package/dist/device-token.js.map +1 -1
  36. package/dist/ed25519-key.d.ts +22 -9
  37. package/dist/ed25519-key.js +47 -10
  38. package/dist/ed25519-key.js.map +1 -1
  39. package/dist/fetch-impl.d.ts +12 -0
  40. package/dist/fetch-impl.js +23 -0
  41. package/dist/fetch-impl.js.map +1 -0
  42. package/dist/file-durable-slot.js +2 -1
  43. package/dist/file-durable-slot.js.map +1 -1
  44. package/dist/file-safe-name.d.ts +25 -0
  45. package/dist/file-safe-name.js +29 -0
  46. package/dist/file-safe-name.js.map +1 -0
  47. package/dist/formation-approval.js +5 -11
  48. package/dist/formation-approval.js.map +1 -1
  49. package/dist/fs-atomic.d.ts +0 -19
  50. package/dist/fs-atomic.js +3 -24
  51. package/dist/fs-atomic.js.map +1 -1
  52. package/dist/index.d.ts +22 -10
  53. package/dist/index.js +54 -11
  54. package/dist/index.js.map +1 -1
  55. package/dist/invitation-bootstrap.d.ts +50 -12
  56. package/dist/invitation-bootstrap.js +76 -13
  57. package/dist/invitation-bootstrap.js.map +1 -1
  58. package/dist/joined-strand-store.d.ts +7 -0
  59. package/dist/joined-strand-store.js +9 -0
  60. package/dist/joined-strand-store.js.map +1 -1
  61. package/dist/key-store-file.js +2 -1
  62. package/dist/key-store-file.js.map +1 -1
  63. package/dist/link-budget.d.ts +25 -33
  64. package/dist/link-budget.js +25 -35
  65. package/dist/link-budget.js.map +1 -1
  66. package/dist/machine-removal.d.ts +78 -0
  67. package/dist/machine-removal.js +166 -0
  68. package/dist/machine-removal.js.map +1 -0
  69. package/dist/membership-connection-gater.d.ts +191 -151
  70. package/dist/membership-connection-gater.js +236 -173
  71. package/dist/membership-connection-gater.js.map +1 -1
  72. package/dist/node-claim-payload.d.ts +74 -0
  73. package/dist/node-claim-payload.js +224 -0
  74. package/dist/node-claim-payload.js.map +1 -0
  75. package/dist/node-default-transports.d.ts +27 -0
  76. package/dist/node-default-transports.js +35 -0
  77. package/dist/node-default-transports.js.map +1 -0
  78. package/dist/node-transports.d.ts +30 -0
  79. package/dist/node-transports.js +40 -0
  80. package/dist/node-transports.js.map +1 -0
  81. package/dist/owner-anchor-sync.d.ts +22 -0
  82. package/dist/owner-anchor-sync.js +143 -0
  83. package/dist/owner-anchor-sync.js.map +1 -0
  84. package/dist/peer-authorization.d.ts +113 -18
  85. package/dist/peer-authorization.js +225 -29
  86. package/dist/peer-authorization.js.map +1 -1
  87. package/dist/peer-dial.d.ts +143 -27
  88. package/dist/peer-dial.js +276 -31
  89. package/dist/peer-dial.js.map +1 -1
  90. package/dist/peer-join-backfill.d.ts +26 -2
  91. package/dist/peer-join-backfill.js +60 -12
  92. package/dist/peer-join-backfill.js.map +1 -1
  93. package/dist/peer-record.d.ts +17 -52
  94. package/dist/peer-record.js +20 -58
  95. package/dist/peer-record.js.map +1 -1
  96. package/dist/pending-join-runner.d.ts +66 -58
  97. package/dist/pending-join-runner.js +136 -145
  98. package/dist/pending-join-runner.js.map +1 -1
  99. package/dist/primary-lan-address.d.ts +8 -0
  100. package/dist/primary-lan-address.js +46 -0
  101. package/dist/primary-lan-address.js.map +1 -0
  102. package/dist/provider/client.d.ts +131 -0
  103. package/dist/provider/client.js +226 -0
  104. package/dist/provider/client.js.map +1 -0
  105. package/dist/provider/descriptor.d.ts +41 -0
  106. package/dist/provider/descriptor.js +103 -0
  107. package/dist/provider/descriptor.js.map +1 -0
  108. package/dist/provider/index.d.ts +9 -0
  109. package/dist/provider/index.js +10 -0
  110. package/dist/provider/index.js.map +1 -0
  111. package/dist/provider/oauth.d.ts +125 -0
  112. package/dist/provider/oauth.js +199 -0
  113. package/dist/provider/oauth.js.map +1 -0
  114. package/dist/provider/rent-node.d.ts +101 -0
  115. package/dist/provider/rent-node.js +220 -0
  116. package/dist/provider/rent-node.js.map +1 -0
  117. package/dist/push-fanout.d.ts +8 -2
  118. package/dist/push-fanout.js +8 -2
  119. package/dist/push-fanout.js.map +1 -1
  120. package/dist/relay-addrs.d.ts +9 -4
  121. package/dist/relay-addrs.js +9 -4
  122. package/dist/relay-addrs.js.map +1 -1
  123. package/dist/relay-reservation.d.ts +51 -16
  124. package/dist/relay-reservation.js +107 -24
  125. package/dist/relay-reservation.js.map +1 -1
  126. package/dist/seed-bootstrap.d.ts +165 -123
  127. package/dist/seed-bootstrap.js +313 -275
  128. package/dist/seed-bootstrap.js.map +1 -1
  129. package/dist/seed-trust-policy.d.ts +98 -37
  130. package/dist/seed-trust-policy.js +188 -2
  131. package/dist/seed-trust-policy.js.map +1 -1
  132. package/dist/strand-addr-protocol.d.ts +24 -26
  133. package/dist/strand-addr-protocol.js +19 -26
  134. package/dist/strand-addr-protocol.js.map +1 -1
  135. package/dist/strand-commit-durability.d.ts +54 -0
  136. package/dist/strand-commit-durability.js +74 -0
  137. package/dist/strand-commit-durability.js.map +1 -0
  138. package/dist/strand-database.d.ts +23 -8
  139. package/dist/strand-database.js +41 -13
  140. package/dist/strand-database.js.map +1 -1
  141. package/dist/strand-first-sync-gate.d.ts +7 -1
  142. package/dist/strand-first-sync-gate.js +7 -1
  143. package/dist/strand-first-sync-gate.js.map +1 -1
  144. package/dist/strand-formation-protocol.js +5 -2
  145. package/dist/strand-formation-protocol.js.map +1 -1
  146. package/dist/strand-fret-addrs.d.ts +19 -1
  147. package/dist/strand-fret-addrs.js +27 -9
  148. package/dist/strand-fret-addrs.js.map +1 -1
  149. package/dist/strand-instance-manager.d.ts +81 -19
  150. package/dist/strand-instance-manager.js +216 -105
  151. package/dist/strand-instance-manager.js.map +1 -1
  152. package/dist/strand-move.d.ts +124 -0
  153. package/dist/strand-move.js +438 -0
  154. package/dist/strand-move.js.map +1 -0
  155. package/dist/strand-network-config.js +7 -6
  156. package/dist/strand-network-config.js.map +1 -1
  157. package/dist/strand-peer-redial.d.ts +113 -0
  158. package/dist/strand-peer-redial.js +236 -0
  159. package/dist/strand-peer-redial.js.map +1 -0
  160. package/dist/strand-revocation-enforcer.d.ts +11 -11
  161. package/dist/strand-revocation-enforcer.js +11 -11
  162. package/dist/strand-wake-protocol.d.ts +13 -16
  163. package/dist/strand-wake-protocol.js +23 -27
  164. package/dist/strand-wake-protocol.js.map +1 -1
  165. package/dist/trusted-owner-store.d.ts +35 -5
  166. package/dist/trusted-owner-store.js +50 -8
  167. package/dist/trusted-owner-store.js.map +1 -1
  168. package/dist/types.d.ts +512 -89
  169. package/dist/types.js +10 -0
  170. package/dist/types.js.map +1 -1
  171. package/package.json +19 -10
  172. package/src/cadre-invite-protocol.ts +1052 -0
  173. package/src/cadre-join-request.ts +167 -0
  174. package/src/cadre-node.ts +1703 -386
  175. package/src/claim-proof.ts +147 -0
  176. package/src/control-authorization.ts +203 -117
  177. package/src/control-cohort.ts +1 -1
  178. package/src/control-database.ts +4663 -3453
  179. package/src/control-protocol-guard.ts +348 -0
  180. package/src/control-schema.ts +828 -1078
  181. package/src/control-stream.ts +26 -7
  182. package/src/delegate-admission.ts +1 -1
  183. package/src/device-token.ts +14 -10
  184. package/src/ed25519-key.ts +47 -10
  185. package/src/fetch-impl.ts +23 -0
  186. package/src/file-durable-slot.ts +64 -63
  187. package/src/file-safe-name.ts +32 -0
  188. package/src/formation-approval.ts +604 -610
  189. package/src/fs-atomic.ts +92 -117
  190. package/src/index.ts +789 -648
  191. package/src/invitation-bootstrap.ts +91 -13
  192. package/src/joined-strand-store.ts +508 -498
  193. package/src/key-store-file.ts +142 -141
  194. package/src/link-budget.ts +25 -36
  195. package/src/machine-removal.ts +216 -0
  196. package/src/membership-connection-gater.ts +259 -191
  197. package/src/node-claim-payload.ts +259 -0
  198. package/src/node-default-transports.ts +38 -0
  199. package/src/node-transports.ts +49 -0
  200. package/src/owner-anchor-sync.ts +171 -0
  201. package/src/peer-authorization.ts +258 -29
  202. package/src/peer-dial.ts +604 -315
  203. package/src/peer-join-backfill.ts +62 -11
  204. package/src/peer-record.ts +159 -197
  205. package/src/pending-join-runner.ts +729 -735
  206. package/src/primary-lan-address.ts +47 -0
  207. package/src/provider/client.ts +326 -0
  208. package/src/provider/descriptor.ts +129 -0
  209. package/src/provider/index.ts +59 -0
  210. package/src/provider/oauth.ts +285 -0
  211. package/src/provider/rent-node.ts +275 -0
  212. package/src/push-fanout.ts +8 -2
  213. package/src/relay-addrs.ts +9 -4
  214. package/src/relay-reservation.ts +1211 -1121
  215. package/src/seed-bootstrap.ts +1502 -1448
  216. package/src/seed-trust-policy.ts +353 -140
  217. package/src/strand-addr-protocol.ts +26 -35
  218. package/src/strand-commit-durability.ts +116 -0
  219. package/src/strand-database.ts +46 -14
  220. package/src/strand-first-sync-gate.ts +7 -1
  221. package/src/strand-formation-protocol.ts +1039 -1036
  222. package/src/strand-fret-addrs.ts +179 -145
  223. package/src/strand-instance-manager.ts +2026 -1883
  224. package/src/strand-move.ts +585 -0
  225. package/src/strand-network-config.ts +7 -6
  226. package/src/strand-peer-redial.ts +307 -0
  227. package/src/strand-revocation-enforcer.ts +11 -11
  228. package/src/strand-wake-protocol.ts +23 -30
  229. package/src/trusted-owner-store.ts +242 -171
  230. package/src/types.ts +2687 -2242
@@ -1,141 +1,142 @@
1
- /**
2
- * Node-only file-backed {@link KeyStore}: one file per slot under a configured
3
- * directory, holding the raw key material bytes.
4
- *
5
- * This module imports `node:fs/promises`, `node:path`, and `node:crypto`, so it
6
- * is deliberately kept OUT of the package's cross-platform default entry
7
- * (`./index.js`). Import it from the dedicated subpath instead:
8
- *
9
- * ```ts
10
- * import { FileKeyStore } from '@serfab/cadre-core/key-store-file';
11
- * ```
12
- *
13
- * A React Native / browser entry graph never resolves this path, so the
14
- * `node:fs` edge never reaches a bundler that cannot satisfy it.
15
- */
16
- import debug from 'debug';
17
- import { readFile, rm, readdir } from 'node:fs/promises';
18
- import { join } from 'node:path';
19
- import { randomBytes } from 'node:crypto';
20
- import type { KeyId, KeyStore } from './key-store.js';
21
- import { KeyStoreAccessError } from './key-store.js';
22
- import { encodeFileSafeComponent, isNotFound, writeFileAtomically } from './fs-atomic.js';
23
-
24
- const log = debug('sereus:cadre:key-store-file');
25
-
26
- /** Suffix appended to the encoded keyId to form a slot filename. */
27
- const SLOT_SUFFIX = '.key';
28
-
29
- /**
30
- * Suffix for the transient file written then atomically renamed over the slot.
31
- * Distinct from {@link SLOT_SUFFIX}, so {@link FileKeyStore.list} (which matches
32
- * only `.key`) never surfaces an in-flight or crash-orphaned temp file.
33
- */
34
- const TEMP_SUFFIX = '.tmp';
35
-
36
- /**
37
- * Percent-encode a keyId into a filesystem-safe filename component (see
38
- * {@link encodeFileSafeComponent} — `.` is escaped so the `.key` suffix is the
39
- * only literal dot, making suffix-stripping in {@link decodeKeyId} unambiguous;
40
- * {@link decodeKeyId} reverses every escape via `decodeURIComponent`).
41
- */
42
- function encodeKeyId(keyId: KeyId): string {
43
- return encodeFileSafeComponent(keyId);
44
- }
45
-
46
- /**
47
- * Reverse {@link encodeKeyId}: strip the slot suffix and percent-decode. Returns
48
- * `undefined` for a `.key` filename this store did not write, so
49
- * {@link FileKeyStore.list} can skip it rather than letting one undecodable
50
- * entry throw and break enumeration of every real slot. That includes a name
51
- * that decodes but is not the one {@link encodeKeyId} produces for the result
52
- * (e.g. a slot written before uppercase letters were escaped):
53
- * {@link FileKeyStore.get} looks only at the canonical name, so listing it
54
- * would name a key id that reads back as absent.
55
- */
56
- function decodeKeyId(fileName: string): KeyId | undefined {
57
- const encoded = fileName.slice(0, -SLOT_SUFFIX.length);
58
- let keyId: KeyId;
59
- try {
60
- keyId = decodeURIComponent(encoded);
61
- } catch (error) {
62
- log('FileKeyStore: skipping undecodable slot filename %s: %o', fileName, error);
63
- return undefined;
64
- }
65
- if (encodeKeyId(keyId) !== encoded) {
66
- log('FileKeyStore: skipping non-canonical slot filename %s', fileName);
67
- return undefined;
68
- }
69
- return keyId;
70
- }
71
-
72
- /**
73
- * File-backed {@link KeyStore}. Each slot is `<dir>/<encoded keyId>.key`
74
- * containing the raw material bytes. The directory is created lazily on first
75
- * {@link set}. Suitable for headless Node cadre nodes and tests; for mobile use
76
- * a platform secure-enclave backend instead.
77
- */
78
- export class FileKeyStore implements KeyStore {
79
- constructor(private readonly dir: string) {}
80
-
81
- private slotPath(keyId: KeyId): string {
82
- return join(this.dir, `${encodeKeyId(keyId)}${SLOT_SUFFIX}`);
83
- }
84
-
85
- /**
86
- * Sibling path for the temp file written before the atomic rename. The random
87
- * component makes concurrent {@link set}s to the same slot (and crash-orphaned
88
- * leftovers) collide-free; it lives in the same directory as the slot so the
89
- * final {@link rename} stays within one filesystem and is therefore atomic.
90
- */
91
- private tempPath(keyId: KeyId): string {
92
- const unique = randomBytes(6).toString('hex');
93
- return join(this.dir, `${encodeKeyId(keyId)}.${unique}${TEMP_SUFFIX}`);
94
- }
95
-
96
- async get(keyId: KeyId): Promise<Uint8Array | undefined> {
97
- try {
98
- const buf = await readFile(this.slotPath(keyId));
99
- // Copy into a plain Uint8Array decoupled from Node's Buffer pool.
100
- return new Uint8Array(buf);
101
- } catch (error) {
102
- // A missing slot is "empty" (undefined), NOT an error — so a
103
- // load-or-create caller generates a key rather than mistaking a
104
- // permission failure for absence. Any other failure (e.g. EACCES) is
105
- // surfaced as access-denied so callers do not clobber an existing key.
106
- if (isNotFound(error)) return undefined;
107
- throw new KeyStoreAccessError(keyId, `FileKeyStore: failed to read key slot ${keyId}`, { cause: error });
108
- }
109
- }
110
-
111
- /**
112
- * Crash-atomic write: a concurrent reader sees either the complete previous
113
- * bytes or the complete new bytes, never a torn slot. The new material is
114
- * written to a sibling temp file, fsync'd, then atomically renamed over the
115
- * slot (see {@link writeFileAtomically}). A failure at any point removes the
116
- * temp file and leaves the previous slot untouched.
117
- */
118
- async set(keyId: KeyId, keyMaterial: Uint8Array): Promise<void> {
119
- await writeFileAtomically(this.dir, this.tempPath(keyId), this.slotPath(keyId), keyMaterial);
120
- }
121
-
122
- async delete(keyId: KeyId): Promise<void> {
123
- // force:true makes removing an absent slot a no-op (idempotent).
124
- await rm(this.slotPath(keyId), { force: true });
125
- }
126
-
127
- async list(): Promise<KeyId[]> {
128
- let entries: string[];
129
- try {
130
- entries = await readdir(this.dir);
131
- } catch (error) {
132
- // An absent directory means no slots yet — not a failure.
133
- if (isNotFound(error)) return [];
134
- throw new Error('FileKeyStore: failed to list key directory', { cause: error });
135
- }
136
- return entries
137
- .filter((name) => name.endsWith(SLOT_SUFFIX))
138
- .map(decodeKeyId)
139
- .filter((id): id is KeyId => id !== undefined);
140
- }
141
- }
1
+ /**
2
+ * Node-only file-backed {@link KeyStore}: one file per slot under a configured
3
+ * directory, holding the raw key material bytes.
4
+ *
5
+ * This module imports `node:fs/promises`, `node:path`, and `node:crypto`, so it
6
+ * is deliberately kept OUT of the package's cross-platform default entry
7
+ * (`./index.js`). Import it from the dedicated subpath instead:
8
+ *
9
+ * ```ts
10
+ * import { FileKeyStore } from '@serfab/cadre-core/key-store-file';
11
+ * ```
12
+ *
13
+ * A React Native / browser entry graph never resolves this path, so the
14
+ * `node:fs` edge never reaches a bundler that cannot satisfy it.
15
+ */
16
+ import debug from 'debug';
17
+ import { readFile, rm, readdir } from 'node:fs/promises';
18
+ import { join } from 'node:path';
19
+ import { randomBytes } from 'node:crypto';
20
+ import type { KeyId, KeyStore } from './key-store.js';
21
+ import { KeyStoreAccessError } from './key-store.js';
22
+ import { isNotFound, writeFileAtomically } from './fs-atomic.js';
23
+ import { encodeFileSafeComponent } from './file-safe-name.js';
24
+
25
+ const log = debug('sereus:cadre:key-store-file');
26
+
27
+ /** Suffix appended to the encoded keyId to form a slot filename. */
28
+ const SLOT_SUFFIX = '.key';
29
+
30
+ /**
31
+ * Suffix for the transient file written then atomically renamed over the slot.
32
+ * Distinct from {@link SLOT_SUFFIX}, so {@link FileKeyStore.list} (which matches
33
+ * only `.key`) never surfaces an in-flight or crash-orphaned temp file.
34
+ */
35
+ const TEMP_SUFFIX = '.tmp';
36
+
37
+ /**
38
+ * Percent-encode a keyId into a filesystem-safe filename component (see
39
+ * {@link encodeFileSafeComponent} — `.` is escaped so the `.key` suffix is the
40
+ * only literal dot, making suffix-stripping in {@link decodeKeyId} unambiguous;
41
+ * {@link decodeKeyId} reverses every escape via `decodeURIComponent`).
42
+ */
43
+ function encodeKeyId(keyId: KeyId): string {
44
+ return encodeFileSafeComponent(keyId);
45
+ }
46
+
47
+ /**
48
+ * Reverse {@link encodeKeyId}: strip the slot suffix and percent-decode. Returns
49
+ * `undefined` for a `.key` filename this store did not write, so
50
+ * {@link FileKeyStore.list} can skip it rather than letting one undecodable
51
+ * entry throw and break enumeration of every real slot. That includes a name
52
+ * that decodes but is not the one {@link encodeKeyId} produces for the result
53
+ * (e.g. a slot written before uppercase letters were escaped):
54
+ * {@link FileKeyStore.get} looks only at the canonical name, so listing it
55
+ * would name a key id that reads back as absent.
56
+ */
57
+ function decodeKeyId(fileName: string): KeyId | undefined {
58
+ const encoded = fileName.slice(0, -SLOT_SUFFIX.length);
59
+ let keyId: KeyId;
60
+ try {
61
+ keyId = decodeURIComponent(encoded);
62
+ } catch (error) {
63
+ log('FileKeyStore: skipping undecodable slot filename %s: %o', fileName, error);
64
+ return undefined;
65
+ }
66
+ if (encodeKeyId(keyId) !== encoded) {
67
+ log('FileKeyStore: skipping non-canonical slot filename %s', fileName);
68
+ return undefined;
69
+ }
70
+ return keyId;
71
+ }
72
+
73
+ /**
74
+ * File-backed {@link KeyStore}. Each slot is `<dir>/<encoded keyId>.key`
75
+ * containing the raw material bytes. The directory is created lazily on first
76
+ * {@link set}. Suitable for headless Node cadre nodes and tests; for mobile use
77
+ * a platform secure-enclave backend instead.
78
+ */
79
+ export class FileKeyStore implements KeyStore {
80
+ constructor(private readonly dir: string) {}
81
+
82
+ private slotPath(keyId: KeyId): string {
83
+ return join(this.dir, `${encodeKeyId(keyId)}${SLOT_SUFFIX}`);
84
+ }
85
+
86
+ /**
87
+ * Sibling path for the temp file written before the atomic rename. The random
88
+ * component makes concurrent {@link set}s to the same slot (and crash-orphaned
89
+ * leftovers) collide-free; it lives in the same directory as the slot so the
90
+ * final {@link rename} stays within one filesystem and is therefore atomic.
91
+ */
92
+ private tempPath(keyId: KeyId): string {
93
+ const unique = randomBytes(6).toString('hex');
94
+ return join(this.dir, `${encodeKeyId(keyId)}.${unique}${TEMP_SUFFIX}`);
95
+ }
96
+
97
+ async get(keyId: KeyId): Promise<Uint8Array | undefined> {
98
+ try {
99
+ const buf = await readFile(this.slotPath(keyId));
100
+ // Copy into a plain Uint8Array decoupled from Node's Buffer pool.
101
+ return new Uint8Array(buf);
102
+ } catch (error) {
103
+ // A missing slot is "empty" (undefined), NOT an error — so a
104
+ // load-or-create caller generates a key rather than mistaking a
105
+ // permission failure for absence. Any other failure (e.g. EACCES) is
106
+ // surfaced as access-denied so callers do not clobber an existing key.
107
+ if (isNotFound(error)) return undefined;
108
+ throw new KeyStoreAccessError(keyId, `FileKeyStore: failed to read key slot ${keyId}`, { cause: error });
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Crash-atomic write: a concurrent reader sees either the complete previous
114
+ * bytes or the complete new bytes, never a torn slot. The new material is
115
+ * written to a sibling temp file, fsync'd, then atomically renamed over the
116
+ * slot (see {@link writeFileAtomically}). A failure at any point removes the
117
+ * temp file and leaves the previous slot untouched.
118
+ */
119
+ async set(keyId: KeyId, keyMaterial: Uint8Array): Promise<void> {
120
+ await writeFileAtomically(this.dir, this.tempPath(keyId), this.slotPath(keyId), keyMaterial);
121
+ }
122
+
123
+ async delete(keyId: KeyId): Promise<void> {
124
+ // force:true makes removing an absent slot a no-op (idempotent).
125
+ await rm(this.slotPath(keyId), { force: true });
126
+ }
127
+
128
+ async list(): Promise<KeyId[]> {
129
+ let entries: string[];
130
+ try {
131
+ entries = await readdir(this.dir);
132
+ } catch (error) {
133
+ // An absent directory means no slots yet — not a failure.
134
+ if (isNotFound(error)) return [];
135
+ throw new Error('FileKeyStore: failed to list key directory', { cause: error });
136
+ }
137
+ return entries
138
+ .filter((name) => name.endsWith(SLOT_SUFFIX))
139
+ .map(decodeKeyId)
140
+ .filter((id): id is KeyId => id !== undefined);
141
+ }
142
+ }
@@ -73,9 +73,7 @@
73
73
  * Every row that opens a relayed connection counts opening the relay connection too
74
74
  * ({@link RELAYED_DIAL_ROUND_TRIPS}), because a dialer cannot know beforehand whether it holds
75
75
  * one, and a budget that is too short fails as silently as an absent peer. The
76
- * reservation-request row is {@link RELAY_RESERVE_REQUEST_ROUND_TRIPS}, which also sizes how long
77
- * a party-run relay waits for a stranger's reservation ({@link relayAdmissionReserveDeadlineMs}).
78
- * The commit row is a measurement of a whole write, not a count of exchanges; its doc comment
76
+ * reservation-request row is {@link RELAY_RESERVE_REQUEST_ROUND_TRIPS}. The commit row is a measurement of a whole write, not a count of exchanges; its doc comment
79
77
  * ({@link COMMIT_ROUND_TRIPS}) holds the figures and says when to re-measure it.
80
78
  *
81
79
  * **Measured** 2026-09-26, one Windows machine, loopback dedicated relay: a relayed dial over a
@@ -198,10 +196,10 @@
198
196
  * the host derives its provisioning budget from its own, so a joiner declaring a faster
199
197
  * link than the host gives up on a reply the host is still entitled to send. Declare the
200
198
  * same link on every machine of a party.
201
- * Relay admission too: a party-run relay derives how long a stranger has to ask for a
202
- * reservation from ITS declaration ({@link relayAdmissionReserveDeadlineMs}), so a relay
203
- * declaring a faster link than a client closes that client's connection before its request
204
- * arrives.
199
+ * Provisional admission too: a member derives how long it keeps a connection from a peer it
200
+ * cannot place from ITS declaration ({@link relayedRequestBudgetMs}), so a member declaring a
201
+ * faster link than a redeeming device can close that device's connection while the device is
202
+ * still waiting on its redemption.
205
203
  */
206
204
 
207
205
  import { resolveLinkDeadlines, type Libp2pConnectionTimeouts, type RpcDeadlineDefaults } from '@optimystic/db-p2p';
@@ -261,20 +259,19 @@ export const RELAY_DIAL_ROUND_TRIPS = 1;
261
259
  export const RELAYED_DIAL_ROUND_TRIPS = RELAY_DIAL_ROUND_TRIPS + CIRCUIT_DIAL_ROUND_TRIPS;
262
260
 
263
261
  /**
264
- * Link round trips from the moment a party-run relay admits a stranger's connection for relay
265
- * use to the moment that stranger's RESERVE request reaches it. libp2p's reservation store
266
- * (`@libp2p/circuit-relay-v2`'s `addRelay`) opens the hop stream the instant its dial resolves,
267
- * without waiting for identify, so:
262
+ * Link round trips from the moment a relay accepts a client's connection to the moment that
263
+ * client's RESERVE request reaches it. libp2p's reservation store (`@libp2p/circuit-relay-v2`'s
264
+ * `addRelay`) opens the hop stream the instant its dial resolves, without waiting for identify,
265
+ * so:
268
266
  *
269
267
  * 1. The client's last Noise handshake message and its hop-stream open arrive at the relay
270
- * together. The relay's gate runs on the handshake message (and arms the reserve deadline),
271
- * then the relay answers the protocol negotiation.
268
+ * together. A party-run relay's gate runs on the handshake message, then the relay answers
269
+ * the protocol negotiation.
272
270
  * 2. The answer reaches the client and the client's RESERVE request reaches the relay: one round
273
271
  * trip on the client-to-relay hop, which is the whole link round trip when all of the link's
274
272
  * delay is on the client's own hop (a phone on a congested mobile link).
275
273
  *
276
- * The relay's decision on that request is local time, not link time;
277
- * {@link relayAdmissionReserveDeadlineMs} adds it separately.
274
+ * The relay's decision on that request is local time, not link time.
278
275
  */
279
276
  export const RELAY_RESERVE_REQUEST_ROUND_TRIPS = 1;
280
277
 
@@ -285,10 +282,15 @@ export const RELAY_RESERVE_REQUEST_ROUND_TRIPS = 1;
285
282
  *
286
283
  * The protocol work measured is 2 — dial the relay ({@link RELAY_DIAL_ROUND_TRIPS}) plus request
287
284
  * the reservation ({@link RELAY_RESERVE_REQUEST_ROUND_TRIPS}). It is budgeted at twice that,
288
- * because the wait afterwards may instead be satisfied by libp2p's own relay discovery, which
289
- * repeats both legs after identify has run; 4 is therefore the largest thing this one deadline
290
- * can be asked to contain, and the poll interval (`DEFAULT_RELAY_RESERVE_POLL_MS`) rounds up on
291
- * top of it.
285
+ * because on a node that keeps libp2p's own relay discovery (a browser tab driving a bare
286
+ * `/p2p-circuit` through `reserveRelays`) the wait afterwards may instead be satisfied by
287
+ * discovery, which repeats both legs after identify has run; 4 is therefore the largest thing
288
+ * this one deadline can be asked to contain, and the poll interval
289
+ * (`DEFAULT_RELAY_RESERVE_POLL_MS`) rounds up on top of it.
290
+ *
291
+ * NOTE: on a node with configured relays discovery is off (`node-transports.ts`), so there the
292
+ * doubling is only headroom, and it is what an unreachable configured relay costs every launch
293
+ * (the whole deadline is polled out). If that launch cost ever matters, budget those nodes at 2.
292
294
  */
293
295
  export const RELAY_RESERVATION_ROUND_TRIPS = 2 * (RELAY_DIAL_ROUND_TRIPS + RELAY_RESERVE_REQUEST_ROUND_TRIPS);
294
296
 
@@ -511,23 +513,6 @@ export function relayReservationBudgetMs(linkRoundTripMs?: number): number {
511
513
  return RELAY_RESERVATION_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs) + 2 * ADMISSION_DECISION_TIMEOUT_MS;
512
514
  }
513
515
 
514
- /**
515
- * How long a party-run relay keeps a connection it admitted only for relay use
516
- * (`membership-connection-gater.ts`'s `'admit-for-relay'`) before closing it, unless a
517
- * reservation is admitted on it first: {@link RELAY_RESERVE_REQUEST_ROUND_TRIPS} at the declared
518
- * link round trip, plus one {@link ADMISSION_DECISION_TIMEOUT_MS} for the relay's own decision on
519
- * the reservation (`denyInboundRelayReservation`), which disarms the timer only once it settles.
520
- * 3 500 + 2 000 = 5 500 ms at the default declaration.
521
- *
522
- * The declaration is the RELAY's own, because the relay is the machine that decides.
523
- *
524
- * What it costs, against the fixed 5 000 ms it replaced: a stranger that connects and never
525
- * reserves holds a mute connection 0.5 s longer at the default declaration.
526
- */
527
- export function relayAdmissionReserveDeadlineMs(linkRoundTripMs?: number): number {
528
- return RELAY_RESERVE_REQUEST_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs) + ADMISSION_DECISION_TIMEOUT_MS;
529
- }
530
-
531
516
  /**
532
517
  * Deadline for one request and its answer over a circuit that is already open —
533
518
  * {@link CIRCUIT_REQUEST_ROUND_TRIPS} at the declared link round trip, plus `transferAllowanceMs`
@@ -549,6 +534,10 @@ export function circuitRequestBudgetMs(
549
534
  *
550
535
  * No transfer allowance, unlike {@link circuitRequestBudgetMs}: the requests this bounds are a few
551
536
  * hundred bytes to a few KB, so their bytes cost nothing a round trip does not already cover.
537
+ *
538
+ * A control node also keeps a connection it admitted provisionally this long, at its own
539
+ * declaration, because a cadre invitation redemption is bounded by this budget on the device's
540
+ * side (`membership-connection-gater.ts`, `PROVISIONAL_ADMISSION_DEADLINE_MS`).
552
541
  */
553
542
  export function relayedRequestBudgetMs(linkRoundTripMs?: number): number {
554
543
  return relayedDialBudgetMs(linkRoundTripMs) + CIRCUIT_REQUEST_ROUND_TRIPS * resolveLinkRoundTripMs(linkRoundTripMs);
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Removing a machine from a cadre: the listing an app shows (`CadreNode.listMachines`), the
3
+ * read-only plan behind its confirmation step (`CadreNode.planMachineRemoval`), and the
4
+ * check that a requested re-vouch stays inside that plan. Pure projections over rows the
5
+ * node has already read; the reads and writes themselves are `CadreNode.removeMachine`'s and
6
+ * `ControlDatabase.removeMachine`'s.
7
+ *
8
+ * A machine's owner key is its own identity key: an owner-granting invitation seats
9
+ * `OwnerKey.Key = CadrePeer.PublicKey`, and `addOwner` takes the key behind a peer id. So
10
+ * "this machine is an owner" is "the ed25519 key behind its peer id is a live `OwnerKey`",
11
+ * and an owner key with no machine row is still listed, as the machine it names.
12
+ */
13
+
14
+ import { ed25519PublicKeyB64FromPeerId, peerIdFromEd25519PublicKeyB64 } from './ed25519-key.js';
15
+ import { verifyPeerRecordSignature } from './peer-record.js';
16
+ import type {
17
+ CadreInviteStatus,
18
+ CadreInviteUsageRow,
19
+ CadreMachine,
20
+ CadrePeerRow,
21
+ MachineRemovalPlan,
22
+ MachineRemovalRefusal,
23
+ OwnerKeyRow,
24
+ RemoveMachineOptions,
25
+ } from './types.js';
26
+
27
+ /**
28
+ * `CadreNode.removeMachine` refused by name, before any write. `code` says which rule
29
+ * (`MachineRemovalRefusal`), for an app to switch on; the message is for a log.
30
+ */
31
+ export class MachineRemovalRefusedError extends Error {
32
+ readonly code: MachineRemovalRefusal;
33
+ readonly peerId: string;
34
+
35
+ constructor(peerId: string, code: MachineRemovalRefusal) {
36
+ super(`Cannot remove machine ${peerId}: ${MACHINE_REMOVAL_REFUSAL_REASON[code]}`);
37
+ this.name = 'MachineRemovalRefusedError';
38
+ this.code = code;
39
+ this.peerId = peerId;
40
+ }
41
+ }
42
+
43
+ const MACHINE_REMOVAL_REFUSAL_REASON: Record<MachineRemovalRefusal, string> = {
44
+ 'not-an-owner': 'this node holds no enrolled owner key',
45
+ 'self': 'an owner cannot remove its own machine; ask another owner',
46
+ 'last-owner': 'it holds the only live owner key',
47
+ };
48
+
49
+ /** What {@link describeMachines} projects from: one node's live rows and its own predicate. */
50
+ export interface MachineListingInput {
51
+ partyId: string;
52
+ selfPeerId: string | null;
53
+ /** Live `CadrePeer` rows (`ControlDatabase.queryCadrePeers`). */
54
+ rows: readonly CadrePeerRow[];
55
+ /** Live `OwnerKey` rows (`ControlDatabase.queryOwnerKeyRows`). */
56
+ owners: readonly OwnerKeyRow[];
57
+ /** This node's authorized-membership predicate for one row (`CadreNode.listAuthorizedMembers`'s). */
58
+ trusted: (row: CadrePeerRow) => boolean;
59
+ }
60
+
61
+ /**
62
+ * One entry per live `CadrePeer` row, then one per live `OwnerKey` whose key derives a peer
63
+ * id with no row. `profile` and `lastPublishedAt` are reported only when the row's
64
+ * self-signature verifies under the key behind its peer id, as `CadreNode.resolvePeerAddrs`
65
+ * judges the same record: no insert rule constrains `Profile`, and an owner re-vouch may
66
+ * bump `UpdatedAt` without re-signing.
67
+ */
68
+ export function describeMachines(input: MachineListingInput): CadreMachine[] {
69
+ const ownerKeys = new Set(input.owners.map((owner) => owner.key));
70
+ const machines = input.rows.map((row) => describeMemberRow(input, row, ownerKeys));
71
+ const listed = new Set(machines.map((machine) => machine.peerId));
72
+ for (const owner of input.owners) {
73
+ const peerId = peerIdFromEd25519PublicKeyB64(owner.key);
74
+ if (peerId === null || listed.has(peerId)) {
75
+ continue;
76
+ }
77
+ listed.add(peerId);
78
+ machines.push({
79
+ peerId,
80
+ publicKey: owner.key,
81
+ isSelf: peerId === input.selfPeerId,
82
+ isOwner: true,
83
+ isMember: false,
84
+ trusted: false,
85
+ profile: null,
86
+ lastPublishedAt: null,
87
+ vouchedBy: owner.vouchOwner,
88
+ });
89
+ }
90
+ return machines;
91
+ }
92
+
93
+ function describeMemberRow(input: MachineListingInput, row: CadrePeerRow, ownerKeys: ReadonlySet<string>): CadreMachine {
94
+ const publicKey = ed25519PublicKeyB64FromPeerId(row.peerId);
95
+ const selfPublished = publicKey !== null && row.publicKey === publicKey && row.sig !== null
96
+ && verifyPeerRecordSignature(input.partyId, {
97
+ peerId: row.peerId,
98
+ publicKey,
99
+ addrs: row.multiaddr ? row.multiaddr.split(',') : [],
100
+ updatedAt: row.updatedAt,
101
+ profile: row.profile,
102
+ sig: row.sig,
103
+ });
104
+ return {
105
+ peerId: row.peerId,
106
+ publicKey,
107
+ isSelf: row.peerId === input.selfPeerId,
108
+ isOwner: publicKey !== null && ownerKeys.has(publicKey),
109
+ isMember: true,
110
+ trusted: input.trusted(row),
111
+ profile: selfPublished ? row.profile : null,
112
+ lastPublishedAt: selfPublished ? row.updatedAt : null,
113
+ vouchedBy: row.vouchOwner,
114
+ };
115
+ }
116
+
117
+ /** What {@link planMachineRemoval} judges from. */
118
+ export interface MachineRemovalPlanInput {
119
+ peerId: string;
120
+ machines: readonly CadreMachine[];
121
+ rows: readonly CadrePeerRow[];
122
+ owners: readonly OwnerKeyRow[];
123
+ selfPeerId: string | null;
124
+ /** This node's enrolled owner key (`CadreNode.enrolledOwnerSigningKey`), or null when it cannot sign owner writes. */
125
+ selfOwnerKey: string | null;
126
+ invites: readonly CadreInviteStatus[];
127
+ usages: readonly CadreInviteUsageRow[];
128
+ }
129
+
130
+ /**
131
+ * The plan for removing `peerId` from these rows. The refusals come first and by name: a
132
+ * node that cannot sign, the node itself (an owner cannot sign its own `OwnerKey` removal;
133
+ * refusing the whole self-removal keeps one rule for owners and non-owners), and a machine
134
+ * holding the only live owner key. The last cannot coexist with this node being a different
135
+ * owner, but the local count can be wrong under concurrency, so the name stays.
136
+ *
137
+ * `dependents` are the rows the machine's owner key vouched, which every reader judges
138
+ * against its anchor through `VouchOwner`: once the key is removed they stop passing the
139
+ * membership predicate on every machine unless re-vouched first. This node's own rows are
140
+ * reported as `ownDependency` instead, since it cannot re-vouch them itself.
141
+ *
142
+ * `invitations` are the live ones the machine could redeem to come back: targeted at it,
143
+ * or already redeemed by it and still redeemable (a multi-use invitation, or an open
144
+ * owner invitation inside its lifetime). The schema's rule: keeping a device out is removing
145
+ * its row and withdrawing the invitation.
146
+ */
147
+ export function planMachineRemoval(input: MachineRemovalPlanInput): MachineRemovalPlan {
148
+ const machine = input.machines.find((candidate) => candidate.peerId === input.peerId) ?? null;
149
+ const key = machine?.isOwner ? machine.publicKey : null;
150
+ const refusal = refusalFor(input, machine);
151
+ const removesOwnerKey = key !== null;
152
+ return {
153
+ machine,
154
+ refusal,
155
+ removesOwnerKey,
156
+ dependents: removesOwnerKey ? dependentsOf(input, key) : { owners: [], peers: [] },
157
+ invitations: invitationsOpenTo(input),
158
+ ownDependency: removesOwnerKey && isOwnDependency(input, key),
159
+ };
160
+ }
161
+
162
+ function refusalFor(input: MachineRemovalPlanInput, machine: CadreMachine | null): MachineRemovalRefusal | null {
163
+ if (input.selfOwnerKey === null) {
164
+ return 'not-an-owner';
165
+ }
166
+ if (input.peerId === input.selfPeerId) {
167
+ return 'self';
168
+ }
169
+ if (machine?.isOwner && input.owners.length <= 1) {
170
+ return 'last-owner';
171
+ }
172
+ return null;
173
+ }
174
+
175
+ function dependentsOf(input: MachineRemovalPlanInput, key: string): MachineRemovalPlan['dependents'] {
176
+ return {
177
+ owners: input.owners
178
+ .filter((owner) => owner.vouchOwner === key && owner.key !== key && owner.key !== input.selfOwnerKey)
179
+ .map((owner) => owner.key),
180
+ peers: input.rows
181
+ .filter((row) => row.vouchOwner === key && row.peerId !== input.peerId && row.peerId !== input.selfPeerId)
182
+ .map((row) => row.peerId),
183
+ };
184
+ }
185
+
186
+ function isOwnDependency(input: MachineRemovalPlanInput, key: string): boolean {
187
+ const ownRow = input.rows.find((row) => row.peerId === input.selfPeerId);
188
+ const ownOwner = input.owners.find((owner) => owner.key === input.selfOwnerKey);
189
+ return ownRow?.vouchOwner === key || ownOwner?.vouchOwner === key;
190
+ }
191
+
192
+ function invitationsOpenTo(input: MachineRemovalPlanInput): string[] {
193
+ const redeemed = new Set(input.usages.filter((usage) => usage.peerId === input.peerId).map((usage) => usage.inviteKey));
194
+ return input.invites
195
+ .filter((status) => status.live && (status.invite.peerId === input.peerId || redeemed.has(status.invite.key)))
196
+ .map((status) => status.invite.key);
197
+ }
198
+
199
+ /**
200
+ * The re-vouch a caller asked for, checked against the plan: every key and peer must be in
201
+ * `plan.dependents`, so a caller cannot re-vouch a row the removed owner never vouched (nor
202
+ * the machine itself, which `dependents.peers` excludes). Throws naming the offenders, before
203
+ * `CadreNode.removeMachine` writes anything. Duplicates are dropped.
204
+ */
205
+ export function revouchWithinPlan(plan: MachineRemovalPlan, revouch: RemoveMachineOptions['revouch']): MachineRemovalPlan['dependents'] {
206
+ const owners = [...new Set(revouch?.owners ?? [])];
207
+ const peers = [...new Set(revouch?.peers ?? [])];
208
+ const strayOwners = owners.filter((key) => !plan.dependents.owners.includes(key));
209
+ const strayPeers = peers.filter((peerId) => !plan.dependents.peers.includes(peerId));
210
+ if (strayOwners.length > 0 || strayPeers.length > 0) {
211
+ throw new Error(
212
+ `removeMachine: re-vouch names rows the removed machine did not vouch: ${[...strayOwners, ...strayPeers].join(', ')}`,
213
+ );
214
+ }
215
+ return { owners, peers };
216
+ }