@serfab/cadre-core 0.10.0 → 0.12.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 (145) hide show
  1. package/README.md +255 -255
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +484 -58
  6. package/dist/cadre-node.js +953 -182
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +26 -0
  9. package/dist/control-cohort.js +26 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +222 -125
  12. package/dist/control-database.js +356 -266
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +8 -7
  15. package/dist/control-formation-recorder.js +8 -7
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +103 -0
  18. package/dist/control-read-retry.js +145 -0
  19. package/dist/control-read-retry.js.map +1 -0
  20. package/dist/control-retry.d.ts +101 -0
  21. package/dist/control-retry.js +108 -0
  22. package/dist/control-retry.js.map +1 -0
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +805 -774
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +65 -31
  27. package/dist/control-write-retry.js +33 -103
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +17 -15
  30. package/dist/delegate-admission.js +17 -15
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/index.d.ts +7 -5
  33. package/dist/index.js +18 -9
  34. package/dist/index.js.map +1 -1
  35. package/dist/membership-connection-gater.d.ts +211 -25
  36. package/dist/membership-connection-gater.js +309 -29
  37. package/dist/membership-connection-gater.js.map +1 -1
  38. package/dist/peer-addr-book.d.ts +92 -0
  39. package/dist/peer-addr-book.js +220 -0
  40. package/dist/peer-addr-book.js.map +1 -0
  41. package/dist/peer-record.d.ts +26 -0
  42. package/dist/peer-record.js +32 -0
  43. package/dist/peer-record.js.map +1 -1
  44. package/dist/relay-addrs.d.ts +76 -26
  45. package/dist/relay-addrs.js +115 -27
  46. package/dist/relay-addrs.js.map +1 -1
  47. package/dist/relay-reservation.d.ts +20 -15
  48. package/dist/relay-reservation.js +18 -14
  49. package/dist/relay-reservation.js.map +1 -1
  50. package/dist/seed-bootstrap.d.ts +7 -0
  51. package/dist/seed-bootstrap.js +15 -13
  52. package/dist/seed-bootstrap.js.map +1 -1
  53. package/dist/strand-addr-protocol.js +16 -0
  54. package/dist/strand-addr-protocol.js.map +1 -1
  55. package/dist/strand-backfill.js +13 -4
  56. package/dist/strand-backfill.js.map +1 -1
  57. package/dist/strand-cohort.d.ts +4 -37
  58. package/dist/strand-cohort.js +4 -15
  59. package/dist/strand-cohort.js.map +1 -1
  60. package/dist/strand-database.d.ts +11 -16
  61. package/dist/strand-database.js +19 -7
  62. package/dist/strand-database.js.map +1 -1
  63. package/dist/strand-formation-manager.d.ts +18 -17
  64. package/dist/strand-formation-manager.js +25 -27
  65. package/dist/strand-formation-manager.js.map +1 -1
  66. package/dist/strand-formation-protocol.d.ts +7 -7
  67. package/dist/strand-formation-protocol.js +8 -8
  68. package/dist/strand-formation-protocol.js.map +1 -1
  69. package/dist/strand-instance-manager.d.ts +53 -21
  70. package/dist/strand-instance-manager.js +128 -37
  71. package/dist/strand-instance-manager.js.map +1 -1
  72. package/dist/strand-membership-writer.d.ts +113 -19
  73. package/dist/strand-membership-writer.js +219 -51
  74. package/dist/strand-membership-writer.js.map +1 -1
  75. package/dist/strand-solicitation.d.ts +3 -3
  76. package/dist/strand-solicitation.js +4 -5
  77. package/dist/strand-solicitation.js.map +1 -1
  78. package/dist/strand-wake-protocol.d.ts +47 -2
  79. package/dist/strand-wake-protocol.js +76 -6
  80. package/dist/strand-wake-protocol.js.map +1 -1
  81. package/dist/types.d.ts +107 -54
  82. package/dist/types.js.map +1 -1
  83. package/package.json +8 -7
  84. package/src/announce-addrs.ts +93 -0
  85. package/src/arachnode-stub.ts +159 -159
  86. package/src/bootstrap-peer-store-file.ts +44 -44
  87. package/src/bootstrap-peer-store.ts +172 -172
  88. package/src/cadre-node.ts +1026 -194
  89. package/src/canonical-json.ts +30 -30
  90. package/src/control-authorization.ts +114 -114
  91. package/src/control-cohort.ts +130 -103
  92. package/src/control-database.ts +376 -298
  93. package/src/control-formation-recorder.ts +305 -304
  94. package/src/control-read-retry.ts +167 -0
  95. package/src/control-retry.ts +190 -0
  96. package/src/control-schema.ts +817 -786
  97. package/src/control-stream.ts +205 -205
  98. package/src/control-write-retry.ts +383 -473
  99. package/src/delegate-admission.ts +304 -302
  100. package/src/device-token.ts +94 -94
  101. package/src/diagnostics/connection-path.ts +277 -277
  102. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  103. package/src/ed25519-key.ts +134 -134
  104. package/src/enrollment.ts +199 -199
  105. package/src/file-durable-slot.ts +61 -61
  106. package/src/formation-approval.ts +604 -604
  107. package/src/fs-atomic.ts +107 -107
  108. package/src/hibernation-manager.ts +437 -437
  109. package/src/identity-key.ts +109 -109
  110. package/src/index.ts +435 -406
  111. package/src/key-store-file.ts +132 -132
  112. package/src/key-store.ts +99 -99
  113. package/src/membership-connection-gater.ts +368 -39
  114. package/src/node-local-snapshot.ts +260 -260
  115. package/src/peer-addr-book.ts +250 -0
  116. package/src/peer-authorization.ts +259 -259
  117. package/src/peer-record.ts +197 -164
  118. package/src/push-credentials.ts +84 -84
  119. package/src/push-node.ts +64 -64
  120. package/src/push-notifier-apns.ts +247 -247
  121. package/src/push-notifier-fcm.ts +220 -220
  122. package/src/push-notifier-shared.ts +28 -28
  123. package/src/push-notifier.ts +52 -52
  124. package/src/relay-addrs.ts +134 -27
  125. package/src/relay-reservation.ts +22 -17
  126. package/src/seed-bootstrap.ts +15 -15
  127. package/src/seed-trust-policy.ts +140 -140
  128. package/src/strand-addr-protocol.ts +458 -441
  129. package/src/strand-backfill.ts +28 -5
  130. package/src/strand-cohort.ts +38 -77
  131. package/src/strand-database.ts +202 -204
  132. package/src/strand-formation-manager.ts +26 -28
  133. package/src/strand-formation-protocol.ts +692 -692
  134. package/src/strand-instance-manager.ts +636 -553
  135. package/src/strand-member-key.ts +39 -39
  136. package/src/strand-member-registry.ts +182 -182
  137. package/src/strand-membership-writer.ts +1627 -1430
  138. package/src/strand-solicitation.ts +471 -472
  139. package/src/strand-transport-key.ts +58 -58
  140. package/src/strand-wake-payload.ts +32 -32
  141. package/src/strand-wake-protocol.ts +400 -314
  142. package/src/strand-watcher.ts +314 -314
  143. package/src/trusted-owner-store-file.ts +41 -41
  144. package/src/trusted-owner-store.ts +171 -171
  145. package/src/types.ts +1354 -1303
@@ -1,32 +1,49 @@
1
1
  /**
2
2
  * Translate `network.relayAddrs` — the operator-facing "use these relay servers"
3
- * setting — into the mechanism this repo already has for reserving a circuit-relay
4
- * slot: a `…/p2p/<relayPeerId>/p2p-circuit` entry in the node's listen addrs.
3
+ * setting — into the listen entry a node needs before it can hold a circuit-relay
4
+ * slot, and validate the operator's entries while doing it.
5
5
  *
6
- * `@libp2p/circuit-relay-v2`'s transport is always present on the Node path
7
- * (`../optimystic/packages/db-p2p/src/libp2p-node.ts` pushes `circuitRelayTransport()`
8
- * unconditionally), and listening on a relay's circuit addr is what makes libp2p dial
9
- * that relay and hold a reservation. Everything downstream of the listen list —
10
- * the reservation itself, `CadreNode.circuitRelayTargets()` picking delegate-announce
11
- * targets, strand nodes inheriting the listen addrs, the `/p2p-circuit`-first address
12
- * publishing in `registerSelf` — then works unchanged.
6
+ * `@libp2p/circuit-relay-v2`'s listener branches on the SHAPE of the listen addr,
7
+ * and the same `relayAddrs` list resolves to either shape depending on WHO is
8
+ * listening ({@link RelayListenRoute}):
13
9
  *
14
- * So `relayAddrs` is a second, friendlier door onto an existing mechanism:
10
+ * | route | listen entry | libp2p behaviour |
11
+ * | -------------- | ------------------------------------------- | --------------------------------------------------------- |
12
+ * | `'configured'` | `<relayAddr>/p2p-circuit`, one per relay | dial that relay from inside `listen()` and reserve, or throw |
13
+ * | `'search'` | a single bare `/p2p-circuit` | register a pending reservation; open no connection |
15
14
  *
16
- * effective listen addrs = listenAddrs ++ relayAddrs.map(a => a + '/p2p-circuit')
15
+ * The CONTROL node takes the `'search'` route, and `CadreNode.start()` drives the
16
+ * reservation explicitly (`relay-reservation.ts`) once the control database is up.
17
+ * That ordering is the point: a configured circuit listener dials the relay from
18
+ * inside `libp2p.start()`, so the control database was being built while a sibling
19
+ * relay was already in this node's cohort — and a sibling that has not yet
20
+ * replicated this node's `CadrePeer` row correctly refuses its control-DB streams,
21
+ * which killed `start()` outright. See `cadre-node.ts` → `start()`.
17
22
  *
18
- * NOTE: this is the FAIL-FAST route to a reservation — libp2p's transport manager
19
- * throws when a configured circuit listener cannot listen, so naming a relay that
20
- * is down means the node does not start. The fail-soft route is the bare
21
- * `/p2p-circuit` search listener driven by `relay-reservation.ts`. The two are
22
- * ALTERNATIVES, not layers: configuring `relayAddrs` on a node that already
23
- * reserves via the search listener adds the fatal listener back and the node
24
- * stops booting when its relay is down.
23
+ * STRAND nodes keep the `'configured'` route (`strand-instance-manager.ts`): they
24
+ * inherit the control node's `NetworkConfig`, nothing drives an explicit
25
+ * reservation for them, and their relay connection cannot disturb a control-DB
26
+ * bring-up (a strand node's protocol ids are namespaced `/optimystic/strand-<id>/…`,
27
+ * so the relay is never in its cohort).
28
+ *
29
+ * `network.relayAddrs` is FAIL-FAST for the operator on both routes, just from
30
+ * different places: a malformed entry throws here at config resolution, and on the
31
+ * control node a first reservation attempt that does not land throws out of
32
+ * `start()` (`RelayReservationFailedError`). Naming a relay that is down still
33
+ * means the node does not come up.
34
+ *
35
+ * The bare `/p2p-circuit` search listener cannot open a connection on its own:
36
+ * libp2p fills a pending reservation from `RelayDiscovery`, which nominates a peer
37
+ * only once the relay-hop protocol id is in that peer's peer-store protocol list,
38
+ * and that list is written exclusively by IDENTIFY — which `@optimystic/db-p2p`
39
+ * namespaces per network, so a cadre node and a stock relay never identify each
40
+ * other. See `relay-reservation.ts`'s module doc and `docs/architecture.md`.
25
41
  */
26
42
 
27
43
  import { multiaddr, type Component } from '@multiformats/multiaddr';
28
44
  import { circuitRelayTargetOrThrow } from './delegate-admission.js';
29
45
  import type { NetworkConfig } from './types.js';
46
+ import type { RelayReservationState } from './relay-reservation.js';
30
47
 
31
48
  /**
32
49
  * The direct listener a node keeps when it configures `relayAddrs` but no
@@ -40,6 +57,21 @@ import type { NetworkConfig } from './types.js';
40
57
  */
41
58
  const DEFAULT_DIRECT_LISTEN_ADDR = '/ip4/0.0.0.0/tcp/0';
42
59
 
60
+ /**
61
+ * libp2p's relay SEARCH listener: registers one pending reservation and dials
62
+ * nothing. One entry covers every configured relay — the pending reservation is
63
+ * per-listener, not per-relay, and `relay-reservation.ts` fills it with whichever
64
+ * relay answers first.
65
+ */
66
+ export const RELAY_SEARCH_LISTEN_ADDR = '/p2p-circuit';
67
+
68
+ /**
69
+ * Which listener shape `network.relayAddrs` resolves to for this caller — see the
70
+ * table in the module doc. Defaults to `'configured'` everywhere except the
71
+ * control node's own libp2p options.
72
+ */
73
+ export type RelayListenRoute = 'configured' | 'search';
74
+
43
75
  /**
44
76
  * The circuit-listen multiaddr for each configured relay: `<relayAddr>/p2p-circuit`.
45
77
  * An entry that already carries `/p2p-circuit` is passed through unchanged.
@@ -57,20 +89,95 @@ export function relayCircuitAddrs(relayAddrs: readonly string[]): string[] {
57
89
 
58
90
  /**
59
91
  * The listen multiaddrs a node actually binds: configured `listenAddrs` plus the
60
- * circuit-listen addr of every configured relay, deduplicated, order-stable
61
- * (configured entries first). Returns `undefined` when neither field is set, so
62
- * callers keep omitting `listenAddrs` and inherit db-p2p's default.
92
+ * relay listen entry `route` calls for, deduplicated, order-stable (configured
93
+ * entries first). Returns `undefined` when neither field is set, so callers keep
94
+ * omitting `listenAddrs` and inherit db-p2p's default.
95
+ *
96
+ * Every `relayAddrs` entry is validated on BOTH routes — the `'search'` route
97
+ * discards the resolved circuit addrs, but an operator typo must fail at config
98
+ * resolution either way.
63
99
  */
64
- export function resolveListenAddrs(network: NetworkConfig | undefined): string[] | undefined {
65
- const circuits = relayCircuitAddrs(network?.relayAddrs ?? []);
100
+ export function resolveListenAddrs(
101
+ network: NetworkConfig | undefined,
102
+ route: RelayListenRoute = 'configured'
103
+ ): string[] | undefined {
104
+ const configured = relayCircuitAddrs(network?.relayAddrs ?? []);
105
+ const relayEntries = route === 'search'
106
+ ? (configured.length > 0 ? [RELAY_SEARCH_LISTEN_ADDR] : [])
107
+ : configured;
66
108
  const listenAddrs = network?.listenAddrs;
67
- if (!listenAddrs && circuits.length === 0) {
109
+ if (route === 'search') {
110
+ rejectConfiguredCircuitListenAddrs(listenAddrs ?? []);
111
+ }
112
+ if (!listenAddrs && relayEntries.length === 0) {
68
113
  return undefined;
69
114
  }
70
115
  // An explicitly empty `listenAddrs` (the React Native "cannot listen" case) stays
71
- // empty — but a relay named alongside it still gets its circuit listener, since
72
- // acquiring one is the whole point of configuring a relay.
73
- return dedupe([...(listenAddrs ?? [DEFAULT_DIRECT_LISTEN_ADDR]), ...circuits]);
116
+ // empty — but a relay named alongside it still gets its listen entry, since
117
+ // acquiring a slot is the whole point of configuring a relay.
118
+ return dedupe([...(listenAddrs ?? [DEFAULT_DIRECT_LISTEN_ADDR]), ...relayEntries]);
119
+ }
120
+
121
+ /**
122
+ * Thrown out of `CadreNode.start()` when the boot-path reservation drive for
123
+ * `network.relayAddrs` produces no `/p2p-circuit` address on its FIRST attempt.
124
+ *
125
+ * This is what keeps `network.relayAddrs` fail-fast now that the control node
126
+ * takes the `'search'` route: libp2p's own `UnsupportedListenAddressesError` used
127
+ * to abort start from inside `listen()`, and an operator who names a relay is
128
+ * telling the node it has no other reachability — coming up undialable is worse
129
+ * than not coming up. Names the relays and the reservation's own reason, both of
130
+ * which the libp2p error omitted.
131
+ */
132
+ export class RelayReservationFailedError extends Error {
133
+ constructor(
134
+ readonly relayAddrs: readonly string[],
135
+ readonly state: RelayReservationState
136
+ ) {
137
+ super(
138
+ `network.relayAddrs reservation failed (status: ${state.status}): ` +
139
+ `${state.error ?? 'no /p2p-circuit address appeared'} — relays: ${relayAddrs.join(', ')}`
140
+ );
141
+ this.name = 'RelayReservationFailedError';
142
+ }
143
+ }
144
+
145
+ /**
146
+ * A hand-written `<relay>/p2p-circuit` entry in `network.listenAddrs` is the
147
+ * CONFIGURED listener shape, and it is unusable on the `'search'` route: libp2p
148
+ * dials that relay from inside `listen()`, the bring-up quiet period
149
+ * (`membership-connection-gater.ts`) denies exactly that dial, `listen()` fails, and
150
+ * the transport manager's default `FATAL_ALL` aborts `libp2p.start()`. The operator
151
+ * would see `UnsupportedListenAddressesError` from deep inside bring-up with nothing
152
+ * naming the cause, so name it here instead.
153
+ *
154
+ * `network.relayAddrs` is the exact replacement — same relay, and the reservation is
155
+ * driven after bring-up, which is the whole point of the route.
156
+ */
157
+ function rejectConfiguredCircuitListenAddrs(listenAddrs: readonly string[]): void {
158
+ const configured = listenAddrs.filter(isConfiguredCircuitListenAddr);
159
+ if (configured.length > 0) {
160
+ throw new Error(
161
+ 'network.listenAddrs names a relay directly ' +
162
+ `(${configured.join(', ')}), which a control node cannot listen on: libp2p would ` +
163
+ 'dial that relay during control-database bring-up, when the node accepts no ' +
164
+ 'connections. Move the relay to network.relayAddrs, which reserves after bring-up.'
165
+ );
166
+ }
167
+ }
168
+
169
+ /** `<something>/p2p-circuit` — the configured shape, as opposed to the bare search addr. */
170
+ function isConfiguredCircuitListenAddr(listenAddr: string): boolean {
171
+ if (listenAddr === RELAY_SEARCH_LISTEN_ADDR) {
172
+ return false;
173
+ }
174
+ try {
175
+ return multiaddr(listenAddr).getComponents().some((c) => c.name === 'p2p-circuit');
176
+ } catch {
177
+ // Not our error to raise: libp2p reports an unparsable listen addr itself, and
178
+ // this helper only ever ADDS a denial.
179
+ return false;
180
+ }
74
181
  }
75
182
 
76
183
  /**
@@ -4,28 +4,32 @@
4
4
  * (live) status derivation.
5
5
  *
6
6
  * `@libp2p/circuit-relay-v2`'s listener branches on the SHAPE of the listen
7
- * address, and the two shapes are alternatives, not layers:
7
+ * address:
8
8
  *
9
9
  * | listen addr | libp2p behaviour | unreachable relay |
10
10
  * | ------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------- |
11
11
  * | `<dial addr>/p2p/<relayPeerId>/p2p-circuit` | CONFIGURED reservation: dial that exact relay and reserve, or fail | `listen()` throws |
12
12
  * | bare `/p2p-circuit` | SEARCH mode: register a pending reservation, to be filled by relay discovery | nothing throws, no reserve |
13
13
  *
14
- * The configured shape is what `relay-addrs.ts` builds out of `network.relayAddrs`.
15
- * It is **fail-fast by construction**: libp2p's transport manager throws
16
- * `UnsupportedListenAddressesError` when any configured listen address fails to
17
- * listen under the default `FATAL_ALL` fault tolerance, so naming a relay that is
18
- * down means the node does not start. Good for a server; wrong for a browser tab,
19
- * which must still boot (solo, undialable) when its relay is down.
20
- *
21
- * So a browser-shaped node listens on bare `/p2p-circuit` and needs someone to
22
- * (a) dial the relay and (b) fill the pending reservation that listener created,
23
- * then report whether one actually landed. That is this module. It is **fail-soft
24
- * by construction**: nothing here throws.
25
- *
26
- * Do NOT "consolidate" the two by also setting `network.relayAddrs` on a node
27
- * that reserves this way — that re-introduces the fatal configured listener
28
- * alongside the search listener and the tab stops booting when the relay is down.
14
+ * A search listener needs someone to (a) dial the relay and (b) fill the pending
15
+ * reservation it created, then report whether one actually landed. That is this
16
+ * module. It is **fail-soft by construction**: nothing here throws.
17
+ *
18
+ * EVERY cadre CONTROL node now takes that route — `network.relayAddrs` resolves
19
+ * to the bare search entry (`relay-addrs.ts` → `RelayListenRoute`) and
20
+ * `CadreNode.start()` drives this module once the control database is up. The
21
+ * configured shape would dial the relay from inside `libp2p.start()`, putting a
22
+ * sibling in this node's cohort before its own control database existed, and a
23
+ * sibling that has not yet replicated this node's `CadrePeer` row correctly
24
+ * refuses its control-DB streams — which killed `start()` outright.
25
+ *
26
+ * So the two shapes are no longer alternative ROUTES, only alternative failure
27
+ * postures on top of the same drive: a node that names `network.relayAddrs` gets
28
+ * a `RelayReservationFailedError` out of `start()` when the first attempt lands
29
+ * nothing (fail-fast, the operator asked for this relay), while a node that calls
30
+ * `CadreNode.reserveRelays()` itself gets a non-`reserved` status and stays up
31
+ * (fail-soft, the browser-tab posture). Configuring both is now redundant, not
32
+ * fatal. STRAND nodes still take the configured shape — see `relay-addrs.ts`.
29
33
  *
30
34
  * ⚠️ WHY THE RESERVATION IS REQUESTED EXPLICITLY RATHER THAN LEFT TO DISCOVERY.
31
35
  * libp2p fills a search listener's pending reservation from `RelayDiscovery`,
@@ -68,7 +72,8 @@ const log = debug('sereus:cadre:relay-reservation');
68
72
 
69
73
  /**
70
74
  * Relay-reservation posture for a node that reserves through the SEARCH listen
71
- * addr (bare `/p2p-circuit`) rather than a configured `network.relayAddrs` entry.
75
+ * addr (bare `/p2p-circuit`) — every cadre control node, whether its relays came
76
+ * from `network.relayAddrs` or from an explicit `CadreNode.reserveRelays()` call.
72
77
  * - `none` — no relay addrs supplied; the node is undialable by design.
73
78
  * - `dialing` — a drive is in flight.
74
79
  * - `reserved` — the node currently holds at least one `/p2p-circuit` addr.
@@ -404,7 +404,7 @@ export class SeedBootstrapService {
404
404
  insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
405
405
  with context OwnerKey = ?, Signature = ?
406
406
  values (?, ?, ?, ?, ?, ?)
407
- `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId]);
407
+ `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
408
408
  log('Device token inserted (owner-signed): %s', record.peerId);
409
409
  }
410
410
 
@@ -751,8 +751,8 @@ export class SeedBootstrapService {
751
751
  // `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
752
752
  // `CadreNode.dialColdStartBootstrap` retries these same addresses on every
753
753
  // control-cohort reconcile pass until the control database has siblings.
754
- // `createSeed` projects EVERY CadrePeer row, so an owner applying a seed
755
- // minted after it joined finds ITSELF in the owner list. Dialing self always
754
+ // `createSeed` projects every non-revoked CadrePeer row, so an owner applying
755
+ // a seed minted after it joined finds ITSELF in the owner list. Dialing self always
756
756
  // throws, which would report a healthy owner as "seeded but stranded".
757
757
  // Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
758
758
  // and an undefined self simply matches nothing.
@@ -966,6 +966,13 @@ export class SeedBootstrapService {
966
966
  * This makes any owner node markable — not just the local one — and ties
967
967
  * `isOwner` to the control table rather than to `peerId === self`.
968
968
  *
969
+ * Read through {@link ControlDatabase.queryCadrePeers}, not a raw `CadrePeer`
970
+ * select: that reader drops any row whose `StampId` is retired in
971
+ * `CadreControl.Revocation`, so a removed member's addresses are never packed
972
+ * into a seed and pushed into a joiner's peerstore (`applySeed` adds every
973
+ * seed peer's addrs and dials the owner-flagged ones). A revoked peer is off
974
+ * the addressable surface everywhere, and this is one of its exits.
975
+ *
969
976
  * NOTE: this is the one owner lookup deliberately left on the REPLICATED
970
977
  * table rather than the node-local anchor. `SeedPeer.isOwner` is a dial hint
971
978
  * — the receiver dials owner-flagged peers first — not a trust decision, and
@@ -980,29 +987,22 @@ export class SeedBootstrapService {
980
987
  }
981
988
 
982
989
  const ownerKeys = await this.controlDatabase.getOwnerKeys();
983
- const db = this.controlDatabase.getDatabase();
984
- const peers: SeedPeer[] = [];
985
-
986
- // Query CadrePeer table
987
- for await (const row of db.eval('select PeerId, Multiaddr from CadreControl.CadrePeer')) {
988
- const peerId = row.PeerId as string;
989
- const multiaddr = row.Multiaddr as string | null;
990
+ const rows = await this.controlDatabase.queryCadrePeers();
990
991
 
992
+ return rows.map(({ peerId, multiaddr }) => {
991
993
  // Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
992
994
  // unparsable id yields null and is treated as a non-owner rather than
993
995
  // failing the whole seed creation.
994
996
  const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
995
997
  const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
996
998
 
997
- peers.push({
999
+ return {
998
1000
  peerId,
999
1001
  multiaddrs: multiaddr ? multiaddr.split(',') : [],
1000
1002
  isOwner,
1001
1003
  ...(isOwner ? { publicKey: pubKeyB64 } : {}),
1002
- });
1003
- }
1004
-
1005
- return peers;
1004
+ };
1005
+ });
1006
1006
  }
1007
1007
 
1008
1008
  /**
@@ -1,140 +1,140 @@
1
- /**
2
- * Trust-anchor policy for control-network seeds.
3
- *
4
- * A seed carries an ed25519 signature over its body and the `signerKey` that
5
- * produced it. Verifying that signature only proves the seed is internally
6
- * consistent — it does NOT prove the signer is an owner, because both the
7
- * signer key and the seed's own `isOwner` peer flags are attacker-supplied.
8
- * The trust decision must therefore rest on an anchor that does not come from
9
- * the seed body:
10
- *
11
- * 1. Anchored — keys in the receiver's NODE-LOCAL `TrustedOwnerStore`: the
12
- * non-replicated, per-party record of owner keys established out of band
13
- * (founding the party, the `CadreInvite` that enrolled this node, an
14
- * operator pin). Deliberately NOT the replicated `CadreControl.OwnerKey`
15
- * table — any connecting node can genesis-insert its own key there and let
16
- * it replicate, so that table can be made to say "yes" by a stranger.
17
- * 2. Pinned out-of-band — keys handed to the node for this seed only (carried
18
- * by a `CadreInvite.ownerKeys`, or pinned by an operator) without having
19
- * been anchored yet.
20
- * 3. TOFU (opt-in) — an explicit confirmation callback invoked on first sight
21
- * of an unknown signer key. Interactive hosts only; off by default.
22
- *
23
- * Secure default (`anchoredTrustPolicy`): a node with an empty anchor and no
24
- * pinned keys rejects the seed — a seed can no longer vouch for its own signer,
25
- * and neither can a polluted replicated table.
26
- *
27
- * A key accepted via anchor 2 or 3 is reported back through
28
- * {@link SeedTrustDecision.anchorAs} so `applySeed` can persist it into the
29
- * node-local anchor, and later seeds from the same owner are accepted without
30
- * re-supplying the invite/confirmation.
31
- */
32
- import type { TrustSource } from './trusted-owner-store.js';
33
-
34
- export interface SeedTrustContext {
35
- /** Party the seed claims to belong to. */
36
- partyId: string;
37
- /** ed25519 base64url signer key — already signature-verified by `applySeed`. */
38
- signerKey: string;
39
- /**
40
- * The receiver's anchored owner keys, sourced from its node-local
41
- * `TrustedOwnerStore` — NOT from the seed, and NOT from the replicated
42
- * `OwnerKey` table. Empty for a node whose anchor was never seeded.
43
- */
44
- knownOwnerKeys: ReadonlySet<string>;
45
- }
46
-
47
- export interface SeedTrustDecision {
48
- /** Whether the signer key is trusted. */
49
- trusted: boolean;
50
- /** Human-readable reason, surfaced as the seed-apply error when not trusted. */
51
- reason?: string;
52
- /**
53
- * Set when the key was trusted via an anchor OUTSIDE the node-local store (a
54
- * pinned key, a TOFU confirmation): the provenance to record it under, so
55
- * `applySeed` persists it and the next seed from that owner is anchored
56
- * without re-supplying the pin. Omitted when the key was already anchored
57
- * (nothing to persist) or not trusted at all.
58
- */
59
- anchorAs?: Exclude<TrustSource, 'genesis'>;
60
- }
61
-
62
- /**
63
- * Decides whether a signature-verified seed's signer key should be trusted.
64
- * May be synchronous (anchored/pinned) or asynchronous (TOFU confirmation).
65
- */
66
- export interface SeedTrustPolicy {
67
- evaluate(ctx: SeedTrustContext): Promise<SeedTrustDecision> | SeedTrustDecision;
68
- }
69
-
70
- /**
71
- * Default policy: trust only keys already in the receiver's node-local
72
- * trusted-owner anchor. A node whose anchor was never seeded (no genesis, no
73
- * invite pin, no operator pin) rejects every seed.
74
- */
75
- export function anchoredTrustPolicy(): SeedTrustPolicy {
76
- return {
77
- evaluate({ signerKey, knownOwnerKeys }) {
78
- if (knownOwnerKeys.has(signerKey)) {
79
- return { trusted: true };
80
- }
81
- return {
82
- trusted: false,
83
- reason: 'Signer key is not an anchored owner (anchored trust policy)',
84
- };
85
- },
86
- };
87
- }
88
-
89
- /**
90
- * Cold-start policy: trust anchored keys plus a set pinned out-of-band
91
- * (typically `CadreInvite.ownerKeys` or operator config). Lets an unenrolled
92
- * invitee accept its first seed without the seed vouching for itself.
93
- *
94
- * @param anchorAs - provenance under which a pin-only acceptance is persisted
95
- * into the node-local anchor ('invite' by default — the invite-redemption
96
- * case; pass 'operator' for an operator-supplied pin).
97
- */
98
- export function pinnedKeyTrustPolicy(
99
- pinned: Iterable<string>,
100
- anchorAs: Exclude<TrustSource, 'genesis'> = 'invite'
101
- ): SeedTrustPolicy {
102
- const pinnedSet = new Set(pinned);
103
- return {
104
- evaluate({ signerKey, knownOwnerKeys }) {
105
- if (knownOwnerKeys.has(signerKey)) {
106
- return { trusted: true };
107
- }
108
- if (pinnedSet.has(signerKey)) {
109
- return { trusted: true, anchorAs };
110
- }
111
- return {
112
- trusted: false,
113
- reason: 'Signer key is neither an anchored nor a pinned owner (pinned-key trust policy)',
114
- };
115
- },
116
- };
117
- }
118
-
119
- /**
120
- * Opt-in interactive policy: trust keys already in the node-local anchor, and
121
- * on an unknown key invoke `confirm` (e.g. a trust-circle UI prompt). The key is
122
- * trusted iff `confirm` resolves true, and a confirmed key is persisted into the
123
- * anchor as an 'operator' pin (a human at the console is the same provenance as
124
- * an explicit operator pin) so the prompt is not repeated. Not enabled by default.
125
- */
126
- export function tofuTrustPolicy(
127
- confirm: (ctx: SeedTrustContext) => Promise<boolean>
128
- ): SeedTrustPolicy {
129
- return {
130
- async evaluate(ctx) {
131
- if (ctx.knownOwnerKeys.has(ctx.signerKey)) {
132
- return { trusted: true };
133
- }
134
- const accepted = await confirm(ctx);
135
- return accepted
136
- ? { trusted: true, anchorAs: 'operator' }
137
- : { trusted: false, reason: 'TOFU confirmation declined for unknown signer key' };
138
- },
139
- };
140
- }
1
+ /**
2
+ * Trust-anchor policy for control-network seeds.
3
+ *
4
+ * A seed carries an ed25519 signature over its body and the `signerKey` that
5
+ * produced it. Verifying that signature only proves the seed is internally
6
+ * consistent — it does NOT prove the signer is an owner, because both the
7
+ * signer key and the seed's own `isOwner` peer flags are attacker-supplied.
8
+ * The trust decision must therefore rest on an anchor that does not come from
9
+ * the seed body:
10
+ *
11
+ * 1. Anchored — keys in the receiver's NODE-LOCAL `TrustedOwnerStore`: the
12
+ * non-replicated, per-party record of owner keys established out of band
13
+ * (founding the party, the `CadreInvite` that enrolled this node, an
14
+ * operator pin). Deliberately NOT the replicated `CadreControl.OwnerKey`
15
+ * table — any connecting node can genesis-insert its own key there and let
16
+ * it replicate, so that table can be made to say "yes" by a stranger.
17
+ * 2. Pinned out-of-band — keys handed to the node for this seed only (carried
18
+ * by a `CadreInvite.ownerKeys`, or pinned by an operator) without having
19
+ * been anchored yet.
20
+ * 3. TOFU (opt-in) — an explicit confirmation callback invoked on first sight
21
+ * of an unknown signer key. Interactive hosts only; off by default.
22
+ *
23
+ * Secure default (`anchoredTrustPolicy`): a node with an empty anchor and no
24
+ * pinned keys rejects the seed — a seed can no longer vouch for its own signer,
25
+ * and neither can a polluted replicated table.
26
+ *
27
+ * A key accepted via anchor 2 or 3 is reported back through
28
+ * {@link SeedTrustDecision.anchorAs} so `applySeed` can persist it into the
29
+ * node-local anchor, and later seeds from the same owner are accepted without
30
+ * re-supplying the invite/confirmation.
31
+ */
32
+ import type { TrustSource } from './trusted-owner-store.js';
33
+
34
+ export interface SeedTrustContext {
35
+ /** Party the seed claims to belong to. */
36
+ partyId: string;
37
+ /** ed25519 base64url signer key — already signature-verified by `applySeed`. */
38
+ signerKey: string;
39
+ /**
40
+ * The receiver's anchored owner keys, sourced from its node-local
41
+ * `TrustedOwnerStore` — NOT from the seed, and NOT from the replicated
42
+ * `OwnerKey` table. Empty for a node whose anchor was never seeded.
43
+ */
44
+ knownOwnerKeys: ReadonlySet<string>;
45
+ }
46
+
47
+ export interface SeedTrustDecision {
48
+ /** Whether the signer key is trusted. */
49
+ trusted: boolean;
50
+ /** Human-readable reason, surfaced as the seed-apply error when not trusted. */
51
+ reason?: string;
52
+ /**
53
+ * Set when the key was trusted via an anchor OUTSIDE the node-local store (a
54
+ * pinned key, a TOFU confirmation): the provenance to record it under, so
55
+ * `applySeed` persists it and the next seed from that owner is anchored
56
+ * without re-supplying the pin. Omitted when the key was already anchored
57
+ * (nothing to persist) or not trusted at all.
58
+ */
59
+ anchorAs?: Exclude<TrustSource, 'genesis'>;
60
+ }
61
+
62
+ /**
63
+ * Decides whether a signature-verified seed's signer key should be trusted.
64
+ * May be synchronous (anchored/pinned) or asynchronous (TOFU confirmation).
65
+ */
66
+ export interface SeedTrustPolicy {
67
+ evaluate(ctx: SeedTrustContext): Promise<SeedTrustDecision> | SeedTrustDecision;
68
+ }
69
+
70
+ /**
71
+ * Default policy: trust only keys already in the receiver's node-local
72
+ * trusted-owner anchor. A node whose anchor was never seeded (no genesis, no
73
+ * invite pin, no operator pin) rejects every seed.
74
+ */
75
+ export function anchoredTrustPolicy(): SeedTrustPolicy {
76
+ return {
77
+ evaluate({ signerKey, knownOwnerKeys }) {
78
+ if (knownOwnerKeys.has(signerKey)) {
79
+ return { trusted: true };
80
+ }
81
+ return {
82
+ trusted: false,
83
+ reason: 'Signer key is not an anchored owner (anchored trust policy)',
84
+ };
85
+ },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Cold-start policy: trust anchored keys plus a set pinned out-of-band
91
+ * (typically `CadreInvite.ownerKeys` or operator config). Lets an unenrolled
92
+ * invitee accept its first seed without the seed vouching for itself.
93
+ *
94
+ * @param anchorAs - provenance under which a pin-only acceptance is persisted
95
+ * into the node-local anchor ('invite' by default — the invite-redemption
96
+ * case; pass 'operator' for an operator-supplied pin).
97
+ */
98
+ export function pinnedKeyTrustPolicy(
99
+ pinned: Iterable<string>,
100
+ anchorAs: Exclude<TrustSource, 'genesis'> = 'invite'
101
+ ): SeedTrustPolicy {
102
+ const pinnedSet = new Set(pinned);
103
+ return {
104
+ evaluate({ signerKey, knownOwnerKeys }) {
105
+ if (knownOwnerKeys.has(signerKey)) {
106
+ return { trusted: true };
107
+ }
108
+ if (pinnedSet.has(signerKey)) {
109
+ return { trusted: true, anchorAs };
110
+ }
111
+ return {
112
+ trusted: false,
113
+ reason: 'Signer key is neither an anchored nor a pinned owner (pinned-key trust policy)',
114
+ };
115
+ },
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Opt-in interactive policy: trust keys already in the node-local anchor, and
121
+ * on an unknown key invoke `confirm` (e.g. a trust-circle UI prompt). The key is
122
+ * trusted iff `confirm` resolves true, and a confirmed key is persisted into the
123
+ * anchor as an 'operator' pin (a human at the console is the same provenance as
124
+ * an explicit operator pin) so the prompt is not repeated. Not enabled by default.
125
+ */
126
+ export function tofuTrustPolicy(
127
+ confirm: (ctx: SeedTrustContext) => Promise<boolean>
128
+ ): SeedTrustPolicy {
129
+ return {
130
+ async evaluate(ctx) {
131
+ if (ctx.knownOwnerKeys.has(ctx.signerKey)) {
132
+ return { trusted: true };
133
+ }
134
+ const accepted = await confirm(ctx);
135
+ return accepted
136
+ ? { trusted: true, anchorAs: 'operator' }
137
+ : { trusted: false, reason: 'TOFU confirmation declined for unknown signer key' };
138
+ },
139
+ };
140
+ }