@serfab/cadre-core 0.6.0 → 0.8.1

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 (166) hide show
  1. package/README.md +9 -8
  2. package/dist/authority-key.d.ts +41 -0
  3. package/dist/authority-key.js +52 -0
  4. package/dist/authority-key.js.map +1 -0
  5. package/dist/cadre-node.d.ts +777 -11
  6. package/dist/cadre-node.js +1912 -82
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/canonical-datetime.d.ts +33 -0
  9. package/dist/canonical-datetime.js +38 -0
  10. package/dist/canonical-datetime.js.map +1 -0
  11. package/dist/canonical-json.d.ts +12 -0
  12. package/dist/canonical-json.js +33 -0
  13. package/dist/canonical-json.js.map +1 -0
  14. package/dist/control-cohort.d.ts +63 -0
  15. package/dist/control-cohort.js +80 -0
  16. package/dist/control-cohort.js.map +1 -0
  17. package/dist/control-database.d.ts +259 -5
  18. package/dist/control-database.js +541 -128
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +89 -0
  21. package/dist/control-formation-recorder.js +139 -0
  22. package/dist/control-formation-recorder.js.map +1 -0
  23. package/dist/control-schema.d.ts +12 -0
  24. package/dist/control-schema.js +199 -0
  25. package/dist/control-schema.js.map +1 -0
  26. package/dist/control-stream.d.ts +54 -0
  27. package/dist/control-stream.js +104 -0
  28. package/dist/control-stream.js.map +1 -0
  29. package/dist/device-token.d.ts +50 -0
  30. package/dist/device-token.js +66 -0
  31. package/dist/device-token.js.map +1 -0
  32. package/dist/diagnostics/connection-path.d.ts +118 -0
  33. package/dist/diagnostics/connection-path.js +204 -0
  34. package/dist/diagnostics/connection-path.js.map +1 -0
  35. package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
  36. package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
  37. package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
  38. package/dist/hibernation-manager.d.ts +79 -0
  39. package/dist/hibernation-manager.js +173 -23
  40. package/dist/hibernation-manager.js.map +1 -1
  41. package/dist/index.d.ts +25 -4
  42. package/dist/index.js +59 -4
  43. package/dist/index.js.map +1 -1
  44. package/dist/key-store-file.d.ts +52 -0
  45. package/dist/key-store-file.js +194 -0
  46. package/dist/key-store-file.js.map +1 -0
  47. package/dist/key-store.d.ts +75 -0
  48. package/dist/key-store.js +66 -0
  49. package/dist/key-store.js.map +1 -0
  50. package/dist/peer-authorization.d.ts +26 -0
  51. package/dist/peer-authorization.js +40 -0
  52. package/dist/peer-authorization.js.map +1 -0
  53. package/dist/peer-record.d.ts +89 -0
  54. package/dist/peer-record.js +117 -0
  55. package/dist/peer-record.js.map +1 -0
  56. package/dist/push-credentials.d.ts +34 -0
  57. package/dist/push-credentials.js +81 -0
  58. package/dist/push-credentials.js.map +1 -0
  59. package/dist/push-fanout.d.ts +137 -0
  60. package/dist/push-fanout.js +197 -0
  61. package/dist/push-fanout.js.map +1 -0
  62. package/dist/push-notifier-apns.d.ts +53 -0
  63. package/dist/push-notifier-apns.js +195 -0
  64. package/dist/push-notifier-apns.js.map +1 -0
  65. package/dist/push-notifier-fcm.d.ts +38 -0
  66. package/dist/push-notifier-fcm.js +183 -0
  67. package/dist/push-notifier-fcm.js.map +1 -0
  68. package/dist/push-notifier-shared.d.ts +18 -0
  69. package/dist/push-notifier-shared.js +25 -0
  70. package/dist/push-notifier-shared.js.map +1 -0
  71. package/dist/push-notifier.d.ts +71 -0
  72. package/dist/push-notifier.js +50 -0
  73. package/dist/push-notifier.js.map +1 -0
  74. package/dist/schema-verification.d.ts +11 -2
  75. package/dist/schema-verification.js +15 -4
  76. package/dist/schema-verification.js.map +1 -1
  77. package/dist/seed-bootstrap.d.ts +210 -7
  78. package/dist/seed-bootstrap.js +429 -131
  79. package/dist/seed-bootstrap.js.map +1 -1
  80. package/dist/seed-trust-policy.d.ts +63 -0
  81. package/dist/seed-trust-policy.js +56 -0
  82. package/dist/seed-trust-policy.js.map +1 -0
  83. package/dist/strand-addr-protocol.d.ts +132 -0
  84. package/dist/strand-addr-protocol.js +313 -0
  85. package/dist/strand-addr-protocol.js.map +1 -0
  86. package/dist/strand-cohort.d.ts +54 -0
  87. package/dist/strand-cohort.js +38 -0
  88. package/dist/strand-cohort.js.map +1 -0
  89. package/dist/strand-database.d.ts +54 -6
  90. package/dist/strand-database.js +88 -89
  91. package/dist/strand-database.js.map +1 -1
  92. package/dist/strand-formation-manager.d.ts +79 -19
  93. package/dist/strand-formation-manager.js +196 -104
  94. package/dist/strand-formation-manager.js.map +1 -1
  95. package/dist/strand-formation-protocol.d.ts +162 -0
  96. package/dist/strand-formation-protocol.js +279 -0
  97. package/dist/strand-formation-protocol.js.map +1 -0
  98. package/dist/strand-instance-manager.d.ts +73 -4
  99. package/dist/strand-instance-manager.js +167 -41
  100. package/dist/strand-instance-manager.js.map +1 -1
  101. package/dist/strand-member-key.d.ts +29 -0
  102. package/dist/strand-member-key.js +38 -0
  103. package/dist/strand-member-key.js.map +1 -0
  104. package/dist/strand-member-registry.d.ts +98 -0
  105. package/dist/strand-member-registry.js +129 -0
  106. package/dist/strand-member-registry.js.map +1 -0
  107. package/dist/strand-membership-writer.d.ts +361 -0
  108. package/dist/strand-membership-writer.js +449 -0
  109. package/dist/strand-membership-writer.js.map +1 -0
  110. package/dist/strand-solicitation.d.ts +77 -2
  111. package/dist/strand-solicitation.js +25 -4
  112. package/dist/strand-solicitation.js.map +1 -1
  113. package/dist/strand-wake-payload.d.ts +30 -0
  114. package/dist/strand-wake-payload.js +20 -0
  115. package/dist/strand-wake-payload.js.map +1 -0
  116. package/dist/strand-wake-protocol.d.ts +114 -0
  117. package/dist/strand-wake-protocol.js +257 -0
  118. package/dist/strand-wake-protocol.js.map +1 -0
  119. package/dist/strand-watcher.d.ts +6 -2
  120. package/dist/strand-watcher.js +57 -17
  121. package/dist/strand-watcher.js.map +1 -1
  122. package/dist/types.d.ts +413 -23
  123. package/dist/types.js +22 -5
  124. package/dist/types.js.map +1 -1
  125. package/package.json +14 -8
  126. package/src/authority-key.ts +68 -0
  127. package/src/cadre-node.ts +2937 -929
  128. package/src/canonical-datetime.ts +39 -0
  129. package/src/canonical-json.ts +30 -0
  130. package/src/control-cohort.ts +103 -0
  131. package/src/control-database.ts +931 -383
  132. package/src/control-formation-recorder.ts +151 -0
  133. package/src/control-schema.ts +198 -0
  134. package/src/control-stream.ts +128 -0
  135. package/src/device-token.ts +89 -0
  136. package/src/diagnostics/connection-path.ts +277 -0
  137. package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
  138. package/src/hibernation-manager.ts +197 -28
  139. package/src/index.ts +273 -79
  140. package/src/key-store-file.ts +203 -0
  141. package/src/key-store.ts +99 -0
  142. package/src/peer-authorization.ts +53 -0
  143. package/src/peer-record.ts +146 -0
  144. package/src/push-credentials.ts +84 -0
  145. package/src/push-fanout.ts +272 -0
  146. package/src/push-notifier-apns.ts +243 -0
  147. package/src/push-notifier-fcm.ts +221 -0
  148. package/src/push-notifier-shared.ts +28 -0
  149. package/src/push-notifier.ts +93 -0
  150. package/src/schema-verification.ts +18 -4
  151. package/src/seed-bootstrap.ts +504 -171
  152. package/src/seed-trust-policy.ts +107 -0
  153. package/src/strand-addr-protocol.ts +410 -0
  154. package/src/strand-cohort.ts +77 -0
  155. package/src/strand-database.ts +113 -126
  156. package/src/strand-formation-manager.ts +233 -142
  157. package/src/strand-formation-protocol.ts +391 -0
  158. package/src/strand-instance-manager.ts +207 -56
  159. package/src/strand-member-key.ts +39 -0
  160. package/src/strand-member-registry.ts +177 -0
  161. package/src/strand-membership-writer.ts +640 -0
  162. package/src/strand-solicitation.ts +93 -4
  163. package/src/strand-wake-payload.ts +32 -0
  164. package/src/strand-wake-protocol.ts +331 -0
  165. package/src/strand-watcher.ts +61 -17
  166. package/src/types.ts +1012 -575
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Peer-address record helpers: the single source of truth for the bytes a node
3
+ * signs when publishing its own `CadrePeer` row and re-verifies when resolving
4
+ * another peer's row.
5
+ *
6
+ * The signed payload is a single SHA-256 digest of a delimited string of the
7
+ * authenticated fields. It is intentionally NOT JSON-canonicalized: a delimited
8
+ * digest is trivially deterministic across node/browser/RN (plain string concat
9
+ * + sha256, no key ordering) and — crucially — is reconstructable inside the
10
+ * `CadrePeer.AuthorizedUpdate` SQL constraint from the row's own columns:
11
+ *
12
+ * digest(new.PeerId || '|' || new.Multiaddr || '|' || cast(new.UpdatedAt as text))
13
+ *
14
+ * Keep {@link peerRecordSignedPayload} and that constraint byte-for-byte in
15
+ * sync. The `'|'` delimiter is safe: a base58btc PeerId, a multiaddr (which uses
16
+ * `/`), and a base-10 integer never contain it.
17
+ *
18
+ * `publicKey` is deliberately excluded from the payload — it is the key the
19
+ * signature is verified *with* (so a signature already commits to exactly one
20
+ * key), and its binding to `peerId` is checked separately at resolve time.
21
+ */
22
+ import type { PeerAddressRecord, PeerResolveTrustPolicy } from './types.js';
23
+ /**
24
+ * Default freshness ceiling for resolved peer-address records (15 minutes).
25
+ * A record older than this is treated as stale and filtered out, so a resolver
26
+ * never hands back a dead relay reservation. The publish path re-stamps at
27
+ * roughly half this interval (see {@link DEFAULT_PEER_RECORD_HEARTBEAT_MS}).
28
+ */
29
+ export declare const DEFAULT_PEER_RECORD_MAX_AGE_MS: number;
30
+ /**
31
+ * TTL heartbeat interval for re-publishing the self record — half the freshness
32
+ * ceiling, so a record is refreshed well before it can go stale.
33
+ */
34
+ export declare const DEFAULT_PEER_RECORD_HEARTBEAT_MS: number;
35
+ /**
36
+ * Build the base64url SHA-256 digest that the self-signature covers. Mirrors the
37
+ * `CadrePeer.AuthorizedUpdate` constraint exactly; both sides take the default
38
+ * base64url output of a single `digest(...)`, which round-trips cleanly (unlike
39
+ * concatenating several base64url digests).
40
+ *
41
+ * @param peerId - base58btc libp2p peer id (the row key)
42
+ * @param multiaddr - the comma-joined `Multiaddr` string EXACTLY as stored
43
+ * @param updatedAt - epoch-ms freshness stamp
44
+ */
45
+ export declare function peerRecordSignedPayload(peerId: string, multiaddr: string, updatedAt: number): string;
46
+ /**
47
+ * Sign a peer-address record with the ed25519 private key behind its `peerId`.
48
+ * Returns a fully-populated {@link PeerAddressRecord} (the `addrs` order is
49
+ * preserved and is the order signed over).
50
+ *
51
+ * @param fields - the record fields to sign (signaling addr first by convention)
52
+ * @param privateKeyB64 - base64url ed25519 seed (see `authorityKeyFromLibp2p`)
53
+ */
54
+ export declare function signPeerRecord(fields: {
55
+ peerId: string;
56
+ publicKey: string;
57
+ addrs: string[];
58
+ updatedAt: number;
59
+ }, privateKeyB64: string): PeerAddressRecord;
60
+ /**
61
+ * Verify a record's self-signature against its own `publicKey`. Reconstructs the
62
+ * signed bytes from the record exactly as {@link signPeerRecord} produced them.
63
+ * Returns false on a missing key/sig or any verification failure.
64
+ */
65
+ export declare function verifyPeerRecordSignature(record: PeerAddressRecord): boolean;
66
+ /**
67
+ * Freshness predicate: true when `updatedAt` is a positive stamp within
68
+ * `maxAgeMs` of `now`. A non-positive `updatedAt` (never self-published) is
69
+ * never fresh.
70
+ */
71
+ export declare function isPeerRecordFresh(updatedAt: number, maxAgeMs: number, now: number): boolean;
72
+ /** True if `addr` is a `/p2p-circuit` signaling/relay multiaddr. */
73
+ export declare function isSignalingAddr(addr: string): boolean;
74
+ /**
75
+ * Return `addrs` with signaling (`/p2p-circuit`) addrs first, otherwise stable.
76
+ * Used to present the WebRTC dial input ahead of direct addrs. Does NOT mutate
77
+ * the input and must not be used before signature verification (verification
78
+ * uses the original on-record order).
79
+ */
80
+ export declare function orderSignalingFirst(addrs: string[]): string[];
81
+ /**
82
+ * Default resolve trust gate: trust any peer that has a `CadrePeer` row.
83
+ *
84
+ * A row only exists because an authority signed its `AuthorizedInsert`, so row
85
+ * presence already means "authority-vouched member". This is the seam where a
86
+ * stricter policy (trust circle, pinned keys) plugs in — see
87
+ * {@link PeerResolveTrustPolicy}.
88
+ */
89
+ export declare function currentMemberTrustPolicy(): PeerResolveTrustPolicy;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Peer-address record helpers: the single source of truth for the bytes a node
3
+ * signs when publishing its own `CadrePeer` row and re-verifies when resolving
4
+ * another peer's row.
5
+ *
6
+ * The signed payload is a single SHA-256 digest of a delimited string of the
7
+ * authenticated fields. It is intentionally NOT JSON-canonicalized: a delimited
8
+ * digest is trivially deterministic across node/browser/RN (plain string concat
9
+ * + sha256, no key ordering) and — crucially — is reconstructable inside the
10
+ * `CadrePeer.AuthorizedUpdate` SQL constraint from the row's own columns:
11
+ *
12
+ * digest(new.PeerId || '|' || new.Multiaddr || '|' || cast(new.UpdatedAt as text))
13
+ *
14
+ * Keep {@link peerRecordSignedPayload} and that constraint byte-for-byte in
15
+ * sync. The `'|'` delimiter is safe: a base58btc PeerId, a multiaddr (which uses
16
+ * `/`), and a base-10 integer never contain it.
17
+ *
18
+ * `publicKey` is deliberately excluded from the payload — it is the key the
19
+ * signature is verified *with* (so a signature already commits to exactly one
20
+ * key), and its binding to `peerId` is checked separately at resolve time.
21
+ */
22
+ import { digest, sign, verify } from '@optimystic/quereus-plugin-crypto';
23
+ /**
24
+ * Default freshness ceiling for resolved peer-address records (15 minutes).
25
+ * A record older than this is treated as stale and filtered out, so a resolver
26
+ * never hands back a dead relay reservation. The publish path re-stamps at
27
+ * roughly half this interval (see {@link DEFAULT_PEER_RECORD_HEARTBEAT_MS}).
28
+ */
29
+ export const DEFAULT_PEER_RECORD_MAX_AGE_MS = 15 * 60 * 1000;
30
+ /**
31
+ * TTL heartbeat interval for re-publishing the self record — half the freshness
32
+ * ceiling, so a record is refreshed well before it can go stale.
33
+ */
34
+ export const DEFAULT_PEER_RECORD_HEARTBEAT_MS = DEFAULT_PEER_RECORD_MAX_AGE_MS / 2;
35
+ /** The signaling/relay prefix a WebRTC (or any relayed) dial path consumes. */
36
+ const SIGNALING_PREFIX = '/p2p-circuit';
37
+ /**
38
+ * Build the base64url SHA-256 digest that the self-signature covers. Mirrors the
39
+ * `CadrePeer.AuthorizedUpdate` constraint exactly; both sides take the default
40
+ * base64url output of a single `digest(...)`, which round-trips cleanly (unlike
41
+ * concatenating several base64url digests).
42
+ *
43
+ * @param peerId - base58btc libp2p peer id (the row key)
44
+ * @param multiaddr - the comma-joined `Multiaddr` string EXACTLY as stored
45
+ * @param updatedAt - epoch-ms freshness stamp
46
+ */
47
+ export function peerRecordSignedPayload(peerId, multiaddr, updatedAt) {
48
+ const joined = `${peerId}|${multiaddr}|${updatedAt}`;
49
+ return digest([joined], 'sha256', 'base64url');
50
+ }
51
+ /**
52
+ * Sign a peer-address record with the ed25519 private key behind its `peerId`.
53
+ * Returns a fully-populated {@link PeerAddressRecord} (the `addrs` order is
54
+ * preserved and is the order signed over).
55
+ *
56
+ * @param fields - the record fields to sign (signaling addr first by convention)
57
+ * @param privateKeyB64 - base64url ed25519 seed (see `authorityKeyFromLibp2p`)
58
+ */
59
+ export function signPeerRecord(fields, privateKeyB64) {
60
+ const payloadDigest = peerRecordSignedPayload(fields.peerId, fields.addrs.join(','), fields.updatedAt);
61
+ const sig = sign(payloadDigest, privateKeyB64, 'ed25519', 'base64url', 'base64url', 'base64url');
62
+ return { peerId: fields.peerId, publicKey: fields.publicKey, addrs: [...fields.addrs], updatedAt: fields.updatedAt, sig };
63
+ }
64
+ /**
65
+ * Verify a record's self-signature against its own `publicKey`. Reconstructs the
66
+ * signed bytes from the record exactly as {@link signPeerRecord} produced them.
67
+ * Returns false on a missing key/sig or any verification failure.
68
+ */
69
+ export function verifyPeerRecordSignature(record) {
70
+ if (!record.publicKey || !record.sig) {
71
+ return false;
72
+ }
73
+ const payloadDigest = peerRecordSignedPayload(record.peerId, record.addrs.join(','), record.updatedAt);
74
+ return verify(payloadDigest, record.sig, record.publicKey, 'ed25519', 'base64url', 'base64url', 'base64url');
75
+ }
76
+ /**
77
+ * Freshness predicate: true when `updatedAt` is a positive stamp within
78
+ * `maxAgeMs` of `now`. A non-positive `updatedAt` (never self-published) is
79
+ * never fresh.
80
+ */
81
+ export function isPeerRecordFresh(updatedAt, maxAgeMs, now) {
82
+ if (!(updatedAt > 0)) {
83
+ return false;
84
+ }
85
+ return now - updatedAt <= maxAgeMs;
86
+ }
87
+ /** True if `addr` is a `/p2p-circuit` signaling/relay multiaddr. */
88
+ export function isSignalingAddr(addr) {
89
+ return addr.includes(SIGNALING_PREFIX);
90
+ }
91
+ /**
92
+ * Return `addrs` with signaling (`/p2p-circuit`) addrs first, otherwise stable.
93
+ * Used to present the WebRTC dial input ahead of direct addrs. Does NOT mutate
94
+ * the input and must not be used before signature verification (verification
95
+ * uses the original on-record order).
96
+ */
97
+ export function orderSignalingFirst(addrs) {
98
+ const signaling = addrs.filter(isSignalingAddr);
99
+ const rest = addrs.filter(a => !isSignalingAddr(a));
100
+ return [...signaling, ...rest];
101
+ }
102
+ /**
103
+ * Default resolve trust gate: trust any peer that has a `CadrePeer` row.
104
+ *
105
+ * A row only exists because an authority signed its `AuthorizedInsert`, so row
106
+ * presence already means "authority-vouched member". This is the seam where a
107
+ * stricter policy (trust circle, pinned keys) plugs in — see
108
+ * {@link PeerResolveTrustPolicy}.
109
+ */
110
+ export function currentMemberTrustPolicy() {
111
+ return {
112
+ evaluate() {
113
+ return true;
114
+ },
115
+ };
116
+ }
117
+ //# sourceMappingURL=peer-record.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"peer-record.js","sourceRoot":"","sources":["../src/peer-record.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * push-credentials.ts — pure validation + log-redaction for {@link PushCredentials}.
3
+ *
4
+ * The platform-push credentials a deployment provisions (`cadre-host` from its
5
+ * secret store, `cadre-provider` from per-tenant config) are written into a
6
+ * spawned node's config under `CadreNodeConfig.push`. This module is the
7
+ * dependency-free seam those provisioners share to (a) reject a *partial* set
8
+ * before it reaches a node — a present platform block must carry all its required
9
+ * fields, or it fails fast at provisioning rather than at first push — and (b)
10
+ * produce a log-safe view that never leaks a private key.
11
+ *
12
+ * It imports only the *types* (erased at emit), so it pulls no node:crypto /
13
+ * node:http2 edge — a provisioner can import these functions without dragging in
14
+ * the FCM/APNs senders.
15
+ */
16
+ import type { PushCredentials } from './types.js';
17
+ /** Marker substituted for every secret field by {@link redactPushCredentials}. */
18
+ export declare const REDACTED = "[redacted]";
19
+ /**
20
+ * Validate a resolved push-credential bundle, returning human-readable errors
21
+ * (empty ⇒ valid). Each platform block is optional, but a *present* block must
22
+ * carry every required field — a partial set (e.g. APNs `keyId` without
23
+ * `privateKey`) is a misconfiguration that must surface at provisioning time, not
24
+ * silently at the first wake. A bundle with neither platform is valid (push is
25
+ * opt-in); callers that require at least one platform check that separately.
26
+ */
27
+ export declare function validatePushCredentials(push: PushCredentials): string[];
28
+ /**
29
+ * Return a log-safe shallow copy of a push bundle with every private key replaced
30
+ * by {@link REDACTED}. Use this for ANY debug/log line that touches push config —
31
+ * `privateKey` fields are secrets and must never reach a log sink (the same rule
32
+ * the startup/seed tokens follow).
33
+ */
34
+ export declare function redactPushCredentials(push: PushCredentials): Record<string, unknown>;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * push-credentials.ts — pure validation + log-redaction for {@link PushCredentials}.
3
+ *
4
+ * The platform-push credentials a deployment provisions (`cadre-host` from its
5
+ * secret store, `cadre-provider` from per-tenant config) are written into a
6
+ * spawned node's config under `CadreNodeConfig.push`. This module is the
7
+ * dependency-free seam those provisioners share to (a) reject a *partial* set
8
+ * before it reaches a node — a present platform block must carry all its required
9
+ * fields, or it fails fast at provisioning rather than at first push — and (b)
10
+ * produce a log-safe view that never leaks a private key.
11
+ *
12
+ * It imports only the *types* (erased at emit), so it pulls no node:crypto /
13
+ * node:http2 edge — a provisioner can import these functions without dragging in
14
+ * the FCM/APNs senders.
15
+ */
16
+ /** Marker substituted for every secret field by {@link redactPushCredentials}. */
17
+ export const REDACTED = '[redacted]';
18
+ /**
19
+ * Validate a resolved push-credential bundle, returning human-readable errors
20
+ * (empty ⇒ valid). Each platform block is optional, but a *present* block must
21
+ * carry every required field — a partial set (e.g. APNs `keyId` without
22
+ * `privateKey`) is a misconfiguration that must surface at provisioning time, not
23
+ * silently at the first wake. A bundle with neither platform is valid (push is
24
+ * opt-in); callers that require at least one platform check that separately.
25
+ */
26
+ export function validatePushCredentials(push) {
27
+ const errors = [];
28
+ if (push.fcm)
29
+ errors.push(...validateFcm(push.fcm));
30
+ if (push.apns)
31
+ errors.push(...validateApns(push.apns));
32
+ return errors;
33
+ }
34
+ function validateFcm(fcm) {
35
+ return missingFields('fcm', fcm, [
36
+ 'projectId',
37
+ 'clientEmail',
38
+ 'privateKey',
39
+ ]);
40
+ }
41
+ function validateApns(apns) {
42
+ return missingFields('apns', apns, [
43
+ 'keyId',
44
+ 'teamId',
45
+ 'bundleId',
46
+ 'privateKey',
47
+ ]);
48
+ }
49
+ /** Names the required fields of `platform` that are missing or blank. */
50
+ function missingFields(platform, obj, required) {
51
+ return required
52
+ .filter((f) => typeof obj[f] !== 'string' || obj[f].trim().length === 0)
53
+ .map((f) => `push.${platform}.${f} is required when a ${platform} credential block is present`);
54
+ }
55
+ /**
56
+ * Return a log-safe shallow copy of a push bundle with every private key replaced
57
+ * by {@link REDACTED}. Use this for ANY debug/log line that touches push config —
58
+ * `privateKey` fields are secrets and must never reach a log sink (the same rule
59
+ * the startup/seed tokens follow).
60
+ */
61
+ export function redactPushCredentials(push) {
62
+ const out = {};
63
+ if (push.fcm) {
64
+ out.fcm = { projectId: push.fcm.projectId, clientEmail: push.fcm.clientEmail, privateKey: REDACTED };
65
+ }
66
+ if (push.apns) {
67
+ out.apns = {
68
+ keyId: push.apns.keyId,
69
+ teamId: push.apns.teamId,
70
+ bundleId: push.apns.bundleId,
71
+ ...(push.apns.production !== undefined ? { production: push.apns.production } : {}),
72
+ privateKey: REDACTED,
73
+ };
74
+ }
75
+ if (push.cooldownMs !== undefined)
76
+ out.cooldownMs = push.cooldownMs;
77
+ if (push.debounceMs !== undefined)
78
+ out.debounceMs = push.debounceMs;
79
+ return out;
80
+ }
81
+ //# sourceMappingURL=push-credentials.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"push-credentials.js","sourceRoot":"","sources":["../src/push-credentials.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH,kFAAkF;AAClF,MAAM,CAAC,MAAM,QAAQ,GAAG,YAAY,CAAC;AAErC;;;;;;;GAOG;AACH,MAAM,UAAU,uBAAuB,CAAC,IAAqB;IAC3D,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,IAAI,CAAC,GAAG;QAAE,MAAM,CAAC,IAAI,CAAC,GAAG,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IACpD,IAAI,IAAI,CAAC,IAAI;QAAE,MAAM,CAAC,IAAI,CAAC,GAAG,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;IACvD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,WAAW,CAAC,GAAmB;IACtC,OAAO,aAAa,CAAC,KAAK,EAAE,GAAyC,EAAE;QACrE,WAAW;QACX,aAAa;QACb,YAAY;KACb,CAAC,CAAC;AACL,CAAC;AAED,SAAS,YAAY,CAAC,IAAqB;IACzC,OAAO,aAAa,CAAC,MAAM,EAAE,IAA0C,EAAE;QACvE,OAAO;QACP,QAAQ;QACR,UAAU;QACV,YAAY;KACb,CAAC,CAAC;AACL,CAAC;AAED,yEAAyE;AACzE,SAAS,aAAa,CAAC,QAAgB,EAAE,GAA4B,EAAE,QAAkB;IACvF,OAAO,QAAQ;SACZ,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,KAAK,QAAQ,IAAK,GAAG,CAAC,CAAC,CAAY,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC,CAAC;SACnF,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,QAAQ,IAAI,CAAC,uBAAuB,QAAQ,8BAA8B,CAAC,CAAC;AACpG,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CAAC,IAAqB;IACzD,MAAM,GAAG,GAA4B,EAAE,CAAC;IACxC,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC;QACb,GAAG,CAAC,GAAG,GAAG,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,WAAW,EAAE,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC;IACvG,CAAC;IACD,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;QACd,GAAG,CAAC,IAAI,GAAG;YACT,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,KAAK;YACtB,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM;YACxB,QAAQ,EAAE,IAAI,CAAC,IAAI,CAAC,QAAQ;YAC5B,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACnF,UAAU,EAAE,QAAQ;SACrB,CAAC;IACJ,CAAC;IACD,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS;QAAE,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC,UAAU,CAAC;IACpE,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS;QAAE,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC,UAAU,CAAC;IACpE,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -0,0 +1,137 @@
1
+ /**
2
+ * push-fanout.ts — the server-side push-wake **trigger policy + fan-out**.
3
+ *
4
+ * The delivery half (`push-notifier.ts`) knows *how* to send one strand-wake to
5
+ * one device; this module owns *who* to wake and *when*. On strand activity it
6
+ * enumerates the cadre's hibernating mobile members, prefers a direct
7
+ * control-network `pushWake`, falls back to FCM/APNs platform push only when the
8
+ * direct dial cannot reach the (suspended) peer, and dedups/cooldowns per
9
+ * `(peer, strand)` so a chatty strand cannot spam wakes.
10
+ *
11
+ * It owns no transport and no DB access: every node primitive (member
12
+ * enumeration, participation check, direct dial, token resolve, stale-token
13
+ * expiry) and the `PushNotifier` are injected, so the whole policy is unit-tested
14
+ * with fakes and never throws to its trigger (a dropped wake is recoverable — the
15
+ * hibernation check-in wake is the backstop).
16
+ *
17
+ * Cross-platform-clean by construction: it imports only the `PushNotifier`
18
+ * *type* (erased at emit) and the dependency-free `STRAND_WAKE_TYPE` value, so it
19
+ * carries no `node:http2`/`node:crypto` edge. The Node-only notifier is built by
20
+ * `CadreNode` (via a guarded dynamic import) and injected here.
21
+ */
22
+ import type { StrandInstance, WakeAck, DeviceTokenRecord } from './types.js';
23
+ import type { PushNotifier } from './push-notifier.js';
24
+ /** Default per-`(peer, strand)` minimum gap between wakes. */
25
+ export declare const DEFAULT_PUSH_COOLDOWN_MS: number;
26
+ /** Default per-strand burst-coalescing window. */
27
+ export declare const DEFAULT_PUSH_DEBOUNCE_MS = 10000;
28
+ /** A cadre member as returned by {@link CadreNode.listMembers}. */
29
+ export interface FanoutMember {
30
+ peerId: string;
31
+ multiaddr: string | null;
32
+ }
33
+ /**
34
+ * Everything {@link PushFanoutService} needs, injected so the policy is pure and
35
+ * testable. The node-bound implementations live on {@link CadreNode}; the
36
+ * `notifier` is a {@link PushNotifier} built from `config.push`.
37
+ */
38
+ export interface PushFanoutOptions {
39
+ /** Enumerate the cadre's `CadrePeer` membership (every party node). */
40
+ listMembers: () => Promise<FanoutMember[]>;
41
+ /**
42
+ * The local strand instance for `strandId`, or `undefined` when this node does
43
+ * not participate — the participation gate (never push for a strand we cannot
44
+ * vouch for).
45
+ */
46
+ getStrand: (strandId: string) => StrandInstance | undefined;
47
+ /** This node's own peerId, excluded from every fan-out (never wake self). */
48
+ selfPeerId: () => string | undefined;
49
+ /**
50
+ * Direct control-network push-wake. Resolves to a {@link WakeAck} when the dial
51
+ * REACHED the peer (whatever the peer then decided); REJECTS only on a
52
+ * dial/transport failure (no dialable address / timeout ⇒ the phone is
53
+ * suspended), which is what triggers the platform-push fallback.
54
+ */
55
+ pushWake: (peerId: string, strandId: string, reason?: string) => Promise<WakeAck>;
56
+ /** Resolve a peer's FCM/APNs token; `null` ⇒ not a registered mobile peer. */
57
+ resolveDeviceToken: (peerId: string) => Promise<DeviceTokenRecord | null>;
58
+ /**
59
+ * Expire a peer's stale `DeviceToken` after a platform reports it unregistered.
60
+ * Authority nodes delete the row; non-authority nodes only log (the in-memory
61
+ * dead-token set below is what actually stops re-pushing this process).
62
+ */
63
+ expireDeviceToken: (peerId: string) => Promise<void>;
64
+ /** Platform-push delivery seam (FCM/APNs router). */
65
+ notifier: PushNotifier;
66
+ /** Monotonic clock (epoch ms); injectable for deterministic cooldown/debounce tests. */
67
+ now?: () => number;
68
+ /** Per-`(peer, strand)` minimum gap. Default {@link DEFAULT_PUSH_COOLDOWN_MS}. */
69
+ cooldownMs?: number;
70
+ /** Per-strand burst coalescing. Default {@link DEFAULT_PUSH_DEBOUNCE_MS}. */
71
+ debounceMs?: number;
72
+ }
73
+ /**
74
+ * Server-side push-wake fan-out (see module header). One per participating
75
+ * {@link CadreNode}, constructed only when push credentials are configured.
76
+ */
77
+ export declare class PushFanoutService {
78
+ private readonly deps;
79
+ private readonly now;
80
+ private readonly cooldownMs;
81
+ private readonly debounceMs;
82
+ /** Per-strand leading-edge debounce: last fan-out start time (epoch ms). */
83
+ private readonly lastFanoutAt;
84
+ /** Per-strand in-flight pass, so two near-simultaneous triggers coalesce into one. */
85
+ private readonly inFlight;
86
+ /** Per-`(peer, strand)` last-wake time (epoch ms); the anti-spam cooldown. */
87
+ private readonly cooldownAt;
88
+ /**
89
+ * Peers whose `DeviceToken` a platform reported unregistered. Consulted before
90
+ * every resolve→send so we stop pushing to a dead token. In-memory and
91
+ * acceptably lossy: a restart re-learns staleness on the next failed send.
92
+ */
93
+ private readonly deadTokens;
94
+ constructor(options: PushFanoutOptions);
95
+ /**
96
+ * Fan a wake out to the cadre's hibernating mobile members for a strand that
97
+ * just saw activity. Best-effort: never throws to the trigger (the check-in
98
+ * wake is the backstop). No-ops when this node does not participate in the
99
+ * strand, when the strand was already fanned out within `debounceMs`, or — by
100
+ * joining — when a pass for the strand is already in flight.
101
+ *
102
+ * @param strandId - the strand whose activity should wake hibernating peers.
103
+ * @param reason - free-form cause hint carried in the wake (default `activity`).
104
+ */
105
+ notify(strandId: string, reason?: string): Promise<void>;
106
+ /** Release the notifier's transport resources (e.g. the APNs HTTP/2 session). */
107
+ close(): Promise<void>;
108
+ /**
109
+ * One fan-out pass: enumerate members, drop self and any peer still cooling
110
+ * down for this strand, and wake the survivors concurrently. Each
111
+ * {@link wakePeer} is self-contained (never rejects), so one peer's failure
112
+ * never aborts the others.
113
+ */
114
+ private runFanout;
115
+ /**
116
+ * Wake one candidate: try the direct control-network dial first, falling back
117
+ * to a platform push only when the dial cannot reach the (suspended) peer.
118
+ *
119
+ * A resolved {@link WakeAck} means the control path REACHED the peer — even an
120
+ * ack with `accepted:false` (the receiver declined, e.g. non-member/unknown
121
+ * strand) counts as reached, so we do NOT also send a platform push (that would
122
+ * double-wake). Only a dial/transport REJECTION means the phone is suspended
123
+ * and unreachable over the control network, which is the one case that falls
124
+ * through to FCM/APNs.
125
+ */
126
+ private wakePeer;
127
+ /**
128
+ * Deliver a platform push (FCM/APNs) to a suspended peer. Skips a peer with a
129
+ * known-dead token, no-ops for a non-mobile peer (`resolveDeviceToken` → null),
130
+ * and — on an `unregistered` send — marks the token dead and expires the row.
131
+ */
132
+ private platformPush;
133
+ /** Whether `peerId` was woken for `strandId` within `cooldownMs`. */
134
+ private isCoolingDown;
135
+ /** Arm the per-`(peer, strand)` cooldown at the current clock time. */
136
+ private markCooldown;
137
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * push-fanout.ts — the server-side push-wake **trigger policy + fan-out**.
3
+ *
4
+ * The delivery half (`push-notifier.ts`) knows *how* to send one strand-wake to
5
+ * one device; this module owns *who* to wake and *when*. On strand activity it
6
+ * enumerates the cadre's hibernating mobile members, prefers a direct
7
+ * control-network `pushWake`, falls back to FCM/APNs platform push only when the
8
+ * direct dial cannot reach the (suspended) peer, and dedups/cooldowns per
9
+ * `(peer, strand)` so a chatty strand cannot spam wakes.
10
+ *
11
+ * It owns no transport and no DB access: every node primitive (member
12
+ * enumeration, participation check, direct dial, token resolve, stale-token
13
+ * expiry) and the `PushNotifier` are injected, so the whole policy is unit-tested
14
+ * with fakes and never throws to its trigger (a dropped wake is recoverable — the
15
+ * hibernation check-in wake is the backstop).
16
+ *
17
+ * Cross-platform-clean by construction: it imports only the `PushNotifier`
18
+ * *type* (erased at emit) and the dependency-free `STRAND_WAKE_TYPE` value, so it
19
+ * carries no `node:http2`/`node:crypto` edge. The Node-only notifier is built by
20
+ * `CadreNode` (via a guarded dynamic import) and injected here.
21
+ */
22
+ import debug from 'debug';
23
+ import { STRAND_WAKE_TYPE } from './strand-wake-payload.js';
24
+ const log = debug('sereus:cadre:push-fanout');
25
+ /** Default per-`(peer, strand)` minimum gap between wakes. */
26
+ export const DEFAULT_PUSH_COOLDOWN_MS = 5 * 60000;
27
+ /** Default per-strand burst-coalescing window. */
28
+ export const DEFAULT_PUSH_DEBOUNCE_MS = 10000;
29
+ /**
30
+ * Server-side push-wake fan-out (see module header). One per participating
31
+ * {@link CadreNode}, constructed only when push credentials are configured.
32
+ */
33
+ export class PushFanoutService {
34
+ constructor(options) {
35
+ /** Per-strand leading-edge debounce: last fan-out start time (epoch ms). */
36
+ this.lastFanoutAt = new Map();
37
+ /** Per-strand in-flight pass, so two near-simultaneous triggers coalesce into one. */
38
+ this.inFlight = new Map();
39
+ /** Per-`(peer, strand)` last-wake time (epoch ms); the anti-spam cooldown. */
40
+ this.cooldownAt = new Map();
41
+ /**
42
+ * Peers whose `DeviceToken` a platform reported unregistered. Consulted before
43
+ * every resolve→send so we stop pushing to a dead token. In-memory and
44
+ * acceptably lossy: a restart re-learns staleness on the next failed send.
45
+ */
46
+ this.deadTokens = new Set();
47
+ this.deps = options;
48
+ this.now = options.now ?? (() => Date.now());
49
+ this.cooldownMs = options.cooldownMs ?? DEFAULT_PUSH_COOLDOWN_MS;
50
+ this.debounceMs = options.debounceMs ?? DEFAULT_PUSH_DEBOUNCE_MS;
51
+ }
52
+ /**
53
+ * Fan a wake out to the cadre's hibernating mobile members for a strand that
54
+ * just saw activity. Best-effort: never throws to the trigger (the check-in
55
+ * wake is the backstop). No-ops when this node does not participate in the
56
+ * strand, when the strand was already fanned out within `debounceMs`, or — by
57
+ * joining — when a pass for the strand is already in flight.
58
+ *
59
+ * @param strandId - the strand whose activity should wake hibernating peers.
60
+ * @param reason - free-form cause hint carried in the wake (default `activity`).
61
+ */
62
+ async notify(strandId, reason = 'activity') {
63
+ try {
64
+ // 1. Participation gate — never push for a strand we do not participate in.
65
+ if (!this.deps.getStrand(strandId)) {
66
+ log('notify: not participating in strand %s; no-op', strandId);
67
+ return;
68
+ }
69
+ // Concurrent trigger coalescing: a second near-simultaneous notify joins the
70
+ // in-flight pass rather than enumerating + sending again (mirrors
71
+ // CadreNode.serviceWakePromises). The stored promise is guarded (see below),
72
+ // so a joiner that adopts it via `return inflight` never observes a rejection
73
+ // — the "never throws to the trigger" guarantee holds on the join path too
74
+ // (a rejection here would otherwise escape this try/catch, since the returned
75
+ // promise settles in the caller's `void notify(...)` context).
76
+ const inflight = this.inFlight.get(strandId);
77
+ if (inflight) {
78
+ log('notify: joining in-flight fan-out for strand %s', strandId);
79
+ return inflight;
80
+ }
81
+ // 2. Per-strand debounce — coalesce bursts after a pass has completed.
82
+ const now = this.now();
83
+ const last = this.lastFanoutAt.get(strandId);
84
+ if (last !== undefined && now - last < this.debounceMs) {
85
+ log('notify: debounced fan-out for strand %s', strandId);
86
+ return;
87
+ }
88
+ this.lastFanoutAt.set(strandId, now);
89
+ // Guard the pass so neither the originator nor a concurrent joiner can see a
90
+ // rejection (e.g. listMembers throwing on a control-DB hiccup): a dropped
91
+ // fan-out is recoverable via the check-in wake backstop.
92
+ const op = this.runFanout(strandId, reason).catch((error) => {
93
+ log('notify: fan-out pass for strand %s failed (best-effort): %o', strandId, error);
94
+ });
95
+ this.inFlight.set(strandId, op);
96
+ try {
97
+ await op;
98
+ }
99
+ finally {
100
+ this.inFlight.delete(strandId);
101
+ }
102
+ }
103
+ catch (error) {
104
+ // The trigger must never see a throw — failures are logged best-effort.
105
+ log('notify: fan-out for strand %s failed (best-effort): %o', strandId, error);
106
+ }
107
+ }
108
+ /** Release the notifier's transport resources (e.g. the APNs HTTP/2 session). */
109
+ async close() {
110
+ await this.deps.notifier.close();
111
+ }
112
+ /**
113
+ * One fan-out pass: enumerate members, drop self and any peer still cooling
114
+ * down for this strand, and wake the survivors concurrently. Each
115
+ * {@link wakePeer} is self-contained (never rejects), so one peer's failure
116
+ * never aborts the others.
117
+ */
118
+ async runFanout(strandId, reason) {
119
+ const members = await this.deps.listMembers();
120
+ const self = this.deps.selfPeerId();
121
+ const targets = members.filter((m) => m.peerId !== self && !this.isCoolingDown(m.peerId, strandId));
122
+ log('fanout: strand %s — %d candidate(s) after self/cooldown filter', strandId, targets.length);
123
+ await Promise.all(targets.map((m) => this.wakePeer(m.peerId, strandId, reason)));
124
+ }
125
+ /**
126
+ * Wake one candidate: try the direct control-network dial first, falling back
127
+ * to a platform push only when the dial cannot reach the (suspended) peer.
128
+ *
129
+ * A resolved {@link WakeAck} means the control path REACHED the peer — even an
130
+ * ack with `accepted:false` (the receiver declined, e.g. non-member/unknown
131
+ * strand) counts as reached, so we do NOT also send a platform push (that would
132
+ * double-wake). Only a dial/transport REJECTION means the phone is suspended
133
+ * and unreachable over the control network, which is the one case that falls
134
+ * through to FCM/APNs.
135
+ */
136
+ async wakePeer(peerId, strandId, reason) {
137
+ // A wake of either kind is now being attempted → arm the cooldown up front.
138
+ this.markCooldown(peerId, strandId);
139
+ try {
140
+ const ack = await this.deps.pushWake(peerId, strandId, reason);
141
+ log('wakePeer: direct pushWake reached %s for strand %s (accepted=%s); no platform fallback', peerId, strandId, ack.accepted);
142
+ return;
143
+ }
144
+ catch (error) {
145
+ // Dial/transport failure ⇒ suspended phone ⇒ fall through to platform push.
146
+ log('wakePeer: direct pushWake could not reach %s; trying platform push: %o', peerId, error);
147
+ }
148
+ try {
149
+ await this.platformPush(peerId, strandId, reason);
150
+ }
151
+ catch (error) {
152
+ log('wakePeer: platform push for %s failed (best-effort): %o', peerId, error);
153
+ }
154
+ }
155
+ /**
156
+ * Deliver a platform push (FCM/APNs) to a suspended peer. Skips a peer with a
157
+ * known-dead token, no-ops for a non-mobile peer (`resolveDeviceToken` → null),
158
+ * and — on an `unregistered` send — marks the token dead and expires the row.
159
+ */
160
+ async platformPush(peerId, strandId, reason) {
161
+ if (this.deadTokens.has(peerId)) {
162
+ log('platformPush: %s has a known-stale token; skipping resolve→send', peerId);
163
+ return;
164
+ }
165
+ const record = await this.deps.resolveDeviceToken(peerId);
166
+ if (!record) {
167
+ log('platformPush: %s is not a registered mobile peer; no platform push', peerId);
168
+ return;
169
+ }
170
+ const result = await this.deps.notifier.send({
171
+ token: record.token,
172
+ platform: record.platform,
173
+ payload: { type: STRAND_WAKE_TYPE, strandId, reason },
174
+ });
175
+ if (!result.ok && result.unregistered) {
176
+ // Permanently-invalid token: stop pushing to it this process (the set), and
177
+ // expire the row (authority delete; non-authority logs re-registration).
178
+ this.deadTokens.add(peerId);
179
+ await this.deps.expireDeviceToken(peerId);
180
+ log('platformPush: expired stale token for %s (platform=%s)', peerId, record.platform);
181
+ }
182
+ }
183
+ /** Whether `peerId` was woken for `strandId` within `cooldownMs`. */
184
+ isCoolingDown(peerId, strandId) {
185
+ const last = this.cooldownAt.get(cooldownKey(peerId, strandId));
186
+ return last !== undefined && this.now() - last < this.cooldownMs;
187
+ }
188
+ /** Arm the per-`(peer, strand)` cooldown at the current clock time. */
189
+ markCooldown(peerId, strandId) {
190
+ this.cooldownAt.set(cooldownKey(peerId, strandId), this.now());
191
+ }
192
+ }
193
+ /** Composite key for the per-`(peer, strand)` cooldown map. */
194
+ function cooldownKey(peerId, strandId) {
195
+ return `${peerId}${strandId}`;
196
+ }
197
+ //# sourceMappingURL=push-fanout.js.map