@serfab/cadre-core 0.10.0 → 0.11.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 (105) hide show
  1. package/README.md +255 -255
  2. package/dist/cadre-node.d.ts +203 -28
  3. package/dist/cadre-node.js +445 -84
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-cohort.d.ts +26 -0
  6. package/dist/control-cohort.js +26 -0
  7. package/dist/control-cohort.js.map +1 -1
  8. package/dist/control-database.d.ts +82 -117
  9. package/dist/control-database.js +217 -295
  10. package/dist/control-database.js.map +1 -1
  11. package/dist/control-formation-recorder.d.ts +8 -7
  12. package/dist/control-formation-recorder.js +8 -7
  13. package/dist/control-formation-recorder.js.map +1 -1
  14. package/dist/control-schema.d.ts +1 -1
  15. package/dist/control-schema.js +789 -774
  16. package/dist/control-schema.js.map +1 -1
  17. package/dist/control-write-retry.d.ts +15 -5
  18. package/dist/control-write-retry.js +19 -18
  19. package/dist/control-write-retry.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/peer-addr-book.d.ts +92 -0
  24. package/dist/peer-addr-book.js +220 -0
  25. package/dist/peer-addr-book.js.map +1 -0
  26. package/dist/seed-bootstrap.d.ts +7 -0
  27. package/dist/seed-bootstrap.js +15 -13
  28. package/dist/seed-bootstrap.js.map +1 -1
  29. package/dist/strand-cohort.d.ts +4 -37
  30. package/dist/strand-cohort.js +4 -15
  31. package/dist/strand-cohort.js.map +1 -1
  32. package/dist/strand-database.d.ts +11 -16
  33. package/dist/strand-database.js +19 -7
  34. package/dist/strand-database.js.map +1 -1
  35. package/dist/strand-formation-manager.d.ts +8 -8
  36. package/dist/strand-formation-manager.js +15 -18
  37. package/dist/strand-formation-manager.js.map +1 -1
  38. package/dist/strand-instance-manager.d.ts +52 -21
  39. package/dist/strand-instance-manager.js +100 -34
  40. package/dist/strand-instance-manager.js.map +1 -1
  41. package/dist/strand-membership-writer.d.ts +6 -4
  42. package/dist/strand-membership-writer.js +38 -36
  43. package/dist/strand-membership-writer.js.map +1 -1
  44. package/dist/types.d.ts +39 -31
  45. package/dist/types.js.map +1 -1
  46. package/package.json +8 -7
  47. package/src/arachnode-stub.ts +159 -159
  48. package/src/bootstrap-peer-store-file.ts +44 -44
  49. package/src/bootstrap-peer-store.ts +172 -172
  50. package/src/cadre-node.ts +5158 -4771
  51. package/src/canonical-json.ts +30 -30
  52. package/src/control-authorization.ts +114 -114
  53. package/src/control-cohort.ts +130 -103
  54. package/src/control-database.ts +2406 -2506
  55. package/src/control-formation-recorder.ts +305 -304
  56. package/src/control-schema.ts +801 -786
  57. package/src/control-stream.ts +205 -205
  58. package/src/control-write-retry.ts +483 -473
  59. package/src/delegate-admission.ts +302 -302
  60. package/src/device-token.ts +94 -94
  61. package/src/diagnostics/connection-path.ts +277 -277
  62. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  63. package/src/ed25519-key.ts +134 -134
  64. package/src/enrollment.ts +199 -199
  65. package/src/file-durable-slot.ts +61 -61
  66. package/src/formation-approval.ts +604 -604
  67. package/src/fs-atomic.ts +107 -107
  68. package/src/hibernation-manager.ts +437 -437
  69. package/src/identity-key.ts +109 -109
  70. package/src/index.ts +1 -0
  71. package/src/key-store-file.ts +132 -132
  72. package/src/key-store.ts +99 -99
  73. package/src/membership-connection-gater.ts +204 -204
  74. package/src/node-local-snapshot.ts +260 -260
  75. package/src/peer-addr-book.ts +250 -0
  76. package/src/peer-authorization.ts +259 -259
  77. package/src/peer-record.ts +164 -164
  78. package/src/push-credentials.ts +84 -84
  79. package/src/push-node.ts +64 -64
  80. package/src/push-notifier-apns.ts +247 -247
  81. package/src/push-notifier-fcm.ts +220 -220
  82. package/src/push-notifier-shared.ts +28 -28
  83. package/src/push-notifier.ts +52 -52
  84. package/src/relay-addrs.ts +111 -111
  85. package/src/relay-reservation.ts +823 -823
  86. package/src/seed-bootstrap.ts +15 -15
  87. package/src/seed-trust-policy.ts +140 -140
  88. package/src/strand-addr-protocol.ts +441 -441
  89. package/src/strand-backfill.ts +366 -366
  90. package/src/strand-cohort.ts +38 -77
  91. package/src/strand-database.ts +202 -204
  92. package/src/strand-formation-manager.ts +491 -494
  93. package/src/strand-formation-protocol.ts +692 -692
  94. package/src/strand-instance-manager.ts +108 -50
  95. package/src/strand-member-key.ts +39 -39
  96. package/src/strand-member-registry.ts +182 -182
  97. package/src/strand-membership-writer.ts +1432 -1430
  98. package/src/strand-solicitation.ts +472 -472
  99. package/src/strand-transport-key.ts +58 -58
  100. package/src/strand-wake-payload.ts +32 -32
  101. package/src/strand-wake-protocol.ts +314 -314
  102. package/src/strand-watcher.ts +314 -314
  103. package/src/trusted-owner-store-file.ts +41 -41
  104. package/src/trusted-owner-store.ts +171 -171
  105. package/src/types.ts +1309 -1303
@@ -1,30 +1,30 @@
1
- /**
2
- * Canonical JSON serialization for signed payloads.
3
- *
4
- * Recursively sorts object keys, drops `undefined` values, emits no whitespace
5
- * and no trailing newline. Arrays preserve their order. This is a tiny RFC 8785
6
- * subset, sufficient for the fixed-shape payloads we sign (update manifests in
7
- * cadre-host, control-network seeds here).
8
- *
9
- * Lives in cadre-core because cadre-host depends on cadre-core (not the
10
- * reverse); cadre-host re-imports this rather than keeping its own copy.
11
- */
12
- export function canonicalJson(value: unknown): string {
13
- if (value === null) return 'null';
14
- if (typeof value === 'number') {
15
- if (!Number.isFinite(value)) throw new Error('cannot serialize non-finite number');
16
- return JSON.stringify(value);
17
- }
18
- if (typeof value === 'string' || typeof value === 'boolean') {
19
- return JSON.stringify(value);
20
- }
21
- if (Array.isArray(value)) {
22
- return '[' + value.map((v) => canonicalJson(v)).join(',') + ']';
23
- }
24
- if (typeof value === 'object') {
25
- const obj = value as Record<string, unknown>;
26
- const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort();
27
- return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}';
28
- }
29
- throw new Error(`cannot serialize value of type ${typeof value}`);
30
- }
1
+ /**
2
+ * Canonical JSON serialization for signed payloads.
3
+ *
4
+ * Recursively sorts object keys, drops `undefined` values, emits no whitespace
5
+ * and no trailing newline. Arrays preserve their order. This is a tiny RFC 8785
6
+ * subset, sufficient for the fixed-shape payloads we sign (update manifests in
7
+ * cadre-host, control-network seeds here).
8
+ *
9
+ * Lives in cadre-core because cadre-host depends on cadre-core (not the
10
+ * reverse); cadre-host re-imports this rather than keeping its own copy.
11
+ */
12
+ export function canonicalJson(value: unknown): string {
13
+ if (value === null) return 'null';
14
+ if (typeof value === 'number') {
15
+ if (!Number.isFinite(value)) throw new Error('cannot serialize non-finite number');
16
+ return JSON.stringify(value);
17
+ }
18
+ if (typeof value === 'string' || typeof value === 'boolean') {
19
+ return JSON.stringify(value);
20
+ }
21
+ if (Array.isArray(value)) {
22
+ return '[' + value.map((v) => canonicalJson(v)).join(',') + ']';
23
+ }
24
+ if (typeof value === 'object') {
25
+ const obj = value as Record<string, unknown>;
26
+ const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort();
27
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}';
28
+ }
29
+ throw new Error(`cannot serialize value of type ${typeof value}`);
30
+ }
@@ -1,114 +1,114 @@
1
- /**
2
- * The single definition of the field vector every signed control-plane message
3
- * covers. Both producers of signed bytes — `control-database.ts`'s
4
- * `buildAuthorizationMessage` (raw digest bytes) and `peer-authorization.ts`'s
5
- * digest helpers (base64url digest strings) — build their vector here, so the
6
- * byte layout cannot drift between them or away from the SQL constraints in
7
- * `schemas/control.qsql`.
8
- *
9
- * Every vector leads with two fixed literals:
10
- *
11
- * digest(<domain>, <action>, <row field 1>, ..., <row field n>)
12
- *
13
- * so a signature verifies ONLY against the one rule it was minted for. Without
14
- * the tags, several rules built byte-identical tuples (e.g. `ValidationKey`
15
- * insert and `OwnerKey` insert both signed `digest(Key, StampId)`), so an
16
- * approval for a narrow grant doubled as an approval for full ownership.
17
- *
18
- * This module deliberately has no Quereus / Optimystic / libp2p imports so the
19
- * lightweight verifiers (`peer-authorization.ts`, consumed by the offline
20
- * `cadre enroll register` check) can use it without pulling in the runtime.
21
- */
22
-
23
- /**
24
- * Names of the CadreControl tables, in schema order. The single list: the
25
- * {@link ControlTable} union is derived from it and `ControlDatabase.countRows`
26
- * guards its dynamic `from` clause against it (keeping the table name off the
27
- * SQL-injection surface), so a new table cannot be added to one and missed in
28
- * the other.
29
- *
30
- * `Revocation` is here for the same two reasons as every other entry: it
31
- * derives a `'CadreControl.Revocation'` domain tag — its `Authorized` CHECK
32
- * verifies an owner signature over the `'remove'`-tagged digest that
33
- * `peer-authorization.ts`'s `revocationDigest` mints, and its `AuthorizedReissue`
34
- * CHECK the `'reissue'`-tagged digest `ControlDatabase.reissueRevocations`
35
- * signs — and `countRows` counts it.
36
- */
37
- export const CONTROL_TABLES = [
38
- 'OwnerKey',
39
- 'ValidationKey',
40
- 'Strand',
41
- 'CadrePeer',
42
- 'DeviceToken',
43
- 'FormationInvite',
44
- 'FormationUsage',
45
- 'Revocation',
46
- ] as const;
47
-
48
- /**
49
- * Name of a CadreControl table. Doubles as the table half of a
50
- * {@link ControlDomain} tag.
51
- */
52
- export type ControlTable = typeof CONTROL_TABLES[number];
53
-
54
- /**
55
- * The control tables whose rows carry a single-use `StampId` retired into
56
- * `CadreControl.Revocation` on delete — i.e. the ones the schema's
57
- * `NotRevoked` / `RevocationRecorded` CHECK pair guards, and the only values
58
- * `Revocation.RowIsGone` accepts in `TableName`. `Extract` from
59
- * {@link ControlTable} rather than a fresh literal list, so a renamed table is a
60
- * compile error here instead of a silently dead branch.
61
- *
62
- * Lives in this import-free module rather than beside its main consumer
63
- * (`control-database.ts`) so the lightweight signers — `peer-authorization.ts`'s
64
- * `revocationDigest` — can type against it without pulling in the runtime.
65
- */
66
- export type RevocableTable = Extract<ControlTable, 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'DeviceToken'>;
67
-
68
- /**
69
- * What a signature authorizes, scoped to one table rule — or, for
70
- * `'Cadre.Enrollment'`, the offline peer vouch that `cadre enroll register`
71
- * verifies (no table checks it, so it needs its own domain to stay disjoint
72
- * from every table rule).
73
- */
74
- export type ControlDomain = `CadreControl.${ControlTable}` | 'Cadre.Enrollment';
75
-
76
- /**
77
- * The action half of the tag:
78
- * - `'add'` / `'remove'` — insert / delete of the named row.
79
- * - `'vouch'` — an owner (or validation key) vouches the row's semantics
80
- * without adding or removing it: the `CadrePeer` membership vouch (shared,
81
- * deliberately, by its insert and its owner-update branch), the
82
- * `FormationUsage` disclosure validation, and the offline enrollment vouch.
83
- * `DeviceToken` has no `'vouch'` digest — its owner re-touch branch was
84
- * removed (it rewrote the row outside the monotonicity guard), so an owner
85
- * correcting a token deletes and re-inserts it.
86
- * - `'publish'` — a peer self-signs its OWN record (the `CadrePeer` /
87
- * `DeviceToken` self-update branches), with its own key rather than an
88
- * owner key.
89
- * - `'consent'` — a peer self-signs its OWN `FormationUsage` redemption (the
90
- * joiner proving it agreed to join), with its own key. Distinct from the
91
- * approver's `'vouch'` over the same table so the two stored signatures are
92
- * never interchangeable.
93
- * - `'reissue'` — an owner re-writes an existing `Revocation` tombstone,
94
- * bumping its `ReissuedAt` counter so a tombstone committed while the node
95
- * was alone can be re-broadcast. Distinct from `'remove'` so a tombstone
96
- * append approval can never be replayed as a re-issue and vice versa.
97
- */
98
- export type ControlAction = 'add' | 'remove' | 'vouch' | 'publish' | 'consent' | 'reissue';
99
-
100
- /**
101
- * The full ordered field vector a control-plane signature covers. Digest this
102
- * with the crypto plugin's injective multi-field encoding (every field TEXT);
103
- * the SQL mirror passes the same literals as leading `digest(...)` arguments:
104
- *
105
- * TS: digest(controlAuthorizationFields('CadreControl.X', 'add', [a, b]), 'sha256', ...)
106
- * SQL: digest('CadreControl.X', 'add', new.A, new.B)
107
- */
108
- export function controlAuthorizationFields(
109
- domain: ControlDomain,
110
- action: ControlAction,
111
- rowFields: string[],
112
- ): string[] {
113
- return [domain, action, ...rowFields];
114
- }
1
+ /**
2
+ * The single definition of the field vector every signed control-plane message
3
+ * covers. Both producers of signed bytes — `control-database.ts`'s
4
+ * `buildAuthorizationMessage` (raw digest bytes) and `peer-authorization.ts`'s
5
+ * digest helpers (base64url digest strings) — build their vector here, so the
6
+ * byte layout cannot drift between them or away from the SQL constraints in
7
+ * `schemas/control.qsql`.
8
+ *
9
+ * Every vector leads with two fixed literals:
10
+ *
11
+ * digest(<domain>, <action>, <row field 1>, ..., <row field n>)
12
+ *
13
+ * so a signature verifies ONLY against the one rule it was minted for. Without
14
+ * the tags, several rules built byte-identical tuples (e.g. `ValidationKey`
15
+ * insert and `OwnerKey` insert both signed `digest(Key, StampId)`), so an
16
+ * approval for a narrow grant doubled as an approval for full ownership.
17
+ *
18
+ * This module deliberately has no Quereus / Optimystic / libp2p imports so the
19
+ * lightweight verifiers (`peer-authorization.ts`, consumed by the offline
20
+ * `cadre enroll register` check) can use it without pulling in the runtime.
21
+ */
22
+
23
+ /**
24
+ * Names of the CadreControl tables, in schema order. The single list: the
25
+ * {@link ControlTable} union is derived from it and `ControlDatabase.countRows`
26
+ * guards its dynamic `from` clause against it (keeping the table name off the
27
+ * SQL-injection surface), so a new table cannot be added to one and missed in
28
+ * the other.
29
+ *
30
+ * `Revocation` is here for the same two reasons as every other entry: it
31
+ * derives a `'CadreControl.Revocation'` domain tag — its `Authorized` CHECK
32
+ * verifies an owner signature over the `'remove'`-tagged digest that
33
+ * `peer-authorization.ts`'s `revocationDigest` mints, and its `AuthorizedReissue`
34
+ * CHECK the `'reissue'`-tagged digest `ControlDatabase.reissueRevocations`
35
+ * signs — and `countRows` counts it.
36
+ */
37
+ export const CONTROL_TABLES = [
38
+ 'OwnerKey',
39
+ 'ValidationKey',
40
+ 'Strand',
41
+ 'CadrePeer',
42
+ 'DeviceToken',
43
+ 'FormationInvite',
44
+ 'FormationUsage',
45
+ 'Revocation',
46
+ ] as const;
47
+
48
+ /**
49
+ * Name of a CadreControl table. Doubles as the table half of a
50
+ * {@link ControlDomain} tag.
51
+ */
52
+ export type ControlTable = typeof CONTROL_TABLES[number];
53
+
54
+ /**
55
+ * The control tables whose rows carry a single-use `StampId` retired into
56
+ * `CadreControl.Revocation` on delete — i.e. the ones the schema's
57
+ * `NotRevoked` / `RevocationRecorded` CHECK pair guards, and the only values
58
+ * `Revocation.RowIsGone` accepts in `TableName`. `Extract` from
59
+ * {@link ControlTable} rather than a fresh literal list, so a renamed table is a
60
+ * compile error here instead of a silently dead branch.
61
+ *
62
+ * Lives in this import-free module rather than beside its main consumer
63
+ * (`control-database.ts`) so the lightweight signers — `peer-authorization.ts`'s
64
+ * `revocationDigest` — can type against it without pulling in the runtime.
65
+ */
66
+ export type RevocableTable = Extract<ControlTable, 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'DeviceToken'>;
67
+
68
+ /**
69
+ * What a signature authorizes, scoped to one table rule — or, for
70
+ * `'Cadre.Enrollment'`, the offline peer vouch that `cadre enroll register`
71
+ * verifies (no table checks it, so it needs its own domain to stay disjoint
72
+ * from every table rule).
73
+ */
74
+ export type ControlDomain = `CadreControl.${ControlTable}` | 'Cadre.Enrollment';
75
+
76
+ /**
77
+ * The action half of the tag:
78
+ * - `'add'` / `'remove'` — insert / delete of the named row.
79
+ * - `'vouch'` — an owner (or validation key) vouches the row's semantics
80
+ * without adding or removing it: the `CadrePeer` membership vouch (shared,
81
+ * deliberately, by its insert and its owner-update branch), the
82
+ * `FormationUsage` disclosure validation, and the offline enrollment vouch.
83
+ * `DeviceToken` has no `'vouch'` digest — its owner re-touch branch was
84
+ * removed (it rewrote the row outside the monotonicity guard), so an owner
85
+ * correcting a token deletes and re-inserts it.
86
+ * - `'publish'` — a peer self-signs its OWN record (the `CadrePeer` /
87
+ * `DeviceToken` self-update branches), with its own key rather than an
88
+ * owner key.
89
+ * - `'consent'` — a peer self-signs its OWN `FormationUsage` redemption (the
90
+ * joiner proving it agreed to join), with its own key. Distinct from the
91
+ * approver's `'vouch'` over the same table so the two stored signatures are
92
+ * never interchangeable.
93
+ * - `'reissue'` — an owner re-writes an existing `Revocation` tombstone,
94
+ * bumping its `ReissuedAt` counter so a tombstone committed while the node
95
+ * was alone can be re-broadcast. Distinct from `'remove'` so a tombstone
96
+ * append approval can never be replayed as a re-issue and vice versa.
97
+ */
98
+ export type ControlAction = 'add' | 'remove' | 'vouch' | 'publish' | 'consent' | 'reissue';
99
+
100
+ /**
101
+ * The full ordered field vector a control-plane signature covers. Digest this
102
+ * with the crypto plugin's injective multi-field encoding (every field TEXT);
103
+ * the SQL mirror passes the same literals as leading `digest(...)` arguments:
104
+ *
105
+ * TS: digest(controlAuthorizationFields('CadreControl.X', 'add', [a, b]), 'sha256', ...)
106
+ * SQL: digest('CadreControl.X', 'add', new.A, new.B)
107
+ */
108
+ export function controlAuthorizationFields(
109
+ domain: ControlDomain,
110
+ action: ControlAction,
111
+ rowFields: string[],
112
+ ): string[] {
113
+ return [domain, action, ...rowFields];
114
+ }
@@ -1,103 +1,130 @@
1
- /**
2
- * Control-cohort dial selection: the pure policy behind
3
- * {@link CadreNode.reconcileControlCohort}. Decides WHICH known cadre siblings a
4
- * node proactively dials each pass to keep the `CadreControl` collections' cohort
5
- * connected (and thus replicating), without devolving into an N² mesh.
6
- *
7
- * FRET only needs a *connected* graph with the block clusters reachable, not a
8
- * full mesh. Storage/owner nodes are the stable, publicly-dialable backbone
9
- * that hold the control blocks, so the policy is **backbone-preferential with a
10
- * bounded out-degree**:
11
- *
12
- * - **Always dial every backbone (owner) member** — the owner set is
13
- * small by design, and routing cohort formation through publicly-dialable
14
- * owner nodes keeps the cohort ≥2 where owner-signed writes originate.
15
- * - **Fill the remainder** up to `targetDegree` with non-owner members,
16
- * ordered deterministically (peerId sort) so every node makes the same stable
17
- * choice across passes and load spreads predictably.
18
- *
19
- * For a small party (members ≤ degree) this degenerates to a full mesh, which is
20
- * correct, cheap, and matches what the convergence tests do by hand; for a large
21
- * party it bounds out-degree to `backbone + targetDegree`.
22
- */
23
-
24
- import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
25
- import type { CohortPeerRow } from './strand-cohort.js';
26
-
27
- /**
28
- * Default recurring cadence for {@link CadreNode.reconcileControlCohort} (ms).
29
- * In the same spirit as the 5s strand-watch poll but lighter, since a control
30
- * cohort changes far less often than strand activity.
31
- */
32
- export const DEFAULT_CONTROL_COHORT_RECONCILE_MS = 15_000;
33
-
34
- /**
35
- * Default cap on the number of **non-owner** members a single reconcile pass
36
- * proactively dials. Backbone (owner) members are always dialed and do NOT
37
- * count against this cap.
38
- */
39
- export const DEFAULT_CONTROL_COHORT_TARGET_DEGREE = 6;
40
-
41
- /** Outcome of {@link selectControlCohortDials}: who to dial + what the cap dropped. */
42
- export interface ControlCohortSelection {
43
- /**
44
- * Siblings to dial this pass, backbone first then the bounded non-owner
45
- * fill. Already self-excluded (the caller must pass self-excluded `siblings`).
46
- * Still includes peers that may turn out to be already-connected — the caller
47
- * diffs against live connections separately.
48
- */
49
- dials: CohortPeerRow[];
50
- /** Non-owner members dropped by the `targetDegree` cap (0 when none). */
51
- cappedNonOwner: number;
52
- }
53
-
54
- /** Stable peerId ordering so the bounded fill is identical across passes/nodes. */
55
- function byPeerId(a: CohortPeerRow, b: CohortPeerRow): number {
56
- if (a.peerId < b.peerId) return -1;
57
- if (a.peerId > b.peerId) return 1;
58
- return 0;
59
- }
60
-
61
- /**
62
- * Backbone-preferential, bounded-out-degree selection of control-cohort siblings
63
- * to dial.
64
- *
65
- * A member is **backbone** iff the ed25519 key derived from its peerId
66
- * (see {@link ed25519PublicKeyB64FromPeerId}) is in `ownerKeys`. All backbone
67
- * members are selected; non-owner members fill up to `targetDegree` in
68
- * deterministic peerId order.
69
- *
70
- * @param siblings - cadre members EXCLUDING self (the caller filters self out).
71
- * @param ownerKeys - the converged `OwnerKey` set; may be empty/partial
72
- * before owner convergence, in which case no member classifies as backbone
73
- * yet and the bounded fill still makes progress (preference sharpens later).
74
- * @param targetDegree - cap on non-owner dials (negative is treated as 0).
75
- */
76
- export function selectControlCohortDials(
77
- siblings: CohortPeerRow[],
78
- ownerKeys: ReadonlySet<string>,
79
- targetDegree: number,
80
- ): ControlCohortSelection {
81
- const backbone: CohortPeerRow[] = [];
82
- const rest: CohortPeerRow[] = [];
83
-
84
- for (const peer of siblings) {
85
- const pubKey = ed25519PublicKeyB64FromPeerId(peer.peerId);
86
- if (pubKey !== null && ownerKeys.has(pubKey)) {
87
- backbone.push(peer);
88
- } else {
89
- rest.push(peer);
90
- }
91
- }
92
-
93
- backbone.sort(byPeerId);
94
- rest.sort(byPeerId);
95
-
96
- const cap = Math.max(0, targetDegree);
97
- const fill = rest.slice(0, cap);
98
-
99
- return {
100
- dials: [...backbone, ...fill],
101
- cappedNonOwner: rest.length - fill.length,
102
- };
103
- }
1
+ /**
2
+ * Control-cohort dial selection: the pure policy behind
3
+ * {@link CadreNode.reconcileControlCohort}. Decides WHICH known cadre siblings a
4
+ * node proactively dials each pass to keep the `CadreControl` collections' cohort
5
+ * connected (and thus replicating), without devolving into an N² mesh.
6
+ *
7
+ * FRET only needs a *connected* graph with the block clusters reachable, not a
8
+ * full mesh. Storage/owner nodes are the stable, publicly-dialable backbone
9
+ * that hold the control blocks, so the policy is **backbone-preferential with a
10
+ * bounded out-degree**:
11
+ *
12
+ * - **Always dial every backbone (owner) member** — the owner set is
13
+ * small by design, and routing cohort formation through publicly-dialable
14
+ * owner nodes keeps the cohort ≥2 where owner-signed writes originate.
15
+ * - **Fill the remainder** up to `targetDegree` with non-owner members,
16
+ * ordered deterministically (peerId sort) so every node makes the same stable
17
+ * choice across passes and load spreads predictably.
18
+ *
19
+ * For a small party (members ≤ degree) this degenerates to a full mesh, which is
20
+ * correct, cheap, and matches what the convergence tests do by hand; for a large
21
+ * party it bounds out-degree to `backbone + targetDegree`.
22
+ */
23
+
24
+ import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
25
+ import type { CohortPeerRow } from './strand-cohort.js';
26
+
27
+ /**
28
+ * Default recurring cadence for {@link CadreNode.reconcileControlCohort} (ms).
29
+ * In the same spirit as the 5s strand-watch poll but lighter, since a control
30
+ * cohort changes far less often than strand activity.
31
+ */
32
+ export const DEFAULT_CONTROL_COHORT_RECONCILE_MS = 15_000;
33
+
34
+ /**
35
+ * Default cap on the number of **non-owner** members a single reconcile pass
36
+ * proactively dials. Backbone (owner) members are always dialed and do NOT
37
+ * count against this cap.
38
+ */
39
+ export const DEFAULT_CONTROL_COHORT_TARGET_DEGREE = 6;
40
+
41
+ /**
42
+ * Default per-sibling budget for one proactive control-cohort dial, in ms.
43
+ *
44
+ * `libp2p.dial(addrs)` is handed EVERY address a sibling resolved to, and each
45
+ * one carries its own attempt timeout (js-libp2p's default is ~10 s, and
46
+ * `@optimystic/db-p2p` does not override it). Some addresses cost more than one
47
+ * attempt on their own: a `…/p2p-circuit/webrtc/…` address makes
48
+ * `@libp2p/webrtc` dial the signalling leg to the relay hop FIRST and only then
49
+ * the peer, so a two-address WebRTC sibling can block the sequential dial loop
50
+ * for four attempt timeouts. Against unroutable addresses none of those attempts
51
+ * ever answers, so an offline sibling's cost is address fan-out × relay hops ×
52
+ * ~10 s — a number nothing in this package chose or bounds.
53
+ *
54
+ * This budget makes the SIBLING the unit instead of the address: one pass costs
55
+ * at most (dialed siblings) × this, whatever each sibling's addresses look like.
56
+ * Deliberately wider than one attempt timeout so a reachable sibling whose first
57
+ * address is stale still gets a genuine try at its later ones; a sibling that
58
+ * needs longer than this is simply retried next pass (every
59
+ * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS}), which is what the dial loop's
60
+ * best-effort contract already does with any other dial failure.
61
+ *
62
+ * Override per node with `network.controlCohort.dialTimeoutMs` — tests that
63
+ * drive dead addresses on purpose set it low so a pass's duration is a chosen
64
+ * number rather than a transitive libp2p default stretched by machine load.
65
+ */
66
+ export const DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS = 20_000;
67
+
68
+ /** Outcome of {@link selectControlCohortDials}: who to dial + what the cap dropped. */
69
+ export interface ControlCohortSelection {
70
+ /**
71
+ * Siblings to dial this pass, backbone first then the bounded non-owner
72
+ * fill. Already self-excluded (the caller must pass self-excluded `siblings`).
73
+ * Still includes peers that may turn out to be already-connected — the caller
74
+ * diffs against live connections separately.
75
+ */
76
+ dials: CohortPeerRow[];
77
+ /** Non-owner members dropped by the `targetDegree` cap (0 when none). */
78
+ cappedNonOwner: number;
79
+ }
80
+
81
+ /** Stable peerId ordering so the bounded fill is identical across passes/nodes. */
82
+ function byPeerId(a: CohortPeerRow, b: CohortPeerRow): number {
83
+ if (a.peerId < b.peerId) return -1;
84
+ if (a.peerId > b.peerId) return 1;
85
+ return 0;
86
+ }
87
+
88
+ /**
89
+ * Backbone-preferential, bounded-out-degree selection of control-cohort siblings
90
+ * to dial.
91
+ *
92
+ * A member is **backbone** iff the ed25519 key derived from its peerId
93
+ * (see {@link ed25519PublicKeyB64FromPeerId}) is in `ownerKeys`. All backbone
94
+ * members are selected; non-owner members fill up to `targetDegree` in
95
+ * deterministic peerId order.
96
+ *
97
+ * @param siblings - cadre members EXCLUDING self (the caller filters self out).
98
+ * @param ownerKeys - the converged `OwnerKey` set; may be empty/partial
99
+ * before owner convergence, in which case no member classifies as backbone
100
+ * yet and the bounded fill still makes progress (preference sharpens later).
101
+ * @param targetDegree - cap on non-owner dials (negative is treated as 0).
102
+ */
103
+ export function selectControlCohortDials(
104
+ siblings: CohortPeerRow[],
105
+ ownerKeys: ReadonlySet<string>,
106
+ targetDegree: number,
107
+ ): ControlCohortSelection {
108
+ const backbone: CohortPeerRow[] = [];
109
+ const rest: CohortPeerRow[] = [];
110
+
111
+ for (const peer of siblings) {
112
+ const pubKey = ed25519PublicKeyB64FromPeerId(peer.peerId);
113
+ if (pubKey !== null && ownerKeys.has(pubKey)) {
114
+ backbone.push(peer);
115
+ } else {
116
+ rest.push(peer);
117
+ }
118
+ }
119
+
120
+ backbone.sort(byPeerId);
121
+ rest.sort(byPeerId);
122
+
123
+ const cap = Math.max(0, targetDegree);
124
+ const fill = rest.slice(0, cap);
125
+
126
+ return {
127
+ dials: [...backbone, ...fill],
128
+ cappedNonOwner: rest.length - fill.length,
129
+ };
130
+ }