@serfab/cadre-core 0.12.0 → 0.13.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 (86) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-node.d.ts +328 -25
  3. package/dist/cadre-node.js +760 -59
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-database.d.ts +19 -0
  6. package/dist/control-database.js +51 -9
  7. package/dist/control-database.js.map +1 -1
  8. package/dist/control-schema.d.ts +1 -1
  9. package/dist/control-schema.js +30 -2
  10. package/dist/control-schema.js.map +1 -1
  11. package/dist/enrolled-machine-store-file.d.ts +13 -0
  12. package/dist/enrolled-machine-store-file.js +41 -0
  13. package/dist/enrolled-machine-store-file.js.map +1 -0
  14. package/dist/enrolled-machine-store.d.ts +81 -0
  15. package/dist/enrolled-machine-store.js +225 -0
  16. package/dist/enrolled-machine-store.js.map +1 -0
  17. package/dist/file-durable-slot.js +5 -3
  18. package/dist/file-durable-slot.js.map +1 -1
  19. package/dist/index.d.ts +5 -3
  20. package/dist/index.js +23 -5
  21. package/dist/index.js.map +1 -1
  22. package/dist/membership-connection-gater.d.ts +15 -1
  23. package/dist/membership-connection-gater.js +9 -3
  24. package/dist/membership-connection-gater.js.map +1 -1
  25. package/dist/node-local-snapshot.d.ts +6 -0
  26. package/dist/node-local-snapshot.js +7 -1
  27. package/dist/node-local-snapshot.js.map +1 -1
  28. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  29. package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
  30. package/dist/peer-join-backfill.js.map +1 -0
  31. package/dist/relay-addrs.d.ts +73 -0
  32. package/dist/relay-addrs.js +195 -0
  33. package/dist/relay-addrs.js.map +1 -1
  34. package/dist/strand-database.d.ts +12 -0
  35. package/dist/strand-database.js +16 -0
  36. package/dist/strand-database.js.map +1 -1
  37. package/dist/strand-formation-manager.d.ts +12 -0
  38. package/dist/strand-formation-manager.js +10 -3
  39. package/dist/strand-formation-manager.js.map +1 -1
  40. package/dist/strand-formation-protocol.d.ts +76 -2
  41. package/dist/strand-formation-protocol.js +78 -2
  42. package/dist/strand-formation-protocol.js.map +1 -1
  43. package/dist/strand-instance-manager.d.ts +147 -14
  44. package/dist/strand-instance-manager.js +225 -45
  45. package/dist/strand-instance-manager.js.map +1 -1
  46. package/dist/strand-membership-writer.d.ts +12 -0
  47. package/dist/strand-membership-writer.js +23 -1
  48. package/dist/strand-membership-writer.js.map +1 -1
  49. package/dist/strand-network-config.d.ts +84 -0
  50. package/dist/strand-network-config.js +136 -0
  51. package/dist/strand-network-config.js.map +1 -0
  52. package/dist/strand-revocation-enforcer.d.ts +392 -0
  53. package/dist/strand-revocation-enforcer.js +533 -0
  54. package/dist/strand-revocation-enforcer.js.map +1 -0
  55. package/dist/strand-solicitation.d.ts +7 -0
  56. package/dist/strand-solicitation.js +8 -2
  57. package/dist/strand-solicitation.js.map +1 -1
  58. package/dist/strand-watcher.js +6 -1
  59. package/dist/strand-watcher.js.map +1 -1
  60. package/dist/types.d.ts +210 -16
  61. package/dist/types.js +7 -1
  62. package/dist/types.js.map +1 -1
  63. package/package.json +16 -8
  64. package/src/cadre-node.ts +6353 -5603
  65. package/src/control-database.ts +53 -9
  66. package/src/control-schema.ts +30 -2
  67. package/src/enrolled-machine-store-file.ts +43 -0
  68. package/src/enrolled-machine-store.ts +280 -0
  69. package/src/file-durable-slot.ts +5 -3
  70. package/src/index.ts +52 -10
  71. package/src/membership-connection-gater.ts +9 -3
  72. package/src/node-local-snapshot.ts +13 -1
  73. package/src/peer-addr-book.ts +251 -250
  74. package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
  75. package/src/relay-addrs.ts +218 -0
  76. package/src/strand-database.ts +17 -0
  77. package/src/strand-formation-manager.ts +22 -3
  78. package/src/strand-formation-protocol.ts +808 -692
  79. package/src/strand-instance-manager.ts +317 -54
  80. package/src/strand-membership-writer.ts +23 -1
  81. package/src/strand-network-config.ts +172 -0
  82. package/src/strand-revocation-enforcer.ts +653 -0
  83. package/src/strand-solicitation.ts +484 -471
  84. package/src/strand-watcher.ts +319 -314
  85. package/src/types.ts +1554 -1354
  86. package/dist/strand-backfill.js.map +0 -1
@@ -6,7 +6,13 @@
6
6
  * - the trusted-owner anchor (`trusted-owner-store.ts`),
7
7
  * - the cold-start bootstrap-peer store (`bootstrap-peer-store.ts`).
8
8
  *
9
- * Both are the same mechanism with a different payload: a
9
+ * A THIRD node-local record — the enrolled-machine count
10
+ * (`enrolled-machine-store.ts`) — reuses {@link DurableSlot} but deliberately
11
+ * NOT this module's snapshot machinery: it is one scalar rather than an entry
12
+ * map, and it must cold-start rather than throw on an unreadable slot. Its
13
+ * module comment carries the reasoning; do not fold it in here.
14
+ *
15
+ * Both records here are the same mechanism with a different payload: a
10
16
  * `{ version, partyId, <entries> }` envelope, snapshot-written whole on every
11
17
  * change, loaded under one fail-safe-but-not-fail-silent policy. The only part
12
18
  * that differs per platform is WHERE the bytes are kept — Node writes a file,
@@ -37,6 +43,12 @@ export interface DurableSlot {
37
43
  * failed read reported as `undefined` silently converts a recoverable error
38
44
  * (permissions, a blocked database upgrade, an I/O fault) into destruction
39
45
  * of a still-intact record on the next save.
46
+ *
47
+ * A slot always reports the fault; what a READER does with it is the
48
+ * reader's policy. The two records here rethrow it; the enrolled-machine
49
+ * count catches it and cold-starts, because losing a repair hint must not
50
+ * stop a node. So an implementation must never soften this to `undefined`
51
+ * on a reader's behalf.
40
52
  */
41
53
  load(): Promise<string | undefined>;
42
54
  /**
@@ -1,250 +1,251 @@
1
- /**
2
- * Merging verified cadre addresses into a libp2p node's **address book** (its
3
- * peerStore), so that every layer below cadre-core which dials by bare peer id
4
- * — Optimystic's cluster client, repo client, FRET ping/announce, all funnelling
5
- * into `libp2p.dialProtocol(peerId, …)` — has an address to use for a sibling
6
- * whose connection has dropped, instead of failing with `NoValidAddressesError`
7
- * until the next reconcile pass happens to re-dial it.
8
- *
9
- * Shared by the control-cohort reconcile pass and the strand one (both in
10
- * `CadreNode`): each resolves a peer's addresses its own way and then needs
11
- * exactly this write, with exactly this workaround.
12
- *
13
- * Two exports, in the order a caller uses them: {@link groupAddrsByPeerId} turns
14
- * a flat address list into per-peer groups (the strand path starts from the
15
- * peer-agnostic union the strand-addr RPC returns), and {@link mergePeerAddrs}
16
- * writes one peer's group.
17
- */
18
-
19
- import debug from 'debug';
20
- import { peerIdFromString } from '@libp2p/peer-id';
21
- import type { Address, Peer, PeerId, PeerStore, TagOptions } from '@libp2p/interface';
22
- import { multiaddr, CODE_P2P, CODE_P2P_CIRCUIT, type Multiaddr } from '@multiformats/multiaddr';
23
-
24
- const log = debug('sereus:cadre:peer-addr-book');
25
-
26
- /** Outcome of one address-book merge, for logging and tests. */
27
- export type MergeAddrsResult = 'merged' | 'restamped' | 'skipped' | 'failed';
28
-
29
- /**
30
- * The slice of a libp2p node {@link mergePeerAddrs} touches. Structurally
31
- * satisfied by `Libp2p`; narrow so a caller (or a test) can pass a bare
32
- * peerStore holder.
33
- */
34
- export interface PeerAddrBookHost {
35
- peerStore: PeerStore;
36
- }
37
-
38
- /**
39
- * Write `addrs` into `host`'s address book for `peerId`, best-effort. `peerId`
40
- * may be the string form callers hold (a `CadrePeer.PeerId` column, a strand-addr
41
- * RPC result); parsing it is part of the best-effort contract below, so no caller
42
- * has to wrap this in a second try/catch of its own.
43
- *
44
- * - **Empty `addrs` writes nothing** and reports `'skipped'`. That is how a
45
- * revoked / stale / untrusted peer's existing entry is allowed to age out on
46
- * its own: the caller resolves `[]` for it, and nothing here refreshes it.
47
- * - Otherwise the addresses are merged, and — see the timestamp trap below —
48
- * re-saved when the merge alone leaves them invisible (`'restamped'`).
49
- * - Any throw (unparsable peer id, datastore failure, a peerStore double without
50
- * `merge`) is logged and folded to `'failed'`. Callers are best-effort loops:
51
- * an address-book failure must never abort a reconcile pass.
52
- *
53
- * NOTE: `peerStore.merge` cannot refresh an address's `observed` timestamp in
54
- * `@libp2p/peer-store` 12.0.10 — `to-peer-pb.js` shadows its own loop variable
55
- * (`…addresses?.find(addr => uint8ArrayEquals(addr.multiaddr, addr.multiaddr))`,
56
- * always true, so it returns the FIRST stored address's timestamp), and every
57
- * read filters out addresses older than `MAX_ADDRESS_AGE` (1 hour). So an entry
58
- * silently dies at the one-hour mark and no amount of re-merging revives it —
59
- * not even merging a brand-new address. The `save` below is the workaround:
60
- * `save` is the one write path that omits `existingPeer` and therefore stamps
61
- * `Date.now()`. Drop it once upstream stamps `Date.now()` on merge.
62
- *
63
- * NOTE: raising the store's own `maxAddressAge` (libp2p forwards `init.peerStore`
64
- * straight into `persistentPeerStore`) would sidestep the bug in one line, and is
65
- * deliberately NOT done: expiry is load-bearing here. Only *verified* addresses
66
- * come through this helper, and the design relies on everything else — the
67
- * cold-start seed entries, identify-learned addresses — still ageing out on the
68
- * stock schedule. A global age bump would keep those alive too.
69
- *
70
- * NOTE: if the store ever REJECTS an address we hand it, the restamp repeats
71
- * every pass forever — `allVisible` stays false, and `save` re-submits the same
72
- * rejected address. libp2p wires the store's `addressFilter` to
73
- * `connectionGater.filterMultiaddrForPeer`, which nothing in this repo implements
74
- * today (`createMembershipConnectionGater` gates dials, not addresses), so the
75
- * loop is unreachable. If an app ever supplies that gater hook via
76
- * `NetworkConfig.connectionGater`, bound the retry — the cost is one redundant
77
- * datastore write per gated sibling per reconcile pass.
78
- */
79
- export async function mergePeerAddrs(
80
- host: PeerAddrBookHost,
81
- peerId: PeerId | string,
82
- addrs: Multiaddr[]
83
- ): Promise<MergeAddrsResult> {
84
- if (addrs.length === 0) {
85
- return 'skipped';
86
- }
87
- try {
88
- const id = typeof peerId === 'string' ? peerIdFromString(peerId) : peerId;
89
- const peer = await host.peerStore.merge(id, { multiaddrs: addrs });
90
- // The returned Peer is already expiry-filtered, so it says whether the merge
91
- // is actually VISIBLE — which is the only question that matters downstream.
92
- if (allVisible(peer.addresses, addrs, id)) {
93
- return 'merged';
94
- }
95
- await host.peerStore.save(id, restampData(peer, addrs, id));
96
- return 'restamped';
97
- } catch (error) {
98
- log('mergePeerAddrs: address-book write for %s failed: %o', peerId.toString(), error);
99
- return 'failed';
100
- }
101
- }
102
-
103
- /**
104
- * Group multiaddr strings by the peer id in their final `/p2p/` component — the
105
- * attribution step between a flat, peer-agnostic address list and the per-peer
106
- * writes {@link mergePeerAddrs} takes.
107
- *
108
- * The **last** `/p2p/` component is the addressed peer, not the first: a relayed
109
- * address (`…/p2p/<relay>/p2p-circuit/p2p/<dst>`) names the relay first and the
110
- * destination last, while a direct address (`…/tcp/…/p2p/<dst>`) names only the
111
- * destination. Taking the last therefore attributes both shapes to the peer the
112
- * address actually reaches.
113
- *
114
- * Three shapes are dropped rather than attributed, because none of them names a
115
- * peer the address reaches: one that does not parse, one carrying no `/p2p/`
116
- * component at all (a peer advertising a bare listen addr), and one whose
117
- * `p2p-circuit` comes AFTER its last `/p2p/` (`…/p2p/<relay>/p2p-circuit`) — that
118
- * last one is a relay hop with the destination missing, so its trailing peer id
119
- * is the RELAY's and filing it under the relay would claim the relay is reachable
120
- * at a circuit leading nowhere. Duplicates collapse within a group. Insertion
121
- * order is preserved, both between groups and inside one.
122
- */
123
- export function groupAddrsByPeerId(addrs: string[]): Map<string, Multiaddr[]> {
124
- const groups = new Map<string, Multiaddr[]>();
125
- const seen = new Set<string>();
126
- for (const addr of addrs) {
127
- const attributed = attributeAddr(addr);
128
- if (!attributed) {
129
- continue;
130
- }
131
- const key = `${attributed.peerId}\n${addr}`;
132
- if (seen.has(key)) {
133
- continue;
134
- }
135
- seen.add(key);
136
- const group = groups.get(attributed.peerId);
137
- if (group) {
138
- group.push(attributed.multiaddr);
139
- } else {
140
- groups.set(attributed.peerId, [attributed.multiaddr]);
141
- }
142
- }
143
- return groups;
144
- }
145
-
146
- /**
147
- * Parse one address and pull out the peer id it addresses (its last `/p2p/`
148
- * value); null when the address is unparsable or names no destination. Every case
149
- * is logged at the same level — an unattributable address is a sibling telling us
150
- * something we cannot use, which is worth seeing but never worth throwing over.
151
- */
152
- function attributeAddr(addr: string): { peerId: string; multiaddr: Multiaddr } | null {
153
- let parsed: Multiaddr;
154
- try {
155
- parsed = multiaddr(addr);
156
- } catch (error) {
157
- log('groupAddrsByPeerId: skipping unparsable addr %s: %o', addr, error);
158
- return null;
159
- }
160
- const peerId = lastAddressedPeerId(parsed);
161
- if (peerId === null) {
162
- log('groupAddrsByPeerId: skipping addr naming no destination peer: %s', addr);
163
- return null;
164
- }
165
- return { peerId, multiaddr: parsed };
166
- }
167
-
168
- /**
169
- * The peer id a parsed address reaches: its last `/p2p/` value, or null when it
170
- * has none — or when a `p2p-circuit` sits after it, which makes that value the
171
- * relay's rather than a destination's (see {@link groupAddrsByPeerId}). Walking
172
- * backwards, meeting the circuit first is exactly that case.
173
- */
174
- function lastAddressedPeerId(parsed: Multiaddr): string | null {
175
- const components = parsed.getComponents();
176
- for (let i = components.length - 1; i >= 0; i--) {
177
- const component = components[i];
178
- if (component.code === CODE_P2P_CIRCUIT) {
179
- return null;
180
- }
181
- if (component.code === CODE_P2P && component.value) {
182
- return component.value;
183
- }
184
- }
185
- return null;
186
- }
187
-
188
- /**
189
- * The peerStore strips a trailing `/p2p/<peerId>` from an address it stores when
190
- * that id is the address's FIRST `/p2p/` component (`dedupe-addresses.js`), so a
191
- * plain address round-trips without its suffix while a relayed one — whose first
192
- * `/p2p/` names the relay — keeps it. Comparing and de-duplicating on this
193
- * suffix-stripped form matches either shape.
194
- */
195
- function addrKey(addr: string, peerId: PeerId): string {
196
- const suffix = `/p2p/${peerId.toString()}`;
197
- return addr.endsWith(suffix) ? addr.slice(0, -suffix.length) : addr;
198
- }
199
-
200
- /** Is every address in `addrs` readable back from the stored, expiry-filtered set? */
201
- function allVisible(stored: Address[], addrs: Multiaddr[], peerId: PeerId): boolean {
202
- const keys = new Set(stored.map((a) => addrKey(a.multiaddr.toString(), peerId)));
203
- return addrs.every((addr) => keys.has(addrKey(addr.toString(), peerId)));
204
- }
205
-
206
- /**
207
- * The full `save` payload for a restamp: `save` re-uses the `patch` strategy with
208
- * no existing peer, so every field it is not handed is DROPPED — and Optimystic
209
- * classifies peers by their peerStore protocol list (`libp2p-key-network.ts`
210
- * `membershipOf`), so losing protocols here would break cluster/repo routing.
211
- * Everything therefore carries forward from the just-merged `peer`.
212
- *
213
- * The read-modify-write is NOT atomic against a concurrent `identify` write to
214
- * the same entry. The loss window is milliseconds and costs at most one identify
215
- * update, recovered by the next identify push — not worth a lock.
216
- *
217
- * A tag's remaining TTL is not carried: the public `Tag` type exposes only
218
- * `value`, so the stored expiry is unreadable from here and the tag comes back
219
- * non-expiring. `publicKey` is likewise not passed, which is lossless for the
220
- * Ed25519 peer ids a cadre uses (the key is embedded in the id).
221
- */
222
- function restampData(peer: Peer, addrs: Multiaddr[], peerId: PeerId): {
223
- addresses: Address[];
224
- protocols: string[];
225
- metadata: Map<string, Uint8Array | undefined>;
226
- tags: Map<string, TagOptions | undefined>;
227
- peerRecordEnvelope?: Uint8Array;
228
- } {
229
- const addresses = new Map<string, Address>();
230
- for (const address of peer.addresses) {
231
- addresses.set(addrKey(address.multiaddr.toString(), peerId), address);
232
- }
233
- for (const addr of addrs) {
234
- const key = addrKey(addr.toString(), peerId);
235
- if (!addresses.has(key)) {
236
- // Uncertified, matching what `merge`'s own `multiaddrs` path would have
237
- // stored; an address already present keeps whatever flag it carried.
238
- addresses.set(key, { multiaddr: addr, isCertified: false });
239
- }
240
- }
241
- return {
242
- addresses: [...addresses.values()],
243
- protocols: peer.protocols,
244
- metadata: new Map<string, Uint8Array | undefined>(peer.metadata),
245
- tags: new Map<string, TagOptions | undefined>(
246
- [...peer.tags].map(([name, tag]) => [name, { value: tag.value }])
247
- ),
248
- peerRecordEnvelope: peer.peerRecordEnvelope
249
- };
250
- }
1
+ /**
2
+ * Merging verified cadre addresses into a libp2p node's **address book** (its
3
+ * peerStore), so that every layer below cadre-core which dials by bare peer id
4
+ * — Optimystic's cluster client, repo client, FRET ping/announce, all funnelling
5
+ * into `libp2p.dialProtocol(peerId, …)` — has an address to use for a sibling
6
+ * whose connection has dropped, instead of failing with `NoValidAddressesError`
7
+ * until the next reconcile pass happens to re-dial it.
8
+ *
9
+ * Shared by the control-cohort reconcile pass and the strand one (both in
10
+ * `CadreNode`): each resolves a peer's addresses its own way and then needs
11
+ * exactly this write, with exactly this workaround.
12
+ *
13
+ * Two exports, in the order a caller uses them: {@link groupAddrsByPeerId} turns
14
+ * a flat address list into per-peer groups (the strand path starts from the
15
+ * peer-agnostic union the strand-addr RPC returns), and {@link mergePeerAddrs}
16
+ * writes one peer's group.
17
+ */
18
+
19
+ import debug from 'debug';
20
+ import { peerIdFromString } from '@libp2p/peer-id';
21
+ import type { Address, Peer, PeerId, PeerStore, TagOptions } from '@libp2p/interface';
22
+ import { multiaddr, CODE_P2P, CODE_P2P_CIRCUIT, type Multiaddr } from '@multiformats/multiaddr';
23
+
24
+ const log = debug('sereus:cadre:peer-addr-book');
25
+
26
+ /** Outcome of one address-book merge, for logging and tests. */
27
+ export type MergeAddrsResult = 'merged' | 'restamped' | 'skipped' | 'failed';
28
+
29
+ /**
30
+ * The slice of a libp2p node {@link mergePeerAddrs} touches. Structurally
31
+ * satisfied by `Libp2p`; narrow so a caller (or a test) can pass a bare
32
+ * peerStore holder.
33
+ */
34
+ export interface PeerAddrBookHost {
35
+ peerStore: PeerStore;
36
+ }
37
+
38
+ /**
39
+ * Write `addrs` into `host`'s address book for `peerId`, best-effort. `peerId`
40
+ * may be the string form callers hold (a `CadrePeer.PeerId` column, a strand-addr
41
+ * RPC result); parsing it is part of the best-effort contract below, so no caller
42
+ * has to wrap this in a second try/catch of its own.
43
+ *
44
+ * - **Empty `addrs` writes nothing** and reports `'skipped'`. That is how a
45
+ * revoked / stale / untrusted peer's existing entry is allowed to age out on
46
+ * its own: the caller resolves `[]` for it, and nothing here refreshes it.
47
+ * - Otherwise the addresses are merged, and — see the timestamp trap below —
48
+ * re-saved when the merge alone leaves them invisible (`'restamped'`).
49
+ * - Any throw (unparsable peer id, datastore failure, a peerStore double without
50
+ * `merge`) is logged and folded to `'failed'`. Callers are best-effort loops:
51
+ * an address-book failure must never abort a reconcile pass.
52
+ *
53
+ * NOTE: `peerStore.merge` cannot refresh an address's `observed` timestamp in
54
+ * `@libp2p/peer-store` 12.0.10 — `to-peer-pb.js` shadows its own loop variable
55
+ * (`…addresses?.find(addr => uint8ArrayEquals(addr.multiaddr, addr.multiaddr))`,
56
+ * always true, so it returns the FIRST stored address's timestamp), and every
57
+ * read filters out addresses older than `MAX_ADDRESS_AGE` (1 hour). So an entry
58
+ * silently dies at the one-hour mark and no amount of re-merging revives it —
59
+ * not even merging a brand-new address. The `save` below is the workaround:
60
+ * `save` is the one write path that omits `existingPeer` and therefore stamps
61
+ * `Date.now()`. Drop it once upstream stamps `Date.now()` on merge.
62
+ *
63
+ * NOTE: raising the store's own `maxAddressAge` (libp2p forwards `init.peerStore`
64
+ * straight into `persistentPeerStore`) would sidestep the bug in one line, and is
65
+ * deliberately NOT done: expiry is load-bearing here. Only *verified* addresses
66
+ * come through this helper, and the design relies on everything else — the
67
+ * cold-start seed entries, identify-learned addresses — still ageing out on the
68
+ * stock schedule. A global age bump would keep those alive too.
69
+ *
70
+ * NOTE: if the store ever REJECTS an address we hand it, the restamp repeats
71
+ * every pass forever — `allVisible` stays false, and `save` re-submits the same
72
+ * rejected address. libp2p wires the store's `addressFilter` to
73
+ * `connectionGater.filterMultiaddrForPeer`, which nothing in this repo implements
74
+ * today (`createMembershipConnectionGater` gates dials, not addresses), so the
75
+ * loop is unreachable. If an app ever supplies that gater hook via
76
+ * `NetworkConfig.connectionGater`, bound the retry — the cost is one redundant
77
+ * datastore write per gated sibling per reconcile pass.
78
+ */
79
+ export async function mergePeerAddrs(
80
+ host: PeerAddrBookHost,
81
+ peerId: PeerId | string,
82
+ addrs: Multiaddr[]
83
+ ): Promise<MergeAddrsResult> {
84
+ if (addrs.length === 0) {
85
+ return 'skipped';
86
+ }
87
+ try {
88
+ const id = typeof peerId === 'string' ? peerIdFromString(peerId) : peerId;
89
+ const peer = await host.peerStore.merge(id, { multiaddrs: addrs });
90
+ // The returned Peer is already expiry-filtered, so it says whether the merge
91
+ // is actually VISIBLE — which is the only question that matters downstream.
92
+ if (allVisible(peer.addresses, addrs, id)) {
93
+ return 'merged';
94
+ }
95
+ await host.peerStore.save(id, restampData(peer, addrs, id));
96
+ return 'restamped';
97
+ } catch (error) {
98
+ log('mergePeerAddrs: address-book write for %s failed: %o', peerId.toString(), error);
99
+ return 'failed';
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Group multiaddr strings by the peer id in their final `/p2p/` component — the
105
+ * attribution step between a flat, peer-agnostic address list and the per-peer
106
+ * writes {@link mergePeerAddrs} takes.
107
+ *
108
+ * The **last** `/p2p/` component is the addressed peer, not the first: a relayed
109
+ * address (`…/p2p/<relay>/p2p-circuit/p2p/<dst>`) names the relay first and the
110
+ * destination last, while a direct address (`…/tcp/…/p2p/<dst>`) names only the
111
+ * destination. Taking the last therefore attributes both shapes to the peer the
112
+ * address actually reaches.
113
+ *
114
+ * Three shapes are dropped rather than attributed, because none of them names a
115
+ * peer the address reaches: one that does not parse, one carrying no `/p2p/`
116
+ * component at all (a peer advertising a bare listen addr), and one whose
117
+ * `p2p-circuit` comes AFTER its last `/p2p/` (`…/p2p/<relay>/p2p-circuit`) — that
118
+ * last one is a relay hop with the destination missing, so its trailing peer id
119
+ * is the RELAY's and filing it under the relay would claim the relay is reachable
120
+ * at a circuit leading nowhere. Duplicates collapse within a group. Insertion
121
+ * order is preserved, both between groups and inside one.
122
+ */
123
+ export function groupAddrsByPeerId(addrs: string[]): Map<string, Multiaddr[]> {
124
+ const groups = new Map<string, Multiaddr[]>();
125
+ const seen = new Set<string>();
126
+ for (const addr of addrs) {
127
+ const attributed = attributeAddr(addr);
128
+ if (!attributed) {
129
+ continue;
130
+ }
131
+ const key = `${attributed.peerId}\n${addr}`;
132
+ if (seen.has(key)) {
133
+ continue;
134
+ }
135
+ seen.add(key);
136
+ const group = groups.get(attributed.peerId);
137
+ if (group) {
138
+ group.push(attributed.multiaddr);
139
+ } else {
140
+ groups.set(attributed.peerId, [attributed.multiaddr]);
141
+ }
142
+ }
143
+ return groups;
144
+ }
145
+
146
+ /**
147
+ * Parse one address and pull out the peer id it addresses (its last `/p2p/`
148
+ * value); null when the address is unparsable or names no destination. Every case
149
+ * is logged at the same level — an unattributable address is a sibling telling us
150
+ * something we cannot use, which is worth seeing but never worth throwing over.
151
+ */
152
+ function attributeAddr(addr: string): { peerId: string; multiaddr: Multiaddr } | null {
153
+ let parsed: Multiaddr;
154
+ try {
155
+ parsed = multiaddr(addr);
156
+ } catch (error) {
157
+ log('groupAddrsByPeerId: skipping unparsable addr %s: %o', addr, error);
158
+ return null;
159
+ }
160
+ const peerId = lastAddressedPeerId(parsed);
161
+ if (peerId === null) {
162
+ log('groupAddrsByPeerId: skipping addr naming no destination peer: %s', addr);
163
+ return null;
164
+ }
165
+ return { peerId, multiaddr: parsed };
166
+ }
167
+
168
+ /**
169
+ * The peer id a parsed address reaches: its last `/p2p/` value, or null when it
170
+ * has none — or when a `p2p-circuit` sits after it, which makes that value the
171
+ * relay's rather than a destination's (see {@link groupAddrsByPeerId}). Walking
172
+ * backwards, meeting the circuit first is exactly that case.
173
+ */
174
+ function lastAddressedPeerId(parsed: Multiaddr): string | null {
175
+ const components = parsed.getComponents();
176
+ for (let i = components.length - 1; i >= 0; i--) {
177
+ const component = components[i];
178
+ if (component.code === CODE_P2P_CIRCUIT) {
179
+ return null;
180
+ }
181
+ if (component.code === CODE_P2P && component.value) {
182
+ return component.value;
183
+ }
184
+ }
185
+ return null;
186
+ }
187
+
188
+ /**
189
+ * The peerStore strips a trailing `/p2p/<peerId>` from an address it stores when
190
+ * that id is the address's FIRST `/p2p/` component (`dedupe-addresses.js`), so a
191
+ * plain address round-trips without its suffix while a relayed one — whose first
192
+ * `/p2p/` names the relay — keeps it. Comparing and de-duplicating on this
193
+ * suffix-stripped form matches either shape.
194
+ */
195
+ function addrKey(addr: string, peerId: PeerId): string {
196
+ const suffix = `/p2p/${peerId.toString()}`;
197
+ return addr.endsWith(suffix) ? addr.slice(0, -suffix.length) : addr;
198
+ }
199
+
200
+ /** Is every address in `addrs` readable back from the stored, expiry-filtered set? */
201
+ function allVisible(stored: Address[], addrs: Multiaddr[], peerId: PeerId): boolean {
202
+ const keys = new Set(stored.map((a) => addrKey(a.multiaddr.toString(), peerId)));
203
+ return addrs.every((addr) => keys.has(addrKey(addr.toString(), peerId)));
204
+ }
205
+
206
+ /**
207
+ * The full `save` payload for a restamp: `save` re-uses the `patch` strategy with
208
+ * no existing peer, so every field it is not handed is DROPPED — and Optimystic
209
+ * classifies peers by their peerStore protocol list (`libp2p-key-network.ts`
210
+ * `membershipOf`), so losing protocols here would break cluster/repo routing.
211
+ * Everything therefore carries forward from the just-merged `peer`.
212
+ *
213
+ * The read-modify-write is NOT atomic against a concurrent `identify` write to
214
+ * the same entry. The loss window is milliseconds and costs at most one identify
215
+ * update, recovered by the next identify push — not worth a lock.
216
+ *
217
+ * A tag's remaining TTL is not carried: the public `Tag` type exposes only
218
+ * `value`, so the stored expiry is unreadable from here and the tag comes back
219
+ * non-expiring. `publicKey` is likewise not passed, which is lossless for the
220
+ * Ed25519 peer ids a cadre uses (the key is embedded in the id).
221
+ */
222
+ function restampData(peer: Peer, addrs: Multiaddr[], peerId: PeerId): {
223
+ addresses: Address[];
224
+ protocols: string[];
225
+ metadata: Map<string, Uint8Array | undefined>;
226
+ tags: Map<string, TagOptions | undefined>;
227
+ peerRecordEnvelope?: Uint8Array;
228
+ } {
229
+ const addresses = new Map<string, Address>();
230
+ for (const address of peer.addresses) {
231
+ addresses.set(addrKey(address.multiaddr.toString(), peerId), address);
232
+ }
233
+ for (const addr of addrs) {
234
+ const key = addrKey(addr.toString(), peerId);
235
+ if (!addresses.has(key)) {
236
+ // Uncertified, matching what `merge`'s own `multiaddrs` path would have
237
+ // stored; an address already present keeps whatever flag it carried.
238
+ addresses.set(key, { multiaddr: addr, isCertified: false });
239
+ }
240
+ }
241
+ return {
242
+ addresses: [...addresses.values()],
243
+ protocols: peer.protocols,
244
+ metadata: new Map<string, Uint8Array | undefined>(peer.metadata),
245
+ tags: new Map<string, TagOptions | undefined>(
246
+ [...peer.tags].map(([name, tag]) => [name, { value: tag.value }])
247
+ ),
248
+ peerRecordEnvelope: peer.peerRecordEnvelope
249
+ };
250
+ }
251
+