@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
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Derive the per-STRAND-node view of the machine's one `NetworkConfig`.
3
+ *
4
+ * A cadre machine runs one **control** libp2p node plus one more libp2p node per
5
+ * strand, and every one of them is built from the SAME operator-written
6
+ * `NetworkConfig` block. Two of its fields describe a single endpoint on the host
7
+ * and therefore cannot be inherited literally by a second node on that host:
8
+ *
9
+ * - **`listenAddrs` with a fixed port.** `cadre-cli`'s example config ships
10
+ * `/ip4/0.0.0.0/tcp/4001`. The control node binds it first; every strand node
11
+ * then tries to bind the same port and fails with `EADDRINUSE`, so a machine
12
+ * configured with a fixed port could not start any strand at all.
13
+ * - **`announceAddrs` / `appendAnnounceAddrs`.** Any concrete announce entry names
14
+ * a port, and that port is the CONTROL node's. A strand node advertising it
15
+ * sends peers to the control node — and `announceAddrs` REPLACES the advertised
16
+ * set (see `announce-addrs.ts`), so that wrong address would be the ONLY thing
17
+ * the strand node publishes.
18
+ *
19
+ * The reachability model this rests on: **a strand node is not separately dialable
20
+ * at a published fixed address.** It is reached through (a) its ephemeral direct
21
+ * listener plus the addresses peers observe for it, and (b) circuit relay —
22
+ * `network.relayAddrs` resolves to per-relay `<relay>/p2p-circuit` listen entries
23
+ * that strand nodes DO inherit correctly, because the port inside such an entry is
24
+ * the relay's, not a local bind (`relay-addrs.ts`).
25
+ *
26
+ * Everything else in `NetworkConfig` — `relayAddrs`, `transports`,
27
+ * `connectionGater`, `enableRelay` — is inherited by the caller; this module
28
+ * only owns the two host-endpoint fields above. One caveat on `connectionGater`:
29
+ * an OPEN strand's node gets it unchanged, while a CLOSED strand's node composes
30
+ * revoked-peer denial onto it in `strand-instance-manager.ts` (every supplied
31
+ * hook still honored — see `strand-revocation-enforcer.ts`).
32
+ *
33
+ * It does carry one non-address option out with them: the WebSocket transport switch
34
+ * a `/ws` listen entry implies (`relay-addrs.ts` → `resolveTransportOptions`). That is
35
+ * DERIVED from the listen entries rather than inherited, so it has to be computed
36
+ * wherever they are, and a strand node whose listen entries reached libp2p without it
37
+ * would bind no WebSocket listener and report nothing.
38
+ */
39
+ import type { NetworkConfig } from './types.js';
40
+ /**
41
+ * The address-shaped `createLibp2pNode` options a strand node gets, ready to spread
42
+ * into the rest of its options.
43
+ *
44
+ * There is deliberately no announce field here: a strand node announces NOTHING the
45
+ * operator configured, only what libp2p derives for it (its own listeners, observed
46
+ * addresses, and any `/p2p-circuit` address a relay reservation earns).
47
+ *
48
+ * NOTE: accepted tradeoff — a hosted deployment that wants a strand node reachable
49
+ * at its own published public port has no way to say so; the announce config is
50
+ * dropped for strand nodes wholesale rather than per-strand. Nothing needs that
51
+ * today, and the alternative is a per-strand network-config surface. Revisit if a
52
+ * hosted or reverse-proxy deployment ever needs direct-dialable strand nodes.
53
+ */
54
+ export interface StrandNodeAddrs {
55
+ /**
56
+ * Listen entries for this strand node, or `undefined` when the operator
57
+ * configured neither `listenAddrs` nor `relayAddrs` — in which case the caller
58
+ * omits the option and inherits `@optimystic/db-p2p`'s own default.
59
+ */
60
+ listenAddrs?: string[];
61
+ /**
62
+ * The WebSocket transport switch those listen entries imply, present only when one
63
+ * of them names WebSocket (`relay-addrs.ts` → `resolveTransportOptions`). Not an
64
+ * address — it is deliberately `0` and nothing binds it — but it lives here because
65
+ * it is derived from, and must travel with, the listen entries above. Without it a
66
+ * `/ws` strand listen entry binds nothing and libp2p reports nothing.
67
+ */
68
+ wsPort?: number;
69
+ }
70
+ /**
71
+ * The strand-node view of `network`: the control node's resolved listen entries with
72
+ * every fixed direct port rewritten to an ephemeral one, and no announce fields.
73
+ *
74
+ * Validation is unchanged and still fail-fast — `resolveListenAddrs` throws here on a
75
+ * malformed `relayAddrs` entry exactly as it does for the control node.
76
+ *
77
+ * NOTE: announce entries are no longer validated on this path at all; the CONTROL
78
+ * node's own build (`cadre-node.ts`) is the only thing that parses them, and it runs
79
+ * before any strand starts, so a typo still refuses node start today. That is an
80
+ * ORDERING guarantee, not a structural one. Revisit if a strand node is ever built
81
+ * before the control node's libp2p options are resolved — a malformed announce entry
82
+ * would then go unreported until the control node's own build.
83
+ */
84
+ export declare function strandNodeAddrs(network: NetworkConfig | undefined): StrandNodeAddrs;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Derive the per-STRAND-node view of the machine's one `NetworkConfig`.
3
+ *
4
+ * A cadre machine runs one **control** libp2p node plus one more libp2p node per
5
+ * strand, and every one of them is built from the SAME operator-written
6
+ * `NetworkConfig` block. Two of its fields describe a single endpoint on the host
7
+ * and therefore cannot be inherited literally by a second node on that host:
8
+ *
9
+ * - **`listenAddrs` with a fixed port.** `cadre-cli`'s example config ships
10
+ * `/ip4/0.0.0.0/tcp/4001`. The control node binds it first; every strand node
11
+ * then tries to bind the same port and fails with `EADDRINUSE`, so a machine
12
+ * configured with a fixed port could not start any strand at all.
13
+ * - **`announceAddrs` / `appendAnnounceAddrs`.** Any concrete announce entry names
14
+ * a port, and that port is the CONTROL node's. A strand node advertising it
15
+ * sends peers to the control node — and `announceAddrs` REPLACES the advertised
16
+ * set (see `announce-addrs.ts`), so that wrong address would be the ONLY thing
17
+ * the strand node publishes.
18
+ *
19
+ * The reachability model this rests on: **a strand node is not separately dialable
20
+ * at a published fixed address.** It is reached through (a) its ephemeral direct
21
+ * listener plus the addresses peers observe for it, and (b) circuit relay —
22
+ * `network.relayAddrs` resolves to per-relay `<relay>/p2p-circuit` listen entries
23
+ * that strand nodes DO inherit correctly, because the port inside such an entry is
24
+ * the relay's, not a local bind (`relay-addrs.ts`).
25
+ *
26
+ * Everything else in `NetworkConfig` — `relayAddrs`, `transports`,
27
+ * `connectionGater`, `enableRelay` — is inherited by the caller; this module
28
+ * only owns the two host-endpoint fields above. One caveat on `connectionGater`:
29
+ * an OPEN strand's node gets it unchanged, while a CLOSED strand's node composes
30
+ * revoked-peer denial onto it in `strand-instance-manager.ts` (every supplied
31
+ * hook still honored — see `strand-revocation-enforcer.ts`).
32
+ *
33
+ * It does carry one non-address option out with them: the WebSocket transport switch
34
+ * a `/ws` listen entry implies (`relay-addrs.ts` → `resolveTransportOptions`). That is
35
+ * DERIVED from the listen entries rather than inherited, so it has to be computed
36
+ * wherever they are, and a strand node whose listen entries reached libp2p without it
37
+ * would bind no WebSocket listener and report nothing.
38
+ */
39
+ import { multiaddr } from '@multiformats/multiaddr';
40
+ import { resolveListenAddrs, resolveTransportOptions } from './relay-addrs.js';
41
+ /**
42
+ * The strand-node view of `network`: the control node's resolved listen entries with
43
+ * every fixed direct port rewritten to an ephemeral one, and no announce fields.
44
+ *
45
+ * Validation is unchanged and still fail-fast — `resolveListenAddrs` throws here on a
46
+ * malformed `relayAddrs` entry exactly as it does for the control node.
47
+ *
48
+ * NOTE: announce entries are no longer validated on this path at all; the CONTROL
49
+ * node's own build (`cadre-node.ts`) is the only thing that parses them, and it runs
50
+ * before any strand starts, so a typo still refuses node start today. That is an
51
+ * ORDERING guarantee, not a structural one. Revisit if a strand node is ever built
52
+ * before the control node's libp2p options are resolved — a malformed announce entry
53
+ * would then go unreported until the control node's own build.
54
+ */
55
+ export function strandNodeAddrs(network) {
56
+ // The default `'configured'` relay route, deliberately NOT the control node's
57
+ // `'search'` route: nothing drives an explicit reservation for a strand node, so a
58
+ // bare `/p2p-circuit` search entry would register a pending reservation nobody fills
59
+ // and leave every NAT'd strand node undialable. See `relay-addrs.ts`.
60
+ const listenAddrs = resolveListenAddrs(network);
61
+ if (!listenAddrs) {
62
+ return {};
63
+ }
64
+ // An explicitly empty `listenAddrs` (the React Native "cannot listen" case) stays
65
+ // empty — rewriting must never resurrect a direct listener that was opted out of.
66
+ const rewritten = dedupe(listenAddrs.map(ephemeralPortListenAddr));
67
+ // Classified AFTER the ephemeral rewrite, so the check reads what this strand node
68
+ // will actually bind. Zeroing a port cannot change an entry's transport — only the
69
+ // `tcp`/`udp` component's value moves — so `/ip4/0.0.0.0/tcp/4002/ws` still resolves
70
+ // to WebSocket as `/ip4/0.0.0.0/tcp/0/ws`.
71
+ return { listenAddrs: rewritten, ...resolveTransportOptions(network, rewritten) };
72
+ }
73
+ /**
74
+ * One listen entry with its fixed direct port rewritten to `0`, so the OS assigns a
75
+ * free one per node. Interface and transport choices are preserved — `/ws`, a
76
+ * specific-interface bind, a `/udp/…/quic-v1` stack — since only the port is
77
+ * contended.
78
+ *
79
+ * Passed through UNCHANGED when there is nothing to rewrite, so the operator's exact
80
+ * string survives rather than being round-tripped through the multiaddr normalizer:
81
+ *
82
+ * - a circuit entry (`…/p2p-circuit`), whose embedded port belongs to the RELAY and
83
+ * is not a local bind at all;
84
+ * - a port that is already `0`, or an entry that names no port;
85
+ * - an unparsable entry — libp2p reports a bad listen addr itself, and this helper
86
+ * only ever narrows a port.
87
+ */
88
+ function ephemeralPortListenAddr(listenAddr) {
89
+ let components;
90
+ try {
91
+ components = multiaddr(listenAddr).getComponents();
92
+ }
93
+ catch {
94
+ return listenAddr;
95
+ }
96
+ if (components.some((c) => c.name === 'p2p-circuit')) {
97
+ return listenAddr;
98
+ }
99
+ if (!components.some(isFixedPort)) {
100
+ return listenAddr;
101
+ }
102
+ return multiaddr(components.map(withEphemeralPort)).toString();
103
+ }
104
+ /** A `tcp`/`udp` component naming a port the OS did not choose. */
105
+ function isFixedPort(component) {
106
+ return (component.name === 'tcp' || component.name === 'udp')
107
+ && component.value !== undefined
108
+ && component.value !== '0';
109
+ }
110
+ /**
111
+ * `component` with its port zeroed, or unchanged when it is not a fixed port.
112
+ *
113
+ * Rebuilt field-by-field rather than spread: a parsed component may carry a `bytes`
114
+ * cache, and `componentsToBytes` PREFERS that cache over `value` — a spread would
115
+ * re-encode the old port.
116
+ */
117
+ function withEphemeralPort(component) {
118
+ return isFixedPort(component)
119
+ ? { code: component.code, name: component.name, value: '0' }
120
+ : component;
121
+ }
122
+ /**
123
+ * Exact-string dedupe, first occurrence wins — matching `relay-addrs.ts`. Entries
124
+ * that differed only by port collapse into one after the rewrite, and libp2p would
125
+ * otherwise try to bind the same ephemeral-port entry twice.
126
+ *
127
+ * NOTE: an operator who writes two fixed ports on the same interface and transport
128
+ * (`/ip4/0.0.0.0/tcp/4001` and `…/tcp/4002`) gets ONE strand listener, not two, since
129
+ * the two entries are identical once zeroed. Intended — the ports were the only thing
130
+ * distinguishing them and neither survives. Revisit if a deployment ever needs a
131
+ * strand node to hold a fixed count of direct listeners.
132
+ */
133
+ function dedupe(addrs) {
134
+ return [...new Set(addrs)];
135
+ }
136
+ //# sourceMappingURL=strand-network-config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"strand-network-config.js","sourceRoot":"","sources":["../src/strand-network-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EAAE,SAAS,EAAkB,MAAM,yBAAyB,CAAC;AACpE,OAAO,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAkC/E;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAAC,OAAkC;IAChE,8EAA8E;IAC9E,mFAAmF;IACnF,qFAAqF;IACrF,sEAAsE;IACtE,MAAM,WAAW,GAAG,kBAAkB,CAAC,OAAO,CAAC,CAAC;IAChD,IAAI,CAAC,WAAW,EAAE,CAAC;QACjB,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,kFAAkF;IAClF,kFAAkF;IAClF,MAAM,SAAS,GAAG,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC,CAAC;IACnE,mFAAmF;IACnF,mFAAmF;IACnF,qFAAqF;IACrF,2CAA2C;IAC3C,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,GAAG,uBAAuB,CAAC,OAAO,EAAE,SAAS,CAAC,EAAE,CAAC;AACpF,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,SAAS,uBAAuB,CAAC,UAAkB;IACjD,IAAI,UAAuB,CAAC;IAC5B,IAAI,CAAC;QACH,UAAU,GAAG,SAAS,CAAC,UAAU,CAAC,CAAC,aAAa,EAAE,CAAC;IACrD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,UAAU,CAAC;IACpB,CAAC;IACD,IAAI,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,aAAa,CAAC,EAAE,CAAC;QACrD,OAAO,UAAU,CAAC;IACpB,CAAC;IACD,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC;QAClC,OAAO,UAAU,CAAC;IACpB,CAAC;IACD,OAAO,SAAS,CAAC,UAAU,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;AACjE,CAAC;AAED,mEAAmE;AACnE,SAAS,WAAW,CAAC,SAAoB;IACvC,OAAO,CAAC,SAAS,CAAC,IAAI,KAAK,KAAK,IAAI,SAAS,CAAC,IAAI,KAAK,KAAK,CAAC;WACxD,SAAS,CAAC,KAAK,KAAK,SAAS;WAC7B,SAAS,CAAC,KAAK,KAAK,GAAG,CAAC;AAC/B,CAAC;AAED;;;;;;GAMG;AACH,SAAS,iBAAiB,CAAC,SAAoB;IAC7C,OAAO,WAAW,CAAC,SAAS,CAAC;QAC3B,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,SAAS,CAAC,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE;QAC5D,CAAC,CAAC,SAAS,CAAC;AAChB,CAAC;AAED;;;;;;;;;;GAUG;AACH,SAAS,MAAM,CAAC,KAAwB;IACtC,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC;AAC7B,CAAC"}
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Strand revoked-peer enforcement: a per-strand deny gate that refuses the
3
+ * network nodes of a REMOVED party, so removal means "we stop talking to you"
4
+ * rather than only deleting a row.
5
+ *
6
+ * ## The deny set is revocation-keyed, never membership-keyed
7
+ *
8
+ * A strand node maintains a materialized in-memory set of revoked peer ids:
9
+ * every `Strand.MemberPeer.PeerId` whose `MemberKey` has no live `Strand.Member`
10
+ * row. `MemberPeer.MemberExists` runs on insert, so an orphaned binding can only
11
+ * mean "this member existed and was removed" — it is exactly the durable,
12
+ * replicated record of a removed party's machines. Everything NOT in the set is
13
+ * admitted. Deliberate consequences:
14
+ *
15
+ * - **Open strands are untouched.** `Member`/`MemberPeer` carry `OnlyClosed`
16
+ * checks, so an open strand has no rows and the set is empty — and
17
+ * `StrandInstanceManager` additionally skips arming this enforcer entirely for
18
+ * `Type !== 'c'`, so an open strand does not even poll.
19
+ * - **A joining party mid-admission is admitted** (it has no orphaned binding).
20
+ * An allowlist ("only peers bound to live members") was considered and
21
+ * rejected: a joiner's first membership writes must travel through the very
22
+ * cohort an allowlist would close to it — the same mutual-denial trap the
23
+ * control network's gater doc describes (`membership-connection-gater.ts`),
24
+ * with no enrollment window to carve out. A deny-list on positive evidence
25
+ * has no such trap.
26
+ * - **Stale-view failure direction: fail-open.** A remaining member that has not
27
+ * yet replicated the revocation keeps serving the removed party until the
28
+ * tombstoned state arrives — bounded by replication, consistent with
29
+ * "revocation is forward-looking". The opposite error (denying a legitimate
30
+ * member) is only reachable for a revoked-then-re-admitted party at a node
31
+ * that saw the revoke but not the re-add; the re-add gives the member key a
32
+ * live `Member` row again, which removes its peers from the deny set as the
33
+ * row replicates — self-healing, and the re-adding manager's own node updates
34
+ * first, so the heal never depends on talking to the peer being wrongly denied.
35
+ * - **Orphaned `MemberPeer` rows are load-bearing**: they ARE the deny record —
36
+ * the `Strand.Revocation` tombstone keeps only the stamp, not the peer id — so
37
+ * nothing here (or anywhere) tombstones or auto-cleans them at revocation.
38
+ * The manager-cleanup path (`removeMemberPeer`'s manager branch) stays as the
39
+ * only way to clear a wrongly-registered binding, and clearing an orphan
40
+ * forgets the denial (the NOTE at that branch says so).
41
+ *
42
+ * ## Removal cuts LIVE sessions too — the refresh IS the teardown seam
43
+ *
44
+ * Refusing NEW connections only half-honors "no longer be answered to or
45
+ * communicated with": a long-lived strand connection may never re-dial, so
46
+ * without teardown a removed party keeps whatever session it already holds.
47
+ * There is no push notification to hang teardown off — a REPLICATED revocation
48
+ * arrives with no change event (the strand database has no reactive
49
+ * subscription; `StrandWatcher` polls for the same reason), and a LOCAL
50
+ * `revokeMember`/`leaveStrand` writes through a bare `Database` handle this
51
+ * runtime never sees — so teardown rides the snapshot refresh itself, with
52
+ * `refresh()` exposed for immediacy
53
+ * (`StrandInstanceManager.refreshRevocationEnforcement` →
54
+ * `CadreNode.refreshRevocationEnforcement`).
55
+ *
56
+ * The sweep is driven by MEMBERSHIP in the deny set, never by an
57
+ * entered-the-set diff: every refresh hangs up every currently-connected
58
+ * revoked peer, so a resume, a crashed refresh, or a connection established
59
+ * between two refreshes cannot leave a session standing (a diff would be a
60
+ * strict subset of this, so none is kept). A peer with no open connection is
61
+ * simply not swept, and a failed `hangUp` is logged, never thrown — the next
62
+ * refresh retries it. `hangUp` closes DIRECT and RELAYED connections alike, and
63
+ * on a strand node running the relay server it also drops any reservation the
64
+ * revoked peer holds there (a reservation dies with its connection — see the
65
+ * relay-seam doc in `membership-connection-gater.ts`).
66
+ *
67
+ * Teardown is also what stops OUTBOUND traffic. The gate is one-directional —
68
+ * `authorizeStream` judges streams the revoked peer opens to US — so nothing
69
+ * else prevents this node from pushing over an already-open connection, which
70
+ * `PeerJoinBackfill` does for that connection's whole life. Closing the
71
+ * connection stops both directions at once, which is why no separate outbound
72
+ * gate exists.
73
+ *
74
+ * If this node's OWN peer id is in the set, it was itself removed (or it left —
75
+ * a voluntary departure files the same tombstone). Nothing is torn down for
76
+ * that: the other side's gate already refuses us, and every other revoked peer
77
+ * is still swept. Instead `onSelfRevoked` fires ONCE per entry into the set
78
+ * (re-armed if the party is re-admitted, so a second removal signals again),
79
+ * which `CadreNode` surfaces as `strand:revoked` so the embedding app decides
80
+ * what to do — this module never stops or leaves a strand on its own.
81
+ * Best-effort by nature: a removed party learns only if the tombstone
82
+ * replicated to it before the cut.
83
+ *
84
+ * ## What transfers from the control gater (`membership-connection-gater.ts`)
85
+ *
86
+ * Transfers: the two-layer shape (fail-closed per-stream gate as PRIMARY, an
87
+ * opportunistic connection-level deny on top), the synchronous in-memory
88
+ * snapshot judged by the stream gate (a live DB read inside a gate deadlocks —
89
+ * the same argument as the control node's `authorizeInboundControlStream`), the
90
+ * fail-open-on-error/timeout posture for the connection hooks
91
+ * (`decideWithinDeadline`, reused as-is), and the "compose over the caller's
92
+ * gater, every base hook preserved" mechanics.
93
+ *
94
+ * Does NOT transfer — the relay-reservation seam (`'admit-for-relay'` + the
95
+ * unauthorized budget). That machinery exists because the control gate denies on
96
+ * ABSENCE of placement, where a member whose row is in flight is
97
+ * indistinguishable from an outsider and a wrong reservation deny is
98
+ * unrecoverable. This gate denies on POSITIVE revocation evidence, so a
99
+ * reservation deny is never that kind of wrong answer; the one stale-wrong case
100
+ * (a missed re-add) heals via replication as above. `denyInboundRelayReservation`
101
+ * here is therefore a plain "deny iff revoked" — no budget, no admit-for-relay
102
+ * verdict, no reserve deadline.
103
+ *
104
+ * Does NOT transfer — the bring-up quiet period. A strand node's DB bring-up
105
+ * NEEDS its cross-party cohort connections; this enforcer simply starts with an
106
+ * empty snapshot (admit everything) until its first successful read — the same
107
+ * fail-open posture as everywhere else here.
108
+ *
109
+ * Does NOT transfer — every stranger carve-out (enrollment window, formation
110
+ * window, delegate grants). None of those protocols exist on a strand node.
111
+ *
112
+ * Unlike the control stream gate, no empty-snapshot carve-out is needed: an
113
+ * empty deny set means "nothing revoked", which correctly admits everyone — the
114
+ * deny-list shape makes the cold-start case and the steady case the same code
115
+ * path.
116
+ */
117
+ import type { ConnectionGater, PeerId } from '@libp2p/interface';
118
+ import type { Database } from '@quereus/quereus';
119
+ /**
120
+ * Default cadence of the deny-set refresh. Bounded staleness is the accepted
121
+ * failure direction (see the module doc's stale-view paragraph); 30 s keeps a
122
+ * revoked peer's post-replication admit window in the same order as the
123
+ * replication delay itself. Overridable via
124
+ * {@link StrandRevocationEnforcementConfig.pollIntervalMs}.
125
+ */
126
+ export declare const DEFAULT_REVOCATION_POLL_INTERVAL_MS = 30000;
127
+ /**
128
+ * Embedder tuning for the strand revoked-peer gate, threaded from
129
+ * `CadreNodeConfig.strandRevocationEnforcement` to
130
+ * `StartStrandConfig.revocationEnforcement` the way the backfill knob is.
131
+ */
132
+ export interface StrandRevocationEnforcementConfig {
133
+ /**
134
+ * Default true. False disarms the gate entirely for this node's strands —
135
+ * the pre-existing behaviour (a removed party's peers keep being served).
136
+ */
137
+ enabled?: boolean;
138
+ /** Deny-set refresh cadence, ms. Default {@link DEFAULT_REVOCATION_POLL_INTERVAL_MS}. */
139
+ pollIntervalMs?: number;
140
+ }
141
+ /** One `Strand.MemberPeer` row, as the deny-set derivation consumes it. */
142
+ export interface StrandMemberPeerBinding {
143
+ memberKey: string;
144
+ peerId: string;
145
+ }
146
+ /** The membership rows one deny-set derivation reads. */
147
+ export interface StrandRevocationRows {
148
+ /** Every live `Strand.Member.Key`. */
149
+ memberKeys: ReadonlySet<string>;
150
+ /** Every `Strand.MemberPeer` row (live-membered and orphaned alike). */
151
+ bindings: readonly StrandMemberPeerBinding[];
152
+ }
153
+ /**
154
+ * Timer seam so the refresh cadence is testable without fake timers — the
155
+ * injectable-clock idiom of `UnauthorizedReservationBudget`'s `now` argument,
156
+ * shaped for an interval-driven component. The default schedules a real
157
+ * interval and unrefs it so an armed enforcer never holds a process open.
158
+ */
159
+ export interface RevocationRefreshScheduler {
160
+ setInterval(fn: () => void, ms: number): unknown;
161
+ clearInterval(handle: unknown): void;
162
+ }
163
+ /**
164
+ * The minimum of a libp2p node the teardown sweep needs: who WE are, what is
165
+ * open, and how to close it. Deliberately not the node itself, so the sweep
166
+ * unit-tests against a three-property stub; `Libp2p` (hence
167
+ * `Libp2pNodeWithRepo`) satisfies it structurally, which is what
168
+ * `StrandInstanceManager` passes.
169
+ */
170
+ export interface StrandRevocationNetwork {
171
+ /** This node's own peer id — how the enforcer recognizes ITSELF in the deny set. */
172
+ readonly peerId: PeerId;
173
+ /** Every open connection. Only `remotePeer` is read — and reused as the hangUp target. */
174
+ getConnections(): readonly {
175
+ readonly remotePeer: PeerId;
176
+ }[];
177
+ /** Close every connection to `peer`: direct, relayed, and any reservation riding one. */
178
+ hangUp(peer: PeerId): Promise<void>;
179
+ }
180
+ export interface StrandRevocationEnforcerDeps {
181
+ /** Log tag naming which strand this enforcer serves (the strand id). */
182
+ label: string;
183
+ /**
184
+ * Read the membership rows the deny set is derived from. Injectable so the
185
+ * enforcer unit-tests without a strand database; production wires
186
+ * {@link readStrandRevocationRows} over the strand's live DB. A throw or
187
+ * rejection KEEPS the previous snapshot (never clears it) — the control
188
+ * model, `refreshAuthorizedControlPeers`.
189
+ */
190
+ readRows: () => Promise<StrandRevocationRows>;
191
+ /**
192
+ * Handle on the strand's libp2p node for the teardown sweep, read LAZILY on
193
+ * every sweep — never captured. It has to be: the enforcer is constructed
194
+ * BEFORE the node (the node's options embed the enforcer's predicates), and
195
+ * the node is dropped again on quiesce. Omit it, or return undefined, and the
196
+ * gate still refuses new connections — only teardown of OPEN ones is skipped.
197
+ */
198
+ getNetwork?: () => StrandRevocationNetwork | undefined;
199
+ /**
200
+ * Called ONCE each time this node's own peer id ENTERS the deny set — i.e.
201
+ * this node's party was removed from the strand, or left it. Not re-fired
202
+ * while it stays there; re-armed if the party is re-admitted, so a second
203
+ * removal signals again. Wired by `StrandInstanceManager` through to
204
+ * `CadreNode`'s `strand:revoked` event. A resume rebuilds the enforcer, so a
205
+ * still-revoked strand also legitimately re-fires once per resume. A throw is
206
+ * logged and swallowed — an embedder's handler must not derail the sweep.
207
+ */
208
+ onSelfRevoked?: () => void;
209
+ /** Timer seam; omit for real (unref'd) intervals. */
210
+ scheduler?: RevocationRefreshScheduler;
211
+ }
212
+ /**
213
+ * Read the deny-set inputs from a strand database: full-scan
214
+ * `Strand.MemberPeer` and `Strand.Member`, to be joined in JavaScript by the
215
+ * enforcer. Scan-and-filter, never seek — the composite-PK point-lookup
216
+ * unreliability documented at `scanMemberPeers` in `strand-membership-writer.ts`
217
+ * applies, so correctness depends only on each scan returning a superset of the
218
+ * live rows.
219
+ *
220
+ * **Scan order is load-bearing, and it is BINDINGS FIRST.** The two scans are
221
+ * separate reads with nothing holding them to one snapshot, so a write that
222
+ * lands between them is seen by the second scan and not the first. A member
223
+ * joins as `Member` row then `MemberPeer` row (`MemberExists` forces that
224
+ * order), so reading `Member` first would let a join that commits mid-read
225
+ * produce a binding whose member key is absent from the key set — a brand-new
226
+ * member classified as REVOKED, the one fail-CLOSED outcome this module exists
227
+ * to avoid. Reading bindings first inverts the skew: the new binding is simply
228
+ * not in the older bindings snapshot, so the joiner is unclassified and
229
+ * admitted, and a revocation that lands mid-read is still seen (the key is
230
+ * missing from the NEWER key scan). Every skew window then resolves fail-open,
231
+ * matching the module doc.
232
+ *
233
+ * NOTE: two whole-table scans per refresh (default every 30 s per closed
234
+ * strand). Fine at strand scale (a handful of members and bindings); if
235
+ * membership churn ever makes these tables large, the fix is a reliable
236
+ * filtered read — which needs the networked point-lookup gap closed
237
+ * (`debt-composite-pk-point-lookup-unreliable-untracked`) — not a bigger scan.
238
+ */
239
+ export declare function readStrandRevocationRows(db: Database): Promise<StrandRevocationRows>;
240
+ /**
241
+ * The per-strand revoked-peer deny set and the synchronous predicates over it.
242
+ *
243
+ * Lifecycle mirrors `PeerJoinBackfill`: created in
244
+ * `StrandInstanceManager.buildStrandRuntime` (closed strands only), started
245
+ * after the strand database initializes, stopped and dropped in
246
+ * `releaseRuntime` — so quiesce → resume rebuilds it with a fresh snapshot.
247
+ * The snapshot starts EMPTY (admit everything) and repopulates on the first
248
+ * successful read; a revoked peer therefore gets a short admit window per
249
+ * resume at the connection level. Accepted: fail-open posture, and resume is
250
+ * never blocked on a refresh.
251
+ */
252
+ export declare class StrandRevocationEnforcer {
253
+ private readonly deps;
254
+ /** The materialized deny set — peer ids with an orphaned binding and no live one. */
255
+ private revokedPeerIds;
256
+ private readonly scheduler;
257
+ private readonly pollIntervalMs;
258
+ /** Tail of the refresh chain — what serializes refreshes (no two reads in flight). */
259
+ private refreshTail;
260
+ private refreshing;
261
+ private intervalHandle;
262
+ private started;
263
+ private stopped;
264
+ /** Latch for the one-shot self-revocation signal — see {@link StrandRevocationEnforcerDeps.onSelfRevoked}. */
265
+ private selfRevokedSignaled;
266
+ constructor(deps: StrandRevocationEnforcerDeps, config?: StrandRevocationEnforcementConfig);
267
+ /** Number of peers currently denied — test/diagnostic surface. */
268
+ get revokedCount(): number;
269
+ /** Kick an immediate refresh (not awaited — bring-up never blocks on it) and arm the poll. */
270
+ start(): void;
271
+ /** Disarm the poll; a read already in flight completes but its result is discarded. */
272
+ stop(): void;
273
+ /**
274
+ * Refresh the deny set now. Never rejects. Serialized: concurrent calls chain,
275
+ * so no two reads overlap, and each explicit call gets a read that STARTS
276
+ * after the call (an on-demand refresh after a revocation commit observes that
277
+ * commit). A failed or slow read keeps the previous snapshot — never clears it.
278
+ */
279
+ refresh(): Promise<void>;
280
+ /**
281
+ * Is `remotePeerId` positively revoked in the current snapshot? Synchronous by
282
+ * design — this is judged inside libp2p gates where an await against the DB
283
+ * would deadlock (the control stream gate's argument).
284
+ */
285
+ isRevoked(remotePeerId: string): boolean;
286
+ /**
287
+ * The fail-closed PRIMARY layer: the per-stream predicate wired as
288
+ * `createLibp2pNode({ authorizeInboundStream })`, gating all four
289
+ * `/optimystic/strand-<id>/{repo,cluster,sync,block-transfer}` protocols.
290
+ * Also what catches a revoked peer arriving over a RELAYED connection, or
291
+ * over a connection admitted before the revocation replicated here.
292
+ */
293
+ authorizeStream(remotePeerId: string, _protocol: string): boolean;
294
+ /**
295
+ * One serialized snapshot rebuild; contains every failure (contract: never rejects).
296
+ *
297
+ * NOTE: nothing here carries a deadline — a `readRows` or a `hangUp` that never
298
+ * settles wedges the refresh chain, which freezes the deny set (the poll skips
299
+ * while `refreshing`, and every later `refresh()` chains behind the stuck one).
300
+ * Both are bounded in practice (Quereus reads settle; libp2p's connection close
301
+ * has its own timeout). If a strand is ever seen with a stale deny set and no
302
+ * refresh log line, wrap both awaits in a timeout rather than hunting further.
303
+ */
304
+ private doRefresh;
305
+ /**
306
+ * Close what is already OPEN to the peers of `revoked` — the teardown half of
307
+ * enforcement (module doc: "Removal cuts LIVE sessions too"). Never rejects:
308
+ * every failure is contained, so the refresh loop survives and simply retries
309
+ * on the next tick.
310
+ *
311
+ * `revoked` is passed IN rather than read off the field, so the sweep judges
312
+ * the exact snapshot its own refresh produced; likewise the connection list is
313
+ * enumerated once, before any await, so a peer re-admitted mid-sweep is at
314
+ * worst hung up once and reconnects.
315
+ *
316
+ * NOTE: dropping the revoked peer from the strand's Optimystic cohort needs no
317
+ * separate action — machines quiesce strands routinely and the remaining cohort
318
+ * keeps committing, so an unreachable cohort peer is already tolerated, and
319
+ * `denyDialPeer` makes the exclusion fast (a local refusal, not a dial timeout).
320
+ * Two residual facts, both accepted: the removed node keeps every block it had
321
+ * already replicated (revocation is forward-looking), and a block whose only
322
+ * holders were the removed party's machines can be unavailable to the rest
323
+ * until repair.
324
+ *
325
+ * NOTE: post-removal commit latency is real and MEASURED, not hypothetical. On
326
+ * the two-parties-by-two-machines fixture in
327
+ * `integration-tests/src/scenarios/strand-removal-cuts-network.integration.ts`
328
+ * the remaining cohort's FIRST write after a cut fails repeatedly with
329
+ * `BlockUnavailableError: … (peers-unreachable)` before it commits — three or
330
+ * four failures, 3.5-4.3 s, over four runs — because the cohort still listed
331
+ * the removed party's now unreachable machines and had to downsize to the
332
+ * live holders first. It
333
+ * recovers on its own, so this is a latency cost, not a defect, and it is
334
+ * stated for app authors in `docs/strands.md` → Removing Members. If it ever
335
+ * stops recovering — a write that never commits, or `cluster-fetch:no-quorum` —
336
+ * that is an upstream (Optimystic) conversation, not a workaround here.
337
+ */
338
+ private tearDownRevoked;
339
+ /** The lazily-read network handle; an accessor that throws costs this pass only. */
340
+ private resolveNetwork;
341
+ /** One hangUp, contained: a stopping node or an already-closed connection is not an error here. */
342
+ private hangUpRevoked;
343
+ /**
344
+ * Latch {@link StrandRevocationEnforcerDeps.onSelfRevoked} to the TRANSITION
345
+ * into the revoked state: fired once on entry, and re-armed the moment this
346
+ * node is out of the set again — a manager can re-admit a party it removed
347
+ * (a fresh `Member` row makes the orphaned bindings live again), and a second
348
+ * removal has to be reported like the first.
349
+ */
350
+ private trackSelfRevoked;
351
+ }
352
+ /** The one capability the gater composition needs from the enforcer. */
353
+ export type StrandRevocationJudge = Pick<StrandRevocationEnforcer, 'isRevoked'>;
354
+ /**
355
+ * Build a closed strand's connection gater: the caller-supplied gater (if any)
356
+ * with revoked-peer denial composed onto three hooks. The opportunistic
357
+ * SECONDARY layer — the per-stream gate above is the fail-closed primary.
358
+ *
359
+ * - `denyInboundEncryptedConnection`: deny iff revoked, so a removed party's
360
+ * node is refused before any protocol negotiation.
361
+ * - `denyDialPeer`: deny iff revoked — the owner ruling covers "communicated
362
+ * with", not just "answered to", and refusing the dial locally is instant,
363
+ * which keeps Optimystic from wasting a dial timeout on a peer this node
364
+ * will not talk to anyway.
365
+ * - `denyInboundRelayReservation`: deny iff revoked — matters when the strand
366
+ * node runs the relay server (the storage-profile default), where admitting
367
+ * the reservation would let a removed party keep using this node's
368
+ * forwarding capacity.
369
+ *
370
+ * Composition semantics: every hook of `base` is preserved as-is; on the three
371
+ * composed hooks a deny from EITHER the base gater or the revocation check
372
+ * denies, and an error (or a decision slower than `decisionTimeoutMs`) in
373
+ * either is fail-open — the base error admits through to the revocation check,
374
+ * a revocation-check error admits outright, and the fail-closed stream gate
375
+ * still stands behind both. The deadline is belt-and-braces: the snapshot read
376
+ * is synchronous.
377
+ *
378
+ * NOTE: accepted tradeoff — swallowing a BASE hook's throw diverges from
379
+ * `createMembershipConnectionGater`, which lets it propagate to libp2p (a
380
+ * fail-closed outcome there). Uniform fail-open was chosen for this module so
381
+ * every hook has one failure direction, and it was the shape the plan specified;
382
+ * the cost is that an embedder gater which throws is silently admitted here
383
+ * while the same gater passed raw to an OPEN strand would refuse. Loud in the
384
+ * log either way. Revisit if an embedder ever ships a gater whose throw is a
385
+ * meaningful deny, or if the two gaters are unified.
386
+ *
387
+ * NOTE: `base` is spread, so a gater passed as a CLASS INSTANCE would lose its
388
+ * prototype methods — same caveat as `createMembershipConnectionGater`; every
389
+ * caller in this repo supplies a plain object (the RN/web permissive gaters,
390
+ * e.g. `{ denyDialMultiaddr: () => false }`, keep working unchanged).
391
+ */
392
+ export declare function createRevocationConnectionGater(enforcer: StrandRevocationJudge, base?: ConnectionGater, decisionTimeoutMs?: number): ConnectionGater;