@serfab/cadre-core 0.7.1 → 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.
- package/README.md +9 -8
- package/dist/authority-key.d.ts +41 -0
- package/dist/authority-key.js +52 -0
- package/dist/authority-key.js.map +1 -0
- package/dist/cadre-node.d.ts +770 -11
- package/dist/cadre-node.js +1900 -88
- package/dist/cadre-node.js.map +1 -1
- package/dist/canonical-datetime.d.ts +33 -0
- package/dist/canonical-datetime.js +38 -0
- package/dist/canonical-datetime.js.map +1 -0
- package/dist/canonical-json.d.ts +12 -0
- package/dist/canonical-json.js +33 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/control-cohort.d.ts +63 -0
- package/dist/control-cohort.js +80 -0
- package/dist/control-cohort.js.map +1 -0
- package/dist/control-database.d.ts +259 -5
- package/dist/control-database.js +541 -128
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +89 -0
- package/dist/control-formation-recorder.js +139 -0
- package/dist/control-formation-recorder.js.map +1 -0
- package/dist/control-schema.d.ts +12 -0
- package/dist/control-schema.js +199 -0
- package/dist/control-schema.js.map +1 -0
- package/dist/control-stream.d.ts +54 -0
- package/dist/control-stream.js +104 -0
- package/dist/control-stream.js.map +1 -0
- package/dist/device-token.d.ts +50 -0
- package/dist/device-token.js +66 -0
- package/dist/device-token.js.map +1 -0
- package/dist/diagnostics/connection-path.d.ts +118 -0
- package/dist/diagnostics/connection-path.js +204 -0
- package/dist/diagnostics/connection-path.js.map +1 -0
- package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
- package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
- package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
- package/dist/hibernation-manager.d.ts +79 -0
- package/dist/hibernation-manager.js +173 -23
- package/dist/hibernation-manager.js.map +1 -1
- package/dist/index.d.ts +25 -4
- package/dist/index.js +59 -4
- package/dist/index.js.map +1 -1
- package/dist/key-store-file.d.ts +52 -0
- package/dist/key-store-file.js +194 -0
- package/dist/key-store-file.js.map +1 -0
- package/dist/key-store.d.ts +75 -0
- package/dist/key-store.js +66 -0
- package/dist/key-store.js.map +1 -0
- package/dist/peer-authorization.d.ts +26 -0
- package/dist/peer-authorization.js +40 -0
- package/dist/peer-authorization.js.map +1 -0
- package/dist/peer-record.d.ts +89 -0
- package/dist/peer-record.js +117 -0
- package/dist/peer-record.js.map +1 -0
- package/dist/push-credentials.d.ts +34 -0
- package/dist/push-credentials.js +81 -0
- package/dist/push-credentials.js.map +1 -0
- package/dist/push-fanout.d.ts +137 -0
- package/dist/push-fanout.js +197 -0
- package/dist/push-fanout.js.map +1 -0
- package/dist/push-notifier-apns.d.ts +53 -0
- package/dist/push-notifier-apns.js +195 -0
- package/dist/push-notifier-apns.js.map +1 -0
- package/dist/push-notifier-fcm.d.ts +38 -0
- package/dist/push-notifier-fcm.js +183 -0
- package/dist/push-notifier-fcm.js.map +1 -0
- package/dist/push-notifier-shared.d.ts +18 -0
- package/dist/push-notifier-shared.js +25 -0
- package/dist/push-notifier-shared.js.map +1 -0
- package/dist/push-notifier.d.ts +71 -0
- package/dist/push-notifier.js +50 -0
- package/dist/push-notifier.js.map +1 -0
- package/dist/schema-verification.d.ts +11 -2
- package/dist/schema-verification.js +15 -4
- package/dist/schema-verification.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +197 -9
- package/dist/seed-bootstrap.js +396 -137
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +63 -0
- package/dist/seed-trust-policy.js +56 -0
- package/dist/seed-trust-policy.js.map +1 -0
- package/dist/strand-addr-protocol.d.ts +132 -0
- package/dist/strand-addr-protocol.js +313 -0
- package/dist/strand-addr-protocol.js.map +1 -0
- package/dist/strand-cohort.d.ts +54 -0
- package/dist/strand-cohort.js +38 -0
- package/dist/strand-cohort.js.map +1 -0
- package/dist/strand-database.d.ts +54 -6
- package/dist/strand-database.js +88 -100
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +79 -19
- package/dist/strand-formation-manager.js +196 -104
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +162 -0
- package/dist/strand-formation-protocol.js +279 -0
- package/dist/strand-formation-protocol.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +73 -4
- package/dist/strand-instance-manager.js +167 -41
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +29 -0
- package/dist/strand-member-key.js +38 -0
- package/dist/strand-member-key.js.map +1 -0
- package/dist/strand-member-registry.d.ts +98 -0
- package/dist/strand-member-registry.js +129 -0
- package/dist/strand-member-registry.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +361 -0
- package/dist/strand-membership-writer.js +449 -0
- package/dist/strand-membership-writer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +77 -2
- package/dist/strand-solicitation.js +25 -4
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-payload.d.ts +30 -0
- package/dist/strand-wake-payload.js +20 -0
- package/dist/strand-wake-payload.js.map +1 -0
- package/dist/strand-wake-protocol.d.ts +114 -0
- package/dist/strand-wake-protocol.js +257 -0
- package/dist/strand-wake-protocol.js.map +1 -0
- package/dist/strand-watcher.d.ts +6 -2
- package/dist/strand-watcher.js +57 -17
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +406 -23
- package/dist/types.js +22 -5
- package/dist/types.js.map +1 -1
- package/package.json +14 -8
- package/src/authority-key.ts +68 -0
- package/src/cadre-node.ts +2937 -948
- package/src/canonical-datetime.ts +39 -0
- package/src/canonical-json.ts +30 -0
- package/src/control-cohort.ts +103 -0
- package/src/control-database.ts +931 -383
- package/src/control-formation-recorder.ts +151 -0
- package/src/control-schema.ts +198 -0
- package/src/control-stream.ts +128 -0
- package/src/device-token.ts +89 -0
- package/src/diagnostics/connection-path.ts +277 -0
- package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
- package/src/hibernation-manager.ts +197 -28
- package/src/index.ts +273 -79
- package/src/key-store-file.ts +203 -0
- package/src/key-store.ts +99 -0
- package/src/peer-authorization.ts +53 -0
- package/src/peer-record.ts +146 -0
- package/src/push-credentials.ts +84 -0
- package/src/push-fanout.ts +272 -0
- package/src/push-notifier-apns.ts +243 -0
- package/src/push-notifier-fcm.ts +221 -0
- package/src/push-notifier-shared.ts +28 -0
- package/src/push-notifier.ts +93 -0
- package/src/schema-verification.ts +18 -4
- package/src/seed-bootstrap.ts +464 -188
- package/src/seed-trust-policy.ts +107 -0
- package/src/strand-addr-protocol.ts +410 -0
- package/src/strand-cohort.ts +77 -0
- package/src/strand-database.ts +113 -141
- package/src/strand-formation-manager.ts +233 -142
- package/src/strand-formation-protocol.ts +391 -0
- package/src/strand-instance-manager.ts +207 -56
- package/src/strand-member-key.ts +39 -0
- package/src/strand-member-registry.ts +177 -0
- package/src/strand-membership-writer.ts +640 -0
- package/src/strand-solicitation.ts +93 -4
- package/src/strand-wake-payload.ts +32 -0
- package/src/strand-wake-protocol.ts +331 -0
- package/src/strand-watcher.ts +61 -17
- package/src/types.ts +1012 -582
|
@@ -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
|