@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.
- 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 +777 -11
- package/dist/cadre-node.js +1912 -82
- 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 +210 -7
- package/dist/seed-bootstrap.js +429 -131
- 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 -89
- 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 +413 -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 -929
- 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 +504 -171
- 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 -126
- 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 -575
|
@@ -0,0 +1,272 @@
|
|
|
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
|
+
|
|
23
|
+
import debug from 'debug';
|
|
24
|
+
import type { StrandInstance, WakeAck, DeviceTokenRecord } from './types.js';
|
|
25
|
+
import type { PushNotifier } from './push-notifier.js';
|
|
26
|
+
import { STRAND_WAKE_TYPE } from './strand-wake-payload.js';
|
|
27
|
+
|
|
28
|
+
const log = debug('sereus:cadre:push-fanout');
|
|
29
|
+
|
|
30
|
+
/** Default per-`(peer, strand)` minimum gap between wakes. */
|
|
31
|
+
export const DEFAULT_PUSH_COOLDOWN_MS = 5 * 60_000;
|
|
32
|
+
/** Default per-strand burst-coalescing window. */
|
|
33
|
+
export const DEFAULT_PUSH_DEBOUNCE_MS = 10_000;
|
|
34
|
+
|
|
35
|
+
/** A cadre member as returned by {@link CadreNode.listMembers}. */
|
|
36
|
+
export interface FanoutMember {
|
|
37
|
+
peerId: string;
|
|
38
|
+
multiaddr: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Everything {@link PushFanoutService} needs, injected so the policy is pure and
|
|
43
|
+
* testable. The node-bound implementations live on {@link CadreNode}; the
|
|
44
|
+
* `notifier` is a {@link PushNotifier} built from `config.push`.
|
|
45
|
+
*/
|
|
46
|
+
export interface PushFanoutOptions {
|
|
47
|
+
/** Enumerate the cadre's `CadrePeer` membership (every party node). */
|
|
48
|
+
listMembers: () => Promise<FanoutMember[]>;
|
|
49
|
+
/**
|
|
50
|
+
* The local strand instance for `strandId`, or `undefined` when this node does
|
|
51
|
+
* not participate — the participation gate (never push for a strand we cannot
|
|
52
|
+
* vouch for).
|
|
53
|
+
*/
|
|
54
|
+
getStrand: (strandId: string) => StrandInstance | undefined;
|
|
55
|
+
/** This node's own peerId, excluded from every fan-out (never wake self). */
|
|
56
|
+
selfPeerId: () => string | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Direct control-network push-wake. Resolves to a {@link WakeAck} when the dial
|
|
59
|
+
* REACHED the peer (whatever the peer then decided); REJECTS only on a
|
|
60
|
+
* dial/transport failure (no dialable address / timeout ⇒ the phone is
|
|
61
|
+
* suspended), which is what triggers the platform-push fallback.
|
|
62
|
+
*/
|
|
63
|
+
pushWake: (peerId: string, strandId: string, reason?: string) => Promise<WakeAck>;
|
|
64
|
+
/** Resolve a peer's FCM/APNs token; `null` ⇒ not a registered mobile peer. */
|
|
65
|
+
resolveDeviceToken: (peerId: string) => Promise<DeviceTokenRecord | null>;
|
|
66
|
+
/**
|
|
67
|
+
* Expire a peer's stale `DeviceToken` after a platform reports it unregistered.
|
|
68
|
+
* Authority nodes delete the row; non-authority nodes only log (the in-memory
|
|
69
|
+
* dead-token set below is what actually stops re-pushing this process).
|
|
70
|
+
*/
|
|
71
|
+
expireDeviceToken: (peerId: string) => Promise<void>;
|
|
72
|
+
/** Platform-push delivery seam (FCM/APNs router). */
|
|
73
|
+
notifier: PushNotifier;
|
|
74
|
+
/** Monotonic clock (epoch ms); injectable for deterministic cooldown/debounce tests. */
|
|
75
|
+
now?: () => number;
|
|
76
|
+
/** Per-`(peer, strand)` minimum gap. Default {@link DEFAULT_PUSH_COOLDOWN_MS}. */
|
|
77
|
+
cooldownMs?: number;
|
|
78
|
+
/** Per-strand burst coalescing. Default {@link DEFAULT_PUSH_DEBOUNCE_MS}. */
|
|
79
|
+
debounceMs?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Server-side push-wake fan-out (see module header). One per participating
|
|
84
|
+
* {@link CadreNode}, constructed only when push credentials are configured.
|
|
85
|
+
*/
|
|
86
|
+
export class PushFanoutService {
|
|
87
|
+
private readonly deps: PushFanoutOptions;
|
|
88
|
+
private readonly now: () => number;
|
|
89
|
+
private readonly cooldownMs: number;
|
|
90
|
+
private readonly debounceMs: number;
|
|
91
|
+
|
|
92
|
+
/** Per-strand leading-edge debounce: last fan-out start time (epoch ms). */
|
|
93
|
+
private readonly lastFanoutAt = new Map<string, number>();
|
|
94
|
+
/** Per-strand in-flight pass, so two near-simultaneous triggers coalesce into one. */
|
|
95
|
+
private readonly inFlight = new Map<string, Promise<void>>();
|
|
96
|
+
/** Per-`(peer, strand)` last-wake time (epoch ms); the anti-spam cooldown. */
|
|
97
|
+
private readonly cooldownAt = new Map<string, number>();
|
|
98
|
+
/**
|
|
99
|
+
* Peers whose `DeviceToken` a platform reported unregistered. Consulted before
|
|
100
|
+
* every resolve→send so we stop pushing to a dead token. In-memory and
|
|
101
|
+
* acceptably lossy: a restart re-learns staleness on the next failed send.
|
|
102
|
+
*/
|
|
103
|
+
private readonly deadTokens = new Set<string>();
|
|
104
|
+
|
|
105
|
+
constructor(options: PushFanoutOptions) {
|
|
106
|
+
this.deps = options;
|
|
107
|
+
this.now = options.now ?? (() => Date.now());
|
|
108
|
+
this.cooldownMs = options.cooldownMs ?? DEFAULT_PUSH_COOLDOWN_MS;
|
|
109
|
+
this.debounceMs = options.debounceMs ?? DEFAULT_PUSH_DEBOUNCE_MS;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Fan a wake out to the cadre's hibernating mobile members for a strand that
|
|
114
|
+
* just saw activity. Best-effort: never throws to the trigger (the check-in
|
|
115
|
+
* wake is the backstop). No-ops when this node does not participate in the
|
|
116
|
+
* strand, when the strand was already fanned out within `debounceMs`, or — by
|
|
117
|
+
* joining — when a pass for the strand is already in flight.
|
|
118
|
+
*
|
|
119
|
+
* @param strandId - the strand whose activity should wake hibernating peers.
|
|
120
|
+
* @param reason - free-form cause hint carried in the wake (default `activity`).
|
|
121
|
+
*/
|
|
122
|
+
async notify(strandId: string, reason = 'activity'): Promise<void> {
|
|
123
|
+
try {
|
|
124
|
+
// 1. Participation gate — never push for a strand we do not participate in.
|
|
125
|
+
if (!this.deps.getStrand(strandId)) {
|
|
126
|
+
log('notify: not participating in strand %s; no-op', strandId);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Concurrent trigger coalescing: a second near-simultaneous notify joins the
|
|
131
|
+
// in-flight pass rather than enumerating + sending again (mirrors
|
|
132
|
+
// CadreNode.serviceWakePromises). The stored promise is guarded (see below),
|
|
133
|
+
// so a joiner that adopts it via `return inflight` never observes a rejection
|
|
134
|
+
// — the "never throws to the trigger" guarantee holds on the join path too
|
|
135
|
+
// (a rejection here would otherwise escape this try/catch, since the returned
|
|
136
|
+
// promise settles in the caller's `void notify(...)` context).
|
|
137
|
+
const inflight = this.inFlight.get(strandId);
|
|
138
|
+
if (inflight) {
|
|
139
|
+
log('notify: joining in-flight fan-out for strand %s', strandId);
|
|
140
|
+
return inflight;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2. Per-strand debounce — coalesce bursts after a pass has completed.
|
|
144
|
+
const now = this.now();
|
|
145
|
+
const last = this.lastFanoutAt.get(strandId);
|
|
146
|
+
if (last !== undefined && now - last < this.debounceMs) {
|
|
147
|
+
log('notify: debounced fan-out for strand %s', strandId);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.lastFanoutAt.set(strandId, now);
|
|
152
|
+
// Guard the pass so neither the originator nor a concurrent joiner can see a
|
|
153
|
+
// rejection (e.g. listMembers throwing on a control-DB hiccup): a dropped
|
|
154
|
+
// fan-out is recoverable via the check-in wake backstop.
|
|
155
|
+
const op = this.runFanout(strandId, reason).catch((error) => {
|
|
156
|
+
log('notify: fan-out pass for strand %s failed (best-effort): %o', strandId, error);
|
|
157
|
+
});
|
|
158
|
+
this.inFlight.set(strandId, op);
|
|
159
|
+
try {
|
|
160
|
+
await op;
|
|
161
|
+
} finally {
|
|
162
|
+
this.inFlight.delete(strandId);
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
// The trigger must never see a throw — failures are logged best-effort.
|
|
166
|
+
log('notify: fan-out for strand %s failed (best-effort): %o', strandId, error);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Release the notifier's transport resources (e.g. the APNs HTTP/2 session). */
|
|
171
|
+
async close(): Promise<void> {
|
|
172
|
+
await this.deps.notifier.close();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One fan-out pass: enumerate members, drop self and any peer still cooling
|
|
177
|
+
* down for this strand, and wake the survivors concurrently. Each
|
|
178
|
+
* {@link wakePeer} is self-contained (never rejects), so one peer's failure
|
|
179
|
+
* never aborts the others.
|
|
180
|
+
*/
|
|
181
|
+
private async runFanout(strandId: string, reason: string): Promise<void> {
|
|
182
|
+
const members = await this.deps.listMembers();
|
|
183
|
+
const self = this.deps.selfPeerId();
|
|
184
|
+
const targets = members.filter(
|
|
185
|
+
(m) => m.peerId !== self && !this.isCoolingDown(m.peerId, strandId)
|
|
186
|
+
);
|
|
187
|
+
log('fanout: strand %s — %d candidate(s) after self/cooldown filter', strandId, targets.length);
|
|
188
|
+
await Promise.all(targets.map((m) => this.wakePeer(m.peerId, strandId, reason)));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Wake one candidate: try the direct control-network dial first, falling back
|
|
193
|
+
* to a platform push only when the dial cannot reach the (suspended) peer.
|
|
194
|
+
*
|
|
195
|
+
* A resolved {@link WakeAck} means the control path REACHED the peer — even an
|
|
196
|
+
* ack with `accepted:false` (the receiver declined, e.g. non-member/unknown
|
|
197
|
+
* strand) counts as reached, so we do NOT also send a platform push (that would
|
|
198
|
+
* double-wake). Only a dial/transport REJECTION means the phone is suspended
|
|
199
|
+
* and unreachable over the control network, which is the one case that falls
|
|
200
|
+
* through to FCM/APNs.
|
|
201
|
+
*/
|
|
202
|
+
private async wakePeer(peerId: string, strandId: string, reason: string): Promise<void> {
|
|
203
|
+
// A wake of either kind is now being attempted → arm the cooldown up front.
|
|
204
|
+
this.markCooldown(peerId, strandId);
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const ack = await this.deps.pushWake(peerId, strandId, reason);
|
|
208
|
+
log(
|
|
209
|
+
'wakePeer: direct pushWake reached %s for strand %s (accepted=%s); no platform fallback',
|
|
210
|
+
peerId, strandId, ack.accepted
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
} catch (error) {
|
|
214
|
+
// Dial/transport failure ⇒ suspended phone ⇒ fall through to platform push.
|
|
215
|
+
log('wakePeer: direct pushWake could not reach %s; trying platform push: %o', peerId, error);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await this.platformPush(peerId, strandId, reason);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
log('wakePeer: platform push for %s failed (best-effort): %o', peerId, error);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Deliver a platform push (FCM/APNs) to a suspended peer. Skips a peer with a
|
|
227
|
+
* known-dead token, no-ops for a non-mobile peer (`resolveDeviceToken` → null),
|
|
228
|
+
* and — on an `unregistered` send — marks the token dead and expires the row.
|
|
229
|
+
*/
|
|
230
|
+
private async platformPush(peerId: string, strandId: string, reason: string): Promise<void> {
|
|
231
|
+
if (this.deadTokens.has(peerId)) {
|
|
232
|
+
log('platformPush: %s has a known-stale token; skipping resolve→send', peerId);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const record = await this.deps.resolveDeviceToken(peerId);
|
|
237
|
+
if (!record) {
|
|
238
|
+
log('platformPush: %s is not a registered mobile peer; no platform push', peerId);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const result = await this.deps.notifier.send({
|
|
243
|
+
token: record.token,
|
|
244
|
+
platform: record.platform,
|
|
245
|
+
payload: { type: STRAND_WAKE_TYPE, strandId, reason },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (!result.ok && result.unregistered) {
|
|
249
|
+
// Permanently-invalid token: stop pushing to it this process (the set), and
|
|
250
|
+
// expire the row (authority delete; non-authority logs re-registration).
|
|
251
|
+
this.deadTokens.add(peerId);
|
|
252
|
+
await this.deps.expireDeviceToken(peerId);
|
|
253
|
+
log('platformPush: expired stale token for %s (platform=%s)', peerId, record.platform);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Whether `peerId` was woken for `strandId` within `cooldownMs`. */
|
|
258
|
+
private isCoolingDown(peerId: string, strandId: string): boolean {
|
|
259
|
+
const last = this.cooldownAt.get(cooldownKey(peerId, strandId));
|
|
260
|
+
return last !== undefined && this.now() - last < this.cooldownMs;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Arm the per-`(peer, strand)` cooldown at the current clock time. */
|
|
264
|
+
private markCooldown(peerId: string, strandId: string): void {
|
|
265
|
+
this.cooldownAt.set(cooldownKey(peerId, strandId), this.now());
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Composite key for the per-`(peer, strand)` cooldown map. */
|
|
270
|
+
function cooldownKey(peerId: string, strandId: string): string {
|
|
271
|
+
return `${peerId}${strandId}`;
|
|
272
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* push-notifier-apns.ts — APNs HTTP/2 strand-wake delivery.
|
|
3
|
+
*
|
|
4
|
+
* Server-only: signs a provider JWT (ES256, JOSE raw r‖s via `node:crypto`) and
|
|
5
|
+
* sends a background data push over an HTTP/2 session to Apple's gateway.
|
|
6
|
+
*
|
|
7
|
+
* The HTTP/2 call is behind an injected `Http2Requester` seam (bundled with a
|
|
8
|
+
* `close` into an {@link ApnsTransport}) so unit tests assert the request shape
|
|
9
|
+
* and map every documented response code with no real network and no credentials.
|
|
10
|
+
* The default transport owns a single lazily-(re)established `node:http2` session.
|
|
11
|
+
*
|
|
12
|
+
* Two single-shot retries guard transient failure without a retry storm: a
|
|
13
|
+
* thrown transport error (session death / GOAWAY mid-send) re-establishes the
|
|
14
|
+
* session and retries the one request once; a 403 `ExpiredProviderToken`
|
|
15
|
+
* re-mints the provider JWT and retries once. A second failure is returned as-is.
|
|
16
|
+
*
|
|
17
|
+
* Secret hygiene: the `.p8` `privateKey`, the minted JWT, and full device tokens
|
|
18
|
+
* are never logged — failure log lines carry only a status/reason and a redacted
|
|
19
|
+
* token prefix.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Namespace import (not `import { sign }`): a *named* import of the
|
|
23
|
+
// browser-externalized `node:crypto` hard-fails the web build, whereas a
|
|
24
|
+
// namespace import only warns — same reasoning as the `node:http2` import below
|
|
25
|
+
// and the matching note in push-notifier-fcm.ts. Server-only; never run in browser.
|
|
26
|
+
import * as nodeCrypto from 'node:crypto';
|
|
27
|
+
import * as http2 from 'node:http2';
|
|
28
|
+
import debug from 'debug';
|
|
29
|
+
import type { ApnsCredentials } from './types.js';
|
|
30
|
+
import type { PushMessage, PushNotifier, PushSendResult } from './push-notifier.js';
|
|
31
|
+
import { MAX_REASON_LEN, b64urlJson, errText, redact } from './push-notifier-shared.js';
|
|
32
|
+
|
|
33
|
+
const APNS_PROD_HOST = 'https://api.push.apple.com';
|
|
34
|
+
const APNS_SANDBOX_HOST = 'https://api.sandbox.push.apple.com';
|
|
35
|
+
/** Apple requires the provider token be 20–60 min old; refresh well inside that. */
|
|
36
|
+
const PROVIDER_TOKEN_TTL_MS = 45 * 60_000;
|
|
37
|
+
|
|
38
|
+
const debugApns = debug('sereus:cadre:push:apns');
|
|
39
|
+
|
|
40
|
+
/** Raw HTTP/2 response: APNs `:status` and the (possibly empty) JSON body. */
|
|
41
|
+
export interface ApnsResponse {
|
|
42
|
+
status: number;
|
|
43
|
+
body: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A single APNs HTTP/2 request — `:path`, headers, and the JSON body. */
|
|
47
|
+
export interface ApnsRequest {
|
|
48
|
+
/** `:path`, e.g. `/3/device/{token}`. */
|
|
49
|
+
path: string;
|
|
50
|
+
headers: Record<string, string>;
|
|
51
|
+
body: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Low-level HTTP/2 request seam. Resolves with the `(status, body)` pair; rejects
|
|
56
|
+
* (throws) on a session/transport failure so the caller can re-establish + retry.
|
|
57
|
+
*/
|
|
58
|
+
export type Http2Requester = (req: ApnsRequest) => Promise<ApnsResponse>;
|
|
59
|
+
|
|
60
|
+
/** The HTTP/2 request seam plus its session-teardown. */
|
|
61
|
+
export interface ApnsTransport {
|
|
62
|
+
request: Http2Requester;
|
|
63
|
+
close(): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ApnsPushDeps {
|
|
67
|
+
/** HTTP/2 transport. Defaults to a real `node:http2` session manager. */
|
|
68
|
+
transport?: ApnsTransport;
|
|
69
|
+
/** Monotonic clock (ms). Defaults to `Date.now`. Injected for token-cache tests. */
|
|
70
|
+
now?: () => number;
|
|
71
|
+
/** Failure log seam (default: `debug('sereus:cadre:push:apns')`). */
|
|
72
|
+
log?: (line: string) => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A cached provider JWT and the wall-clock ms after which to refresh it. */
|
|
76
|
+
interface CachedProviderToken {
|
|
77
|
+
value: string;
|
|
78
|
+
expiresAt: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createApnsPushNotifier(creds: ApnsCredentials, deps: ApnsPushDeps = {}): PushNotifier {
|
|
82
|
+
const host = creds.production ? APNS_PROD_HOST : APNS_SANDBOX_HOST;
|
|
83
|
+
const transport = deps.transport ?? createHttp2Transport(host);
|
|
84
|
+
const now = deps.now ?? (() => Date.now());
|
|
85
|
+
const log = deps.log ?? ((line: string) => debugApns(line));
|
|
86
|
+
|
|
87
|
+
let cached: CachedProviderToken | null = null;
|
|
88
|
+
|
|
89
|
+
async function send(msg: PushMessage): Promise<PushSendResult> {
|
|
90
|
+
let jwt: string;
|
|
91
|
+
try {
|
|
92
|
+
jwt = providerToken(false);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
log(`provider-token mint failed: ${errText(err)}`);
|
|
95
|
+
return { ok: false, unregistered: false, error: `apns jwt mint failed: ${errText(err)}` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let res = await tryRequest(msg, jwt);
|
|
99
|
+
if (!res) return { ok: false, unregistered: false, error: `apns transport failed (token=${redact(msg.token)})` };
|
|
100
|
+
|
|
101
|
+
// Provider token expired mid-flight — re-mint once and retry the single send.
|
|
102
|
+
if (res.status === 403 && /ExpiredProviderToken/i.test(res.body)) {
|
|
103
|
+
try {
|
|
104
|
+
jwt = providerToken(true);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
log(`provider-token re-mint failed: ${errText(err)}`);
|
|
107
|
+
return { ok: false, unregistered: false, error: `apns jwt re-mint failed: ${errText(err)}` };
|
|
108
|
+
}
|
|
109
|
+
res = await tryRequest(msg, jwt);
|
|
110
|
+
if (!res) return { ok: false, unregistered: false, error: `apns transport failed (token=${redact(msg.token)})` };
|
|
111
|
+
}
|
|
112
|
+
return mapResult(res, msg.token);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Send one request with a single re-establish-on-throw retry. A thrown error is
|
|
117
|
+
* a session death / GOAWAY; the default transport recreates its session lazily
|
|
118
|
+
* on the next call, so a second attempt rides a fresh session. Returns `null`
|
|
119
|
+
* when both attempts throw.
|
|
120
|
+
*/
|
|
121
|
+
async function tryRequest(msg: PushMessage, jwt: string): Promise<ApnsResponse | null> {
|
|
122
|
+
const req = buildRequest(msg, jwt);
|
|
123
|
+
try {
|
|
124
|
+
return await transport.request(req);
|
|
125
|
+
} catch (err1) {
|
|
126
|
+
log(`http2 request failed, re-establishing once: ${errText(err1)}`);
|
|
127
|
+
try {
|
|
128
|
+
return await transport.request(req);
|
|
129
|
+
} catch (err2) {
|
|
130
|
+
log(`http2 request failed after re-establish: ${errText(err2)}`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function mapResult(res: ApnsResponse, deviceToken: string): PushSendResult {
|
|
137
|
+
if (res.status === 200) return { ok: true };
|
|
138
|
+
const reason = apnsReason(res.body);
|
|
139
|
+
const summary = `apns ${res.status}${reason ? ` ${reason}` : ''}`;
|
|
140
|
+
if (res.status === 410 || reason === 'Unregistered' || (res.status === 400 && reason === 'BadDeviceToken')) {
|
|
141
|
+
log(`unregistered token=${redact(deviceToken)} (${summary})`);
|
|
142
|
+
return { ok: false, unregistered: true, error: summary };
|
|
143
|
+
}
|
|
144
|
+
log(`send failed token=${redact(deviceToken)} (${summary})`);
|
|
145
|
+
return { ok: false, unregistered: false, error: summary };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildRequest(msg: PushMessage, jwt: string): ApnsRequest {
|
|
149
|
+
const body = JSON.stringify({
|
|
150
|
+
aps: { 'content-available': 1 },
|
|
151
|
+
type: msg.payload.type,
|
|
152
|
+
strandId: msg.payload.strandId,
|
|
153
|
+
reason: msg.payload.reason.slice(0, MAX_REASON_LEN),
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
path: `/3/device/${msg.token}`,
|
|
157
|
+
headers: {
|
|
158
|
+
authorization: `bearer ${jwt}`,
|
|
159
|
+
'apns-topic': creds.bundleId,
|
|
160
|
+
'apns-push-type': 'background',
|
|
161
|
+
'apns-priority': '5',
|
|
162
|
+
'apns-expiration': '0',
|
|
163
|
+
},
|
|
164
|
+
body,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function providerToken(forceRefresh: boolean): string {
|
|
169
|
+
if (!forceRefresh && cached && cached.expiresAt > now()) return cached.value;
|
|
170
|
+
const iat = Math.floor(now() / 1000);
|
|
171
|
+
const header = { alg: 'ES256', kid: creds.keyId };
|
|
172
|
+
const claims = { iss: creds.teamId, iat };
|
|
173
|
+
const signingInput = `${b64urlJson(header)}.${b64urlJson(claims)}`;
|
|
174
|
+
const sig = nodeCrypto.sign('SHA256', Buffer.from(signingInput), {
|
|
175
|
+
key: creds.privateKey,
|
|
176
|
+
dsaEncoding: 'ieee-p1363',
|
|
177
|
+
}).toString('base64url');
|
|
178
|
+
const jwt = `${signingInput}.${sig}`;
|
|
179
|
+
cached = { value: jwt, expiresAt: now() + PROVIDER_TOKEN_TTL_MS };
|
|
180
|
+
return jwt;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function close(): Promise<void> {
|
|
184
|
+
cached = null;
|
|
185
|
+
await transport.close();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { send, close };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Default {@link ApnsTransport}: a single lazily-(re)established `node:http2`
|
|
193
|
+
* session to Apple's gateway. A session that errors / receives GOAWAY / closes is
|
|
194
|
+
* dropped so the next request re-establishes it. Not exercised by unit tests (no
|
|
195
|
+
* network) — tests inject a fake transport; the real path is validated out-of-band.
|
|
196
|
+
*/
|
|
197
|
+
function createHttp2Transport(host: string): ApnsTransport {
|
|
198
|
+
let session: http2.ClientHttp2Session | null = null;
|
|
199
|
+
|
|
200
|
+
function ensureSession(): http2.ClientHttp2Session {
|
|
201
|
+
if (session && !session.closed && !session.destroyed) return session;
|
|
202
|
+
const s = http2.connect(host);
|
|
203
|
+
// Drop a dead session so ensureSession recreates it on the next request.
|
|
204
|
+
s.on('error', () => { if (session === s) session = null; });
|
|
205
|
+
s.on('goaway', () => { s.destroy(); });
|
|
206
|
+
s.on('close', () => { if (session === s) session = null; });
|
|
207
|
+
session = s;
|
|
208
|
+
return s;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function request(req: ApnsRequest): Promise<ApnsResponse> {
|
|
212
|
+
const s = ensureSession();
|
|
213
|
+
return await new Promise<ApnsResponse>((resolve, reject) => {
|
|
214
|
+
const stream = s.request({ ':method': 'POST', ':path': req.path, ...req.headers });
|
|
215
|
+
const chunks: Buffer[] = [];
|
|
216
|
+
let status = 0;
|
|
217
|
+
stream.on('response', (headers) => { status = Number(headers[':status'] ?? 0); });
|
|
218
|
+
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
219
|
+
stream.on('end', () => resolve({ status, body: Buffer.concat(chunks).toString('utf8') }));
|
|
220
|
+
stream.on('error', (err) => reject(err));
|
|
221
|
+
stream.end(req.body);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function close(): Promise<void> {
|
|
226
|
+
const s = session;
|
|
227
|
+
session = null;
|
|
228
|
+
if (s) await new Promise<void>((resolve) => s.close(() => resolve()));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { request, close };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Extract the APNs error `reason` field from a response body, if present. */
|
|
235
|
+
function apnsReason(body: string): string | null {
|
|
236
|
+
if (!body) return null;
|
|
237
|
+
try {
|
|
238
|
+
const parsed = JSON.parse(body) as { reason?: unknown };
|
|
239
|
+
return typeof parsed.reason === 'string' ? parsed.reason : null;
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|