@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
package/src/fs-atomic.ts CHANGED
@@ -1,117 +1,92 @@
1
- /**
2
- * Node-only filesystem helpers: atomic file write, filename-safe encoding, and
3
- * the ENOENT check, shared by `key-store-file.ts` and `file-durable-slot.ts`.
4
- *
5
- * This module imports `node:fs/promises`, so — like its consumers — it is kept
6
- * OUT of the package's cross-platform default entry and is not itself an
7
- * exported subpath; it is only reachable through the Node-only store modules.
8
- */
9
- import debug from 'debug';
10
- import { mkdir, rm, rename, open } from 'node:fs/promises';
11
- import type { FileHandle } from 'node:fs/promises';
12
-
13
- const log = debug('sereus:cadre:fs-atomic');
14
-
15
- /** Best-effort POSIX permissions (ignored on Windows / unsupported FSes). */
16
- export const DIR_MODE = 0o700;
17
- export const FILE_MODE = 0o600;
18
-
19
- /**
20
- * Percent-encode a string into a filesystem-safe filename component. Beyond
21
- * `encodeURIComponent` (which already escapes `/`, spaces, and unicode), this
22
- * also escapes the unreserved characters `encodeURIComponent` leaves intact but
23
- * that are unsafe in filenames on some platforms (notably `*` on Windows) — and
24
- * `.`, so a file suffix appended by the caller is the only literal dot, making
25
- * suffix-stripping unambiguous — and every uppercase letter, because Windows
26
- * (NTFS) and macOS (APFS/HFS+ by default) ignore case in file names. Reversed by
27
- * `decodeURIComponent`.
28
- *
29
- * The result contains only `a-z0-9-_` and `%XX` escapes (uppercase hex). Since a
30
- * literal `%` is itself escaped, every `%` starts an escape, so ignoring case
31
- * can change only escape hex digits — which decode case-insensitively — and two
32
- * different inputs never yield names equal ignoring case. The escapes keep
33
- * `encodeURIComponent`'s uppercase hex so a name built from an id with no
34
- * capital letters (the identity slot `cadre%2Fidentity.key`, a lowercase party
35
- * id) is the same as before uppercase letters were escaped.
36
- */
37
- export function encodeFileSafeComponent(component: string): string {
38
- // NOTE: a file left from before uppercase letters were escaped (`trusted-owners.Alice.json`) has, ignoring case, the new name of the all-lowercase id (`alice`), so on Windows/macOS a lookup for that id opens it; the per-party records' envelope party-id check rejects it (cold start), and `FileKeyStore` needs two parties in one key directory whose ids fold to the same lowercase string. The release note says to delete old files; if a store ever reads another id's old file in practice, rename or remove old-format files when the store opens.
39
- return encodeURIComponent(component).replace(
40
- /(%[0-9A-F]{2})|[!'()*~.A-Z]/g,
41
- (c, escape: string | undefined) => escape ?? `%${c.charCodeAt(0).toString(16).toUpperCase()}`
42
- );
43
- }
44
-
45
- /** Whether an unknown error is a Node "file/dir not found" (ENOENT). */
46
- export function isNotFound(error: unknown): boolean {
47
- return typeof error === 'object' && error !== null
48
- && (error as { code?: unknown }).code === 'ENOENT';
49
- }
50
-
51
- /**
52
- * Crash-atomic file write: a concurrent reader sees either the complete
53
- * previous bytes or the complete new bytes, never a torn file. `dir` is created
54
- * (0700) if absent; the data is written to `tmpPath` (created exclusively,
55
- * 0600), fsync'd, then atomically renamed onto `finalPath`. A failure at any
56
- * point removes the temp file and leaves the previous file untouched.
57
- *
58
- * `tmpPath` must be a sibling of `finalPath` (same directory) so the rename
59
- * stays within one filesystem and is therefore atomic, and should carry a
60
- * random component so concurrent writers (and crash-orphaned leftovers) are
61
- * collide-free.
62
- *
63
- * Durability bar: the temp file is fsync'd before the rename so a power loss
64
- * cannot surface a file whose bytes never reached the platter, and `dir` is
65
- * fsync'd afterwards (best-effort — see {@link syncDirBestEffort}) so the
66
- * rename itself survives a crash. On POSIX `rename(2)` replaces atomically;
67
- * Node maps to `MoveFileEx(MOVEFILE_REPLACE_EXISTING)` on Windows, which is
68
- * also atomic — though it can fail with `EPERM`/`EBUSY` if another process
69
- * holds the destination open. That cross-process case is out of scope for the
70
- * single-flight in-process callers here; such a failure surfaces to the caller
71
- * with the previous file left intact.
72
- */
73
- export async function writeFileAtomically(
74
- dir: string,
75
- tmpPath: string,
76
- finalPath: string,
77
- data: Uint8Array
78
- ): Promise<void> {
79
- await mkdir(dir, { recursive: true, mode: DIR_MODE });
80
- // 'wx' creates the temp file exclusively; a name collision (astronomically
81
- // unlikely given the caller's random component) fails here before we own anything.
82
- const handle = await open(tmpPath, 'wx', FILE_MODE);
83
- try {
84
- try {
85
- await handle.writeFile(data);
86
- await handle.sync(); // land bytes on disk before the rename exposes them
87
- } finally {
88
- await handle.close().catch(() => {});
89
- }
90
- await rename(tmpPath, finalPath);
91
- } catch (error) {
92
- // Any failure after creation (write, sync, or rename): drop the temp
93
- // file so a retry leaves no `.tmp` debris. The destination is only ever
94
- // swapped by the atomic rename, so the previous bytes stay intact.
95
- await rm(tmpPath, { force: true }).catch(() => {});
96
- throw error;
97
- }
98
- await syncDirBestEffort(dir);
99
- }
100
-
101
- /**
102
- * Best-effort fsync of a directory so a fresh file's directory entry is
103
- * durable across power loss. Opening a directory for fsync is not portable
104
- * (unsupported on Windows and some filesystems), and rename atomicity does not
105
- * depend on it — so a failure here is logged and swallowed, never surfaced.
106
- */
107
- async function syncDirBestEffort(dir: string): Promise<void> {
108
- let dirHandle: FileHandle | undefined;
109
- try {
110
- dirHandle = await open(dir, 'r');
111
- await dirHandle.sync();
112
- } catch (error) {
113
- log('directory fsync skipped for %s: %o', dir, error);
114
- } finally {
115
- await dirHandle?.close().catch(() => {});
116
- }
117
- }
1
+ /**
2
+ * Node-only filesystem helpers: atomic file write and the ENOENT check, shared
3
+ * by `key-store-file.ts` and `file-durable-slot.ts`. The filename-safe encoding
4
+ * they also use is cross-platform and lives in `file-safe-name.ts`.
5
+ *
6
+ * This module imports `node:fs/promises`, so — like its consumers — it is kept
7
+ * OUT of the package's cross-platform default entry and is not itself an
8
+ * exported subpath; it is only reachable through the Node-only store modules.
9
+ */
10
+ import debug from 'debug';
11
+ import { mkdir, rm, rename, open } from 'node:fs/promises';
12
+ import type { FileHandle } from 'node:fs/promises';
13
+
14
+ const log = debug('sereus:cadre:fs-atomic');
15
+
16
+ /** Best-effort POSIX permissions (ignored on Windows / unsupported FSes). */
17
+ export const DIR_MODE = 0o700;
18
+ export const FILE_MODE = 0o600;
19
+
20
+ /** Whether an unknown error is a Node "file/dir not found" (ENOENT). */
21
+ export function isNotFound(error: unknown): boolean {
22
+ return typeof error === 'object' && error !== null
23
+ && (error as { code?: unknown }).code === 'ENOENT';
24
+ }
25
+
26
+ /**
27
+ * Crash-atomic file write: a concurrent reader sees either the complete
28
+ * previous bytes or the complete new bytes, never a torn file. `dir` is created
29
+ * (0700) if absent; the data is written to `tmpPath` (created exclusively,
30
+ * 0600), fsync'd, then atomically renamed onto `finalPath`. A failure at any
31
+ * point removes the temp file and leaves the previous file untouched.
32
+ *
33
+ * `tmpPath` must be a sibling of `finalPath` (same directory) so the rename
34
+ * stays within one filesystem and is therefore atomic, and should carry a
35
+ * random component so concurrent writers (and crash-orphaned leftovers) are
36
+ * collide-free.
37
+ *
38
+ * Durability bar: the temp file is fsync'd before the rename so a power loss
39
+ * cannot surface a file whose bytes never reached the platter, and `dir` is
40
+ * fsync'd afterwards (best-effort — see {@link syncDirBestEffort}) so the
41
+ * rename itself survives a crash. On POSIX `rename(2)` replaces atomically;
42
+ * Node maps to `MoveFileEx(MOVEFILE_REPLACE_EXISTING)` on Windows, which is
43
+ * also atomic — though it can fail with `EPERM`/`EBUSY` if another process
44
+ * holds the destination open. That cross-process case is out of scope for the
45
+ * single-flight in-process callers here; such a failure surfaces to the caller
46
+ * with the previous file left intact.
47
+ */
48
+ export async function writeFileAtomically(
49
+ dir: string,
50
+ tmpPath: string,
51
+ finalPath: string,
52
+ data: Uint8Array
53
+ ): Promise<void> {
54
+ await mkdir(dir, { recursive: true, mode: DIR_MODE });
55
+ // 'wx' creates the temp file exclusively; a name collision (astronomically
56
+ // unlikely given the caller's random component) fails here before we own anything.
57
+ const handle = await open(tmpPath, 'wx', FILE_MODE);
58
+ try {
59
+ try {
60
+ await handle.writeFile(data);
61
+ await handle.sync(); // land bytes on disk before the rename exposes them
62
+ } finally {
63
+ await handle.close().catch(() => {});
64
+ }
65
+ await rename(tmpPath, finalPath);
66
+ } catch (error) {
67
+ // Any failure after creation (write, sync, or rename): drop the temp
68
+ // file so a retry leaves no `.tmp` debris. The destination is only ever
69
+ // swapped by the atomic rename, so the previous bytes stay intact.
70
+ await rm(tmpPath, { force: true }).catch(() => {});
71
+ throw error;
72
+ }
73
+ await syncDirBestEffort(dir);
74
+ }
75
+
76
+ /**
77
+ * Best-effort fsync of a directory so a fresh file's directory entry is
78
+ * durable across power loss. Opening a directory for fsync is not portable
79
+ * (unsupported on Windows and some filesystems), and rename atomicity does not
80
+ * depend on it — so a failure here is logged and swallowed, never surfaced.
81
+ */
82
+ async function syncDirBestEffort(dir: string): Promise<void> {
83
+ let dirHandle: FileHandle | undefined;
84
+ try {
85
+ dirHandle = await open(dir, 'r');
86
+ await dirHandle.sync();
87
+ } catch (error) {
88
+ log('directory fsync skipped for %s: %o', dir, error);
89
+ } finally {
90
+ await dirHandle?.close().catch(() => {});
91
+ }
92
+ }
package/src/index.ts CHANGED
@@ -7,8 +7,12 @@ export { canonicalJson } from './canonical-json.js';
7
7
  // Main CadreNode class
8
8
  export { CadreNode, OWNER_INVITATION_DEFAULT_TTL_MS, CADRE_INVITATION_DEFAULT_TTL_MS } from './cadre-node.js';
9
9
 
10
+ // Removing a machine from a cadre: the by-name refusal `CadreNode.removeMachine` throws
11
+ // (the listing, plan and result types are in types.ts)
12
+ export { MachineRemovalRefusedError } from './machine-removal.js';
13
+
10
14
  // Control database
11
- export { ControlDatabase, MissingHostStrandError, InvitationExhaustedError, CadreInviteIssuerUnknownError, buildAuthorizationMessage, registerPartyIdFunction, formationVouchMessage, formationConsentMessage, cadreInviteAddMessage, cadreInviteRedeemMessage, cadreInviteConsentMessage, projectSeedPeers, REAPABLE_TABLES, type ReapableTable, type ControlDatabaseConfig, type ControlTable, type RevocableTable, type FormationUsageResult, type MembershipChangeListener, type GuardedDeleteListener, type ControlWriteAbandonedListener, type RevokedRowRef } from './control-database.js';
15
+ export { ControlDatabase, parseNullableStoredDatetimeMs, MissingHostStrandError, InvitationExhaustedError, CadreInviteIssuerUnknownError, buildAuthorizationMessage, registerPartyIdFunction, formationVouchMessage, formationConsentMessage, cadreInviteAddMessage, cadreInviteRedeemMessage, cadreInviteConsentMessage, projectSeedPeers, REAPABLE_TABLES, type ReapableTable, type ControlDatabaseConfig, type ControlTable, type RevocableTable, type FormationUsageResult, type FormationInviteRow, type MembershipChangeListener, type GuardedDeleteListener, type ControlWriteAbandonedListener, type RevokedRowRef, type MachineRemovalWrites } from './control-database.js';
12
16
 
13
17
  // Bounded retry for transient control-write failures (classifier + loop behind
14
18
  // ControlDatabase.lockedWithRetry; exported so the integration package can drive the
@@ -63,6 +67,10 @@ export {
63
67
  type KeyId
64
68
  } from './key-store.js';
65
69
 
70
+ // The filename-safe form of an id that every Node-only per-party file store names its file
71
+ // with (`<name>.<encoded party>.json`), for an embedder keeping its own file beside theirs.
72
+ export { encodeFileSafeComponent } from './file-safe-name.js';
73
+
66
74
  // The KeyStore-backed identity rule, shared with embedding apps that need the
67
75
  // node key BEFORE `CadreNode` exists.
68
76
  export { loadOrCreateIdentityKey } from './identity-key.js';
@@ -155,6 +163,7 @@ export {
155
163
 
156
164
  // Strand database
157
165
  export { StrandDatabase, type StrandDatabaseConfig } from './strand-database.js';
166
+ export type { StrandCommitDurability, StrandCommitHeld } from './strand-commit-durability.js';
158
167
 
159
168
  // Strand management
160
169
  export {
@@ -428,7 +437,6 @@ export {
428
437
  isPeerRecordFresh,
429
438
  isSignalingAddr,
430
439
  orderSignalingFirst,
431
- withTrailingPeerId,
432
440
  currentMemberTrustPolicy,
433
441
  DEFAULT_PEER_RECORD_MAX_AGE_MS,
434
442
  DEFAULT_PEER_RECORD_HEARTBEAT_MS
@@ -481,8 +489,12 @@ export {
481
489
  } from './control-cohort.js';
482
490
 
483
491
  // Dialing one peer from several candidate addresses, each under its own time
484
- // limit, so an address that never answers cannot starve the rest.
492
+ // limit, so an address that never answers cannot starve the rest. Each address is
493
+ // bound to the peer expected and dialed outside libp2p's dial queue (`dialAddr`),
494
+ // so another dial of the same peer in flight cannot capture the attempt.
485
495
  export {
496
+ dialAddr,
497
+ withTrailingPeerId,
486
498
  dialPeerAddrs,
487
499
  SelfRelayOnlyError,
488
500
  PeerUnreachableError,
@@ -550,10 +562,56 @@ export {
550
562
  export {
551
563
  encodeNodeClaimPayload,
552
564
  decodeNodeClaimPayload,
565
+ selectNodeClaimAddresses,
566
+ withClaimDestination,
567
+ destinationPeerIdOf,
568
+ classifyDialAddress,
553
569
  NODE_CLAIM_PAYLOAD_PREFIX,
554
- type NodeClaimPayload
570
+ type NodeClaimPayload,
571
+ type NodeClaimAddressOptions,
572
+ type DialAddressKind
555
573
  } from './node-claim-payload.js';
556
574
 
575
+ // Moving a party's strands into another party of the same owner, so an app's strands follow
576
+ // the app into the user's main cadre: the bundle one node exports from the party it leaves,
577
+ // its adoption by a node of the party it joined, and the check that the party it left holds
578
+ // nothing the joined one lacks before it is dissolved.
579
+ export {
580
+ exportStrandBundle,
581
+ adoptStrandBundle,
582
+ readStrandHoldings,
583
+ unheldBundleContent,
584
+ StrandMoveNotOwnerError,
585
+ StrandMoveSamePartyError,
586
+ type StrandHoldings,
587
+ type StrandBundle,
588
+ type StrandBundleEntry,
589
+ type FormationInviteEntry,
590
+ type StrandMoveReport,
591
+ type StrandMoveFailure
592
+ } from './strand-move.js';
593
+
594
+ // The join request: what an app that wants to join a cadre copies to the app that manages it,
595
+ // which mints an invitation targeted at the request's peer id. Both paste-text prefixes live here.
596
+ export {
597
+ encodeCadreJoinRequest,
598
+ decodeCadreJoinRequest,
599
+ requireCadreJoinReply,
600
+ CADRE_JOIN_REQUEST_PREFIX,
601
+ CADRE_INVITATION_PREFIX,
602
+ CADRE_JOIN_REQUEST_MAX_APP_NAME,
603
+ type CadreJoinReply,
604
+ type CadreJoinRequest
605
+ } from './cadre-join-request.js';
606
+
607
+ // Renting a node from a hosting provider: the provider's descriptor, the app's side of the
608
+ // OAuth 2 + PKCE sign-in, the typed client for its container API, and `rentNode` /
609
+ // `finishRental`, which add the rented container to this node's cadre by invitation or by
610
+ // claim. Global `fetch` and `crypto.getRandomValues` only, so the whole set loads on React
611
+ // Native and in the browser (`docs/architecture.md` → "Renting a node from an app"). The
612
+ // barrel is the curated list; it is not repeated here.
613
+ export * from './provider/index.js';
614
+
557
615
  // Schema Verification
558
616
  export {
559
617
  signSchema,
@@ -599,6 +657,7 @@ export { formationDeadlines, type FormationDeadlines } from './strand-formation-
599
657
  export {
600
658
  CadreInviteHandler,
601
659
  redeemAtMembers,
660
+ redeemInPasses,
602
661
  signRedeemRequest,
603
662
  verifyRedeemReply,
604
663
  encodeCadreInvitation,
@@ -620,9 +679,17 @@ export {
620
679
  type CadreInviteHandlerOptions,
621
680
  type CadreInviteStore,
622
681
  type RedeemAtMembersOptions,
682
+ type RedeemInPassesOptions,
623
683
  type RedeemSigner,
624
684
  type CadreInviteRedemption
625
685
  } from './cadre-invite-protocol.js';
686
+ // Which machines, and how many addresses of each, an invitation names; how far a cadre invitation reaches
687
+ export {
688
+ selectCadreInvitationMembers,
689
+ cadreInvitationReach,
690
+ CADRE_INVITATION_MAX_ADDRS,
691
+ CADRE_INVITATION_ADDRS_PER_SIBLING
692
+ } from './invitation-bootstrap.js';
626
693
  // The pending-join retry loop behind CadreNode.requestJoin (PendingJoinStatus is in types.ts)
627
694
  export { PENDING_JOIN_POLL_MS, PENDING_JOIN_MAX_BACKOFF_MS, MAX_PENDING_JOIN_MS } from './pending-join-runner.js';
628
695
 
@@ -1,23 +1,43 @@
1
1
  /**
2
- * Which of the inviting party's other machines an open invitation names, so a joiner can still
3
- * form while the machine that minted it is offline. The minting machine's own addresses go
4
- * first and are not chosen here (`CadreNode.createOpenInvitation`).
2
+ * Which of the inviting party's machines an invitation names, so a joiner can still form or
3
+ * join while the machine that minted it is offline: an open strand invitation
4
+ * (`CadreNode.createOpenInvitation`, which puts the minting machine's own addresses first
5
+ * itself) and a cadre invitation ({@link selectCadreInvitationMembers}). Also how far a cadre
6
+ * invitation reaches once its minting machine is gone ({@link cadreInvitationReach}).
5
7
  */
6
8
 
9
+ import { classifyDialAddress, destinationPeerIdOf, type DialAddressKind } from './node-claim-payload.js';
10
+ import type { CadreInvitationReach } from './types.js';
11
+
7
12
  /**
8
13
  * At most this many of the party's other machines are named, each with at most
9
14
  * {@link INVITATION_ADDRS_PER_SIBLING} addresses: 12 extra addresses of roughly 100–200
10
15
  * characters, base64url-encoded into an invitation shared as text.
11
16
  *
12
- * NOTE: if invitations are ever carried in a QR code, lower these caps or name the machines by
13
- * peer id only.
14
- * NOTE: the per-machine cap keeps the record's order (relay first, then direct addresses as the
15
- * machine published them, loopback included). If a sibling with many interfaces is seen to have
16
- * its reachable address cut, rank loopback and private addresses last before slicing.
17
+ * NOTE: a cadre invitation, which may be carried in a QR code, is capped harder
18
+ * ({@link CADRE_INVITATION_MAX_ADDRS}); an open strand invitation keeps these caps. If open
19
+ * invitations are ever carried in a QR code too, cap them the same way.
20
+ * NOTE: for an open strand invitation the per-machine cap keeps the record's order (relay first,
21
+ * then direct addresses as the machine published them, loopback included); a cadre invitation
22
+ * ranks them first. If an open invitation's sibling is seen to have its reachable address cut,
23
+ * rank the same way.
17
24
  */
18
25
  export const INVITATION_SIBLING_MACHINES = 3;
19
26
  export const INVITATION_ADDRS_PER_SIBLING = 4;
20
27
 
28
+ /**
29
+ * Most addresses a cadre invitation names in total, so its text fits one QR code. Measured
30
+ * with three owner keys: 2,206 characters encoded at 6 relayed addresses of about 155
31
+ * characters (the worst case, since ranking puts relayed addresses first), 1,972 with relayed
32
+ * and direct WebSocket addresses alternating, against 2,331 bytes in the largest QR code at
33
+ * error-correction level M (2,953 at L). Each further address costs about 170 characters, each
34
+ * further owner key about 60.
35
+ */
36
+ export const CADRE_INVITATION_MAX_ADDRS = 6;
37
+
38
+ /** Most addresses a cadre invitation names for each of the party's other machines. */
39
+ export const CADRE_INVITATION_ADDRS_PER_SIBLING = 2;
40
+
21
41
  /** One other machine of the party whose signed address record resolved. */
22
42
  export interface InvitationSibling {
23
43
  peerId: string;
@@ -28,13 +48,71 @@ export interface InvitationSibling {
28
48
  }
29
49
 
30
50
  /**
31
- * The addresses of the siblings an invitation names. Machines this node is connected to right
32
- * now come first, since a live connection is the best evidence that a machine is up; a record
33
- * shows only that it was up within the freshness window. Within each half, newest record first.
51
+ * The addresses of the siblings an invitation names, at most `perSibling` each. Machines this
52
+ * node is connected to right now come first, since a live connection is the best evidence that
53
+ * a machine is up; a record shows only that it was up within the freshness window. Within each
54
+ * half, newest record first.
34
55
  */
35
- export function selectInvitationSiblingAddrs(siblings: InvitationSibling[], connected: ReadonlySet<string>): string[] {
56
+ export function selectInvitationSiblingAddrs(
57
+ siblings: InvitationSibling[],
58
+ connected: ReadonlySet<string>,
59
+ perSibling: number = INVITATION_ADDRS_PER_SIBLING
60
+ ): string[] {
36
61
  return [...siblings]
37
62
  .sort((a, b) => Number(connected.has(b.peerId)) - Number(connected.has(a.peerId)) || b.updatedAt - a.updatedAt)
38
63
  .slice(0, INVITATION_SIBLING_MACHINES)
39
- .flatMap((sibling) => sibling.addrs.slice(0, INVITATION_ADDRS_PER_SIBLING));
64
+ .flatMap((sibling) => sibling.addrs.slice(0, perSibling));
65
+ }
66
+
67
+ /**
68
+ * The `members` of a cadre invitation: the minting machine's own addresses first (at most
69
+ * {@link INVITATION_ADDRS_PER_SIBLING}), then the other machines' in
70
+ * {@link selectInvitationSiblingAddrs} order (at most {@link CADRE_INVITATION_ADDRS_PER_SIBLING}
71
+ * each), the whole list cut to {@link CADRE_INVITATION_MAX_ADDRS}. Each machine's addresses are
72
+ * ranked for a phone before they are cut ({@link rankForPhone}): libp2p reports a machine
73
+ * listening on every interface with its loopback and private addresses ahead of its relayed one.
74
+ *
75
+ * NOTE: accepted tradeoff — with two or more other machines, only the best two addresses of
76
+ * each survive, connected machines first, so a reachable address of a sibling can be cut. Size
77
+ * was weighed over completeness, so the text fits a QR code; revisit if redemptions are seen
78
+ * failing for want of a sibling address the record held.
79
+ */
80
+ export function selectCadreInvitationMembers(
81
+ own: readonly string[],
82
+ siblings: InvitationSibling[],
83
+ connected: ReadonlySet<string>
84
+ ): string[] {
85
+ const ranked = siblings.map((sibling) => ({ ...sibling, addrs: rankForPhone(sibling.addrs) }));
86
+ return [
87
+ ...rankForPhone(own).slice(0, INVITATION_ADDRS_PER_SIBLING),
88
+ ...selectInvitationSiblingAddrs(ranked, connected, CADRE_INVITATION_ADDRS_PER_SIBLING),
89
+ ].slice(0, CADRE_INVITATION_MAX_ADDRS);
90
+ }
91
+
92
+ /** Order in which a phone is likeliest to reach an address: from anywhere, on its network, on that machine, not at all. */
93
+ const PHONE_DIAL_RANK: Record<DialAddressKind, number> = { relay: 0, name: 0, public: 0, private: 1, loopback: 2 };
94
+
95
+ /** `addrs` best first for a phone dialing them ({@link PHONE_DIAL_RANK}), ties in their given order; one a phone cannot dial (plain TCP) last. */
96
+ function rankForPhone(addrs: readonly string[]): string[] {
97
+ const rankOf = (addr: string): number => {
98
+ const kind = classifyDialAddress(addr);
99
+ return kind === undefined ? 3 : PHONE_DIAL_RANK[kind];
100
+ };
101
+ return addrs.map((addr) => ({ addr, rank: rankOf(addr) })).sort((a, b) => a.rank - b.rank).map(({ addr }) => addr);
102
+ }
103
+
104
+ /**
105
+ * Where a phone could redeem an invitation naming `members` from, judged on the machines other
106
+ * than `issuerPeerId`: in the app flows the minting app is the one closed or in the background
107
+ * when the other app redeems, and two apps on one phone cannot reach each other. An address
108
+ * names its machine by its last `/p2p/`.
109
+ */
110
+ export function cadreInvitationReach(members: readonly string[], issuerPeerId: string): CadreInvitationReach {
111
+ const others = members.flatMap((addr) => {
112
+ const kind = classifyDialAddress(addr);
113
+ // Classified, so it parses and `destinationPeerIdOf` cannot throw.
114
+ return kind === undefined || destinationPeerIdOf(addr) === issuerPeerId ? [] : [kind];
115
+ });
116
+ if (others.some((kind) => kind === 'relay' || kind === 'name' || kind === 'public')) return 'anywhere';
117
+ return others.length > 0 ? 'local-network' : 'issuer-only';
40
118
  }