@serfab/cadre-core 1.14.0 → 1.16.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 (171) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +64 -29
  3. package/dist/cadre-invite-protocol.js +207 -38
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  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 +375 -79
  9. package/dist/cadre-node.js +758 -186
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-database.d.ts +92 -12
  12. package/dist/control-database.js +323 -142
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-schema.d.ts +1 -1
  15. package/dist/control-schema.js +19 -3
  16. package/dist/control-schema.js.map +1 -1
  17. package/dist/ed25519-key.d.ts +7 -9
  18. package/dist/ed25519-key.js +20 -10
  19. package/dist/ed25519-key.js.map +1 -1
  20. package/dist/fetch-impl.d.ts +12 -0
  21. package/dist/fetch-impl.js +23 -0
  22. package/dist/fetch-impl.js.map +1 -0
  23. package/dist/file-durable-slot.js +2 -1
  24. package/dist/file-durable-slot.js.map +1 -1
  25. package/dist/file-safe-name.d.ts +25 -0
  26. package/dist/file-safe-name.js +29 -0
  27. package/dist/file-safe-name.js.map +1 -0
  28. package/dist/formation-approval.js +5 -11
  29. package/dist/formation-approval.js.map +1 -1
  30. package/dist/fs-atomic.d.ts +0 -19
  31. package/dist/fs-atomic.js +3 -24
  32. package/dist/fs-atomic.js.map +1 -1
  33. package/dist/index.d.ts +12 -5
  34. package/dist/index.js +31 -6
  35. package/dist/index.js.map +1 -1
  36. package/dist/invitation-bootstrap.d.ts +50 -12
  37. package/dist/invitation-bootstrap.js +76 -13
  38. package/dist/invitation-bootstrap.js.map +1 -1
  39. package/dist/joined-strand-store.d.ts +7 -0
  40. package/dist/joined-strand-store.js +9 -0
  41. package/dist/joined-strand-store.js.map +1 -1
  42. package/dist/key-store-file.js +2 -1
  43. package/dist/key-store-file.js.map +1 -1
  44. package/dist/link-budget.d.ts +9 -4
  45. package/dist/link-budget.js +9 -4
  46. package/dist/link-budget.js.map +1 -1
  47. package/dist/machine-removal.d.ts +78 -0
  48. package/dist/machine-removal.js +166 -0
  49. package/dist/machine-removal.js.map +1 -0
  50. package/dist/membership-connection-gater.d.ts +6 -5
  51. package/dist/membership-connection-gater.js +6 -5
  52. package/dist/membership-connection-gater.js.map +1 -1
  53. package/dist/node-claim-payload.d.ts +55 -0
  54. package/dist/node-claim-payload.js +108 -4
  55. package/dist/node-claim-payload.js.map +1 -1
  56. package/dist/node-default-transports.d.ts +27 -0
  57. package/dist/node-default-transports.js +35 -0
  58. package/dist/node-default-transports.js.map +1 -0
  59. package/dist/node-transports.d.ts +30 -0
  60. package/dist/node-transports.js +40 -0
  61. package/dist/node-transports.js.map +1 -0
  62. package/dist/peer-dial.d.ts +124 -27
  63. package/dist/peer-dial.js +254 -30
  64. package/dist/peer-dial.js.map +1 -1
  65. package/dist/peer-join-backfill.js +6 -0
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/peer-record.d.ts +6 -45
  68. package/dist/peer-record.js +8 -50
  69. package/dist/peer-record.js.map +1 -1
  70. package/dist/primary-lan-address.d.ts +8 -0
  71. package/dist/primary-lan-address.js +46 -0
  72. package/dist/primary-lan-address.js.map +1 -0
  73. package/dist/provider/client.d.ts +131 -0
  74. package/dist/provider/client.js +226 -0
  75. package/dist/provider/client.js.map +1 -0
  76. package/dist/provider/descriptor.d.ts +41 -0
  77. package/dist/provider/descriptor.js +103 -0
  78. package/dist/provider/descriptor.js.map +1 -0
  79. package/dist/provider/index.d.ts +9 -0
  80. package/dist/provider/index.js +10 -0
  81. package/dist/provider/index.js.map +1 -0
  82. package/dist/provider/oauth.d.ts +125 -0
  83. package/dist/provider/oauth.js +199 -0
  84. package/dist/provider/oauth.js.map +1 -0
  85. package/dist/provider/rent-node.d.ts +101 -0
  86. package/dist/provider/rent-node.js +220 -0
  87. package/dist/provider/rent-node.js.map +1 -0
  88. package/dist/relay-addrs.d.ts +9 -4
  89. package/dist/relay-addrs.js +9 -4
  90. package/dist/relay-addrs.js.map +1 -1
  91. package/dist/relay-reservation.d.ts +51 -16
  92. package/dist/relay-reservation.js +107 -24
  93. package/dist/relay-reservation.js.map +1 -1
  94. package/dist/seed-bootstrap.d.ts +22 -5
  95. package/dist/seed-bootstrap.js +48 -25
  96. package/dist/seed-bootstrap.js.map +1 -1
  97. package/dist/strand-commit-durability.d.ts +54 -0
  98. package/dist/strand-commit-durability.js +74 -0
  99. package/dist/strand-commit-durability.js.map +1 -0
  100. package/dist/strand-database.d.ts +23 -8
  101. package/dist/strand-database.js +41 -13
  102. package/dist/strand-database.js.map +1 -1
  103. package/dist/strand-first-sync-gate.d.ts +7 -1
  104. package/dist/strand-first-sync-gate.js +7 -1
  105. package/dist/strand-first-sync-gate.js.map +1 -1
  106. package/dist/strand-formation-protocol.js +4 -1
  107. package/dist/strand-formation-protocol.js.map +1 -1
  108. package/dist/strand-fret-addrs.d.ts +19 -1
  109. package/dist/strand-fret-addrs.js +27 -9
  110. package/dist/strand-fret-addrs.js.map +1 -1
  111. package/dist/strand-instance-manager.d.ts +81 -19
  112. package/dist/strand-instance-manager.js +216 -105
  113. package/dist/strand-instance-manager.js.map +1 -1
  114. package/dist/strand-move.d.ts +124 -0
  115. package/dist/strand-move.js +438 -0
  116. package/dist/strand-move.js.map +1 -0
  117. package/dist/strand-network-config.js +7 -6
  118. package/dist/strand-network-config.js.map +1 -1
  119. package/dist/strand-peer-redial.d.ts +113 -0
  120. package/dist/strand-peer-redial.js +236 -0
  121. package/dist/strand-peer-redial.js.map +1 -0
  122. package/dist/strand-wake-protocol.js +10 -7
  123. package/dist/strand-wake-protocol.js.map +1 -1
  124. package/dist/types.d.ts +212 -4
  125. package/dist/types.js +10 -0
  126. package/dist/types.js.map +1 -1
  127. package/package.json +18 -10
  128. package/src/cadre-invite-protocol.ts +1052 -864
  129. package/src/cadre-join-request.ts +167 -0
  130. package/src/cadre-node.ts +10003 -9381
  131. package/src/control-database.ts +4663 -4451
  132. package/src/control-schema.ts +19 -3
  133. package/src/ed25519-key.ts +20 -10
  134. package/src/fetch-impl.ts +23 -0
  135. package/src/file-durable-slot.ts +64 -63
  136. package/src/file-safe-name.ts +32 -0
  137. package/src/formation-approval.ts +604 -610
  138. package/src/fs-atomic.ts +92 -117
  139. package/src/index.ts +71 -4
  140. package/src/invitation-bootstrap.ts +91 -13
  141. package/src/joined-strand-store.ts +508 -498
  142. package/src/key-store-file.ts +142 -141
  143. package/src/link-budget.ts +9 -4
  144. package/src/machine-removal.ts +216 -0
  145. package/src/membership-connection-gater.ts +638 -637
  146. package/src/node-claim-payload.ts +138 -4
  147. package/src/node-default-transports.ts +38 -0
  148. package/src/node-transports.ts +49 -0
  149. package/src/peer-dial.ts +604 -336
  150. package/src/peer-join-backfill.ts +6 -0
  151. package/src/peer-record.ts +159 -202
  152. package/src/primary-lan-address.ts +47 -0
  153. package/src/provider/client.ts +326 -0
  154. package/src/provider/descriptor.ts +129 -0
  155. package/src/provider/index.ts +59 -0
  156. package/src/provider/oauth.ts +285 -0
  157. package/src/provider/rent-node.ts +275 -0
  158. package/src/relay-addrs.ts +9 -4
  159. package/src/relay-reservation.ts +1211 -1121
  160. package/src/seed-bootstrap.ts +1502 -1471
  161. package/src/strand-commit-durability.ts +116 -0
  162. package/src/strand-database.ts +46 -14
  163. package/src/strand-first-sync-gate.ts +7 -1
  164. package/src/strand-formation-protocol.ts +1039 -1036
  165. package/src/strand-fret-addrs.ts +179 -145
  166. package/src/strand-instance-manager.ts +2026 -1883
  167. package/src/strand-move.ts +585 -0
  168. package/src/strand-network-config.ts +7 -6
  169. package/src/strand-peer-redial.ts +307 -0
  170. package/src/strand-wake-protocol.ts +10 -7
  171. package/src/types.ts +220 -4
@@ -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
+ }
@@ -282,10 +282,15 @@ export const RELAY_RESERVE_REQUEST_ROUND_TRIPS = 1;
282
282
  *
283
283
  * The protocol work measured is 2 — dial the relay ({@link RELAY_DIAL_ROUND_TRIPS}) plus request
284
284
  * the reservation ({@link RELAY_RESERVE_REQUEST_ROUND_TRIPS}). It is budgeted at twice that,
285
- * because the wait afterwards may instead be satisfied by libp2p's own relay discovery, which
286
- * repeats both legs after identify has run; 4 is therefore the largest thing this one deadline
287
- * can be asked to contain, and the poll interval (`DEFAULT_RELAY_RESERVE_POLL_MS`) rounds up on
288
- * 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.
289
294
  */
290
295
  export const RELAY_RESERVATION_ROUND_TRIPS = 2 * (RELAY_DIAL_ROUND_TRIPS + RELAY_RESERVE_REQUEST_ROUND_TRIPS);
291
296
 
@@ -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
+ }