@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,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
+ }