@serfab/cadre-core 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +255 -255
- package/dist/announce-addrs.d.ts +49 -0
- package/dist/announce-addrs.js +78 -0
- package/dist/announce-addrs.js.map +1 -0
- package/dist/cadre-node.d.ts +484 -58
- package/dist/cadre-node.js +953 -182
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-cohort.d.ts +26 -0
- package/dist/control-cohort.js +26 -0
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +222 -125
- package/dist/control-database.js +356 -266
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +8 -7
- package/dist/control-formation-recorder.js +8 -7
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-read-retry.d.ts +103 -0
- package/dist/control-read-retry.js +145 -0
- package/dist/control-read-retry.js.map +1 -0
- package/dist/control-retry.d.ts +101 -0
- package/dist/control-retry.js +108 -0
- package/dist/control-retry.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +805 -774
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +65 -31
- package/dist/control-write-retry.js +33 -103
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +17 -15
- package/dist/delegate-admission.js +17 -15
- package/dist/delegate-admission.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +18 -9
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +211 -25
- package/dist/membership-connection-gater.js +309 -29
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/peer-addr-book.d.ts +92 -0
- package/dist/peer-addr-book.js +220 -0
- package/dist/peer-addr-book.js.map +1 -0
- package/dist/peer-record.d.ts +26 -0
- package/dist/peer-record.js +32 -0
- package/dist/peer-record.js.map +1 -1
- package/dist/relay-addrs.d.ts +76 -26
- package/dist/relay-addrs.js +115 -27
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +20 -15
- package/dist/relay-reservation.js +18 -14
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +7 -0
- package/dist/seed-bootstrap.js +15 -13
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/strand-addr-protocol.js +16 -0
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-backfill.js +13 -4
- package/dist/strand-backfill.js.map +1 -1
- package/dist/strand-cohort.d.ts +4 -37
- package/dist/strand-cohort.js +4 -15
- package/dist/strand-cohort.js.map +1 -1
- package/dist/strand-database.d.ts +11 -16
- package/dist/strand-database.js +19 -7
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +18 -17
- package/dist/strand-formation-manager.js +25 -27
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +7 -7
- package/dist/strand-formation-protocol.js +8 -8
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +53 -21
- package/dist/strand-instance-manager.js +128 -37
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +113 -19
- package/dist/strand-membership-writer.js +219 -51
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +3 -3
- package/dist/strand-solicitation.js +4 -5
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +47 -2
- package/dist/strand-wake-protocol.js +76 -6
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/types.d.ts +107 -54
- package/dist/types.js.map +1 -1
- package/package.json +8 -7
- package/src/announce-addrs.ts +93 -0
- package/src/arachnode-stub.ts +159 -159
- package/src/bootstrap-peer-store-file.ts +44 -44
- package/src/bootstrap-peer-store.ts +172 -172
- package/src/cadre-node.ts +1026 -194
- package/src/canonical-json.ts +30 -30
- package/src/control-authorization.ts +114 -114
- package/src/control-cohort.ts +130 -103
- package/src/control-database.ts +376 -298
- package/src/control-formation-recorder.ts +305 -304
- package/src/control-read-retry.ts +167 -0
- package/src/control-retry.ts +190 -0
- package/src/control-schema.ts +817 -786
- package/src/control-stream.ts +205 -205
- package/src/control-write-retry.ts +383 -473
- package/src/delegate-admission.ts +304 -302
- package/src/device-token.ts +94 -94
- package/src/diagnostics/connection-path.ts +277 -277
- package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
- package/src/ed25519-key.ts +134 -134
- package/src/enrollment.ts +199 -199
- package/src/file-durable-slot.ts +61 -61
- package/src/formation-approval.ts +604 -604
- package/src/fs-atomic.ts +107 -107
- package/src/hibernation-manager.ts +437 -437
- package/src/identity-key.ts +109 -109
- package/src/index.ts +435 -406
- package/src/key-store-file.ts +132 -132
- package/src/key-store.ts +99 -99
- package/src/membership-connection-gater.ts +368 -39
- package/src/node-local-snapshot.ts +260 -260
- package/src/peer-addr-book.ts +250 -0
- package/src/peer-authorization.ts +259 -259
- package/src/peer-record.ts +197 -164
- package/src/push-credentials.ts +84 -84
- package/src/push-node.ts +64 -64
- package/src/push-notifier-apns.ts +247 -247
- package/src/push-notifier-fcm.ts +220 -220
- package/src/push-notifier-shared.ts +28 -28
- package/src/push-notifier.ts +52 -52
- package/src/relay-addrs.ts +134 -27
- package/src/relay-reservation.ts +22 -17
- package/src/seed-bootstrap.ts +15 -15
- package/src/seed-trust-policy.ts +140 -140
- package/src/strand-addr-protocol.ts +458 -441
- package/src/strand-backfill.ts +28 -5
- package/src/strand-cohort.ts +38 -77
- package/src/strand-database.ts +202 -204
- package/src/strand-formation-manager.ts +26 -28
- package/src/strand-formation-protocol.ts +692 -692
- package/src/strand-instance-manager.ts +636 -553
- package/src/strand-member-key.ts +39 -39
- package/src/strand-member-registry.ts +182 -182
- package/src/strand-membership-writer.ts +1627 -1430
- package/src/strand-solicitation.ts +471 -472
- package/src/strand-transport-key.ts +58 -58
- package/src/strand-wake-payload.ts +32 -32
- package/src/strand-wake-protocol.ts +400 -314
- package/src/strand-watcher.ts +314 -314
- package/src/trusted-owner-store-file.ts +41 -41
- package/src/trusted-owner-store.ts +171 -171
- package/src/types.ts +1354 -1303
package/src/push-node.ts
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* push-node.ts — Node-only subpath entry for platform push **construction**.
|
|
3
|
-
*
|
|
4
|
-
* Import from the dedicated subpath, never the package root:
|
|
5
|
-
*
|
|
6
|
-
* ```ts
|
|
7
|
-
* import { createPushNotifier } from '@serfab/cadre-core/push-node';
|
|
8
|
-
* ```
|
|
9
|
-
*
|
|
10
|
-
* The FCM/APNs implementations reach for `node:crypto` / `node:http2`, so they
|
|
11
|
-
* are quarantined here — unreachable from the cross-platform `./index.js` graph
|
|
12
|
-
* (same isolation pattern as `./key-store-file`). A React Native / browser entry
|
|
13
|
-
* never resolves this path, so the Node builtins never reach a bundler that
|
|
14
|
-
* cannot satisfy them. The core references only the {@link PushNotifier}
|
|
15
|
-
* *interface* (`push-notifier.ts`) and a Node host injects the instance built
|
|
16
|
-
* here into `CadreNodeConfig.push.notifier`.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import type { PushCredentials, PushPlatform } from './types.js';
|
|
20
|
-
import type { PushMessage, PushNotifier, PushSendResult } from './push-notifier.js';
|
|
21
|
-
import { createFcmPushNotifier, type FcmPushDeps } from './push-notifier-fcm.js';
|
|
22
|
-
import { createApnsPushNotifier, type ApnsPushDeps } from './push-notifier-apns.js';
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Per-platform transport/clock/log seams, injected by tests. Defaults are the
|
|
26
|
-
* real implementations (global `fetch` for FCM, a `node:http2` session for APNs).
|
|
27
|
-
*/
|
|
28
|
-
export interface PushNotifierDeps {
|
|
29
|
-
fcm?: FcmPushDeps;
|
|
30
|
-
apns?: ApnsPushDeps;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Build a {@link PushNotifier} router over the configured credentials. Only the
|
|
35
|
-
* platforms whose credentials are present get a backing implementation; a `send`
|
|
36
|
-
* for an unconfigured platform returns a best-effort `no <platform> credentials`
|
|
37
|
-
* failure rather than throwing.
|
|
38
|
-
*/
|
|
39
|
-
export function createPushNotifier(creds: PushCredentials, deps: PushNotifierDeps = {}): PushNotifier {
|
|
40
|
-
const fcm = creds.fcm ? createFcmPushNotifier(creds.fcm, deps.fcm) : undefined;
|
|
41
|
-
const apns = creds.apns ? createApnsPushNotifier(creds.apns, deps.apns) : undefined;
|
|
42
|
-
|
|
43
|
-
async function send(msg: PushMessage): Promise<PushSendResult> {
|
|
44
|
-
if (msg.platform === 'fcm') return fcm ? fcm.send(msg) : noCreds('fcm');
|
|
45
|
-
if (msg.platform === 'apns') return apns ? apns.send(msg) : noCreds('apns');
|
|
46
|
-
return { ok: false, unregistered: false, error: `unknown platform ${String(msg.platform)}` };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function close(): Promise<void> {
|
|
50
|
-
await Promise.all([fcm?.close(), apns?.close()]);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return { send, close };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Best-effort no-op result for a platform with no configured credentials. */
|
|
57
|
-
function noCreds(platform: PushPlatform): PushSendResult {
|
|
58
|
-
return { ok: false, unregistered: false, error: `no ${platform} credentials` };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Per-platform builders + their injected-dep types, for a host that wants a
|
|
62
|
-
// single-platform notifier or to inject a fake transport directly.
|
|
63
|
-
export { createFcmPushNotifier, createApnsPushNotifier };
|
|
64
|
-
export type { FcmPushDeps, ApnsPushDeps };
|
|
1
|
+
/**
|
|
2
|
+
* push-node.ts — Node-only subpath entry for platform push **construction**.
|
|
3
|
+
*
|
|
4
|
+
* Import from the dedicated subpath, never the package root:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { createPushNotifier } from '@serfab/cadre-core/push-node';
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* The FCM/APNs implementations reach for `node:crypto` / `node:http2`, so they
|
|
11
|
+
* are quarantined here — unreachable from the cross-platform `./index.js` graph
|
|
12
|
+
* (same isolation pattern as `./key-store-file`). A React Native / browser entry
|
|
13
|
+
* never resolves this path, so the Node builtins never reach a bundler that
|
|
14
|
+
* cannot satisfy them. The core references only the {@link PushNotifier}
|
|
15
|
+
* *interface* (`push-notifier.ts`) and a Node host injects the instance built
|
|
16
|
+
* here into `CadreNodeConfig.push.notifier`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { PushCredentials, PushPlatform } from './types.js';
|
|
20
|
+
import type { PushMessage, PushNotifier, PushSendResult } from './push-notifier.js';
|
|
21
|
+
import { createFcmPushNotifier, type FcmPushDeps } from './push-notifier-fcm.js';
|
|
22
|
+
import { createApnsPushNotifier, type ApnsPushDeps } from './push-notifier-apns.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Per-platform transport/clock/log seams, injected by tests. Defaults are the
|
|
26
|
+
* real implementations (global `fetch` for FCM, a `node:http2` session for APNs).
|
|
27
|
+
*/
|
|
28
|
+
export interface PushNotifierDeps {
|
|
29
|
+
fcm?: FcmPushDeps;
|
|
30
|
+
apns?: ApnsPushDeps;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a {@link PushNotifier} router over the configured credentials. Only the
|
|
35
|
+
* platforms whose credentials are present get a backing implementation; a `send`
|
|
36
|
+
* for an unconfigured platform returns a best-effort `no <platform> credentials`
|
|
37
|
+
* failure rather than throwing.
|
|
38
|
+
*/
|
|
39
|
+
export function createPushNotifier(creds: PushCredentials, deps: PushNotifierDeps = {}): PushNotifier {
|
|
40
|
+
const fcm = creds.fcm ? createFcmPushNotifier(creds.fcm, deps.fcm) : undefined;
|
|
41
|
+
const apns = creds.apns ? createApnsPushNotifier(creds.apns, deps.apns) : undefined;
|
|
42
|
+
|
|
43
|
+
async function send(msg: PushMessage): Promise<PushSendResult> {
|
|
44
|
+
if (msg.platform === 'fcm') return fcm ? fcm.send(msg) : noCreds('fcm');
|
|
45
|
+
if (msg.platform === 'apns') return apns ? apns.send(msg) : noCreds('apns');
|
|
46
|
+
return { ok: false, unregistered: false, error: `unknown platform ${String(msg.platform)}` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function close(): Promise<void> {
|
|
50
|
+
await Promise.all([fcm?.close(), apns?.close()]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { send, close };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Best-effort no-op result for a platform with no configured credentials. */
|
|
57
|
+
function noCreds(platform: PushPlatform): PushSendResult {
|
|
58
|
+
return { ok: false, unregistered: false, error: `no ${platform} credentials` };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Per-platform builders + their injected-dep types, for a host that wants a
|
|
62
|
+
// single-platform notifier or to inject a fake transport directly.
|
|
63
|
+
export { createFcmPushNotifier, createApnsPushNotifier };
|
|
64
|
+
export type { FcmPushDeps, ApnsPushDeps };
|
|
@@ -1,247 +1,247 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* push-notifier-apns.ts — APNs HTTP/2 strand-wake delivery.
|
|
3
|
-
*
|
|
4
|
-
* Node-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. APNs
|
|
6
|
-
* mandates HTTP/2, so `node:http2` is unavoidable here — one reason the push
|
|
7
|
-
* path is injected rather than rebuilt on WebCrypto/`fetch`.
|
|
8
|
-
*
|
|
9
|
-
* Reachable only through the `@serfab/cadre-core/push-node` subpath — never from
|
|
10
|
-
* the cross-platform `./index.js` graph — so the `node:crypto` / `node:http2`
|
|
11
|
-
* imports below are safe: no RN/browser bundler ever resolves this module. (Plain
|
|
12
|
-
* named imports; the old namespace-import-to-only-warn dance is obsolete now that
|
|
13
|
-
* the module is unreachable from a browser build by construction.)
|
|
14
|
-
*
|
|
15
|
-
* The HTTP/2 call is behind an injected `Http2Requester` seam (bundled with a
|
|
16
|
-
* `close` into an {@link ApnsTransport}) so unit tests assert the request shape
|
|
17
|
-
* and map every documented response code with no real network and no credentials.
|
|
18
|
-
* The default transport owns a single lazily-(re)established `node:http2` session.
|
|
19
|
-
*
|
|
20
|
-
* Two single-shot retries guard transient failure without a retry storm: a
|
|
21
|
-
* thrown transport error (session death / GOAWAY mid-send) re-establishes the
|
|
22
|
-
* session and retries the one request once; a 403 `ExpiredProviderToken`
|
|
23
|
-
* re-mints the provider JWT and retries once. A second failure is returned as-is.
|
|
24
|
-
*
|
|
25
|
-
* Secret hygiene: the `.p8` `privateKey`, the minted JWT, and full device tokens
|
|
26
|
-
* are never logged — failure log lines carry only a status/reason and a redacted
|
|
27
|
-
* token prefix.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { sign } from 'node:crypto';
|
|
31
|
-
import { connect, type ClientHttp2Session } from 'node:http2';
|
|
32
|
-
import debug from 'debug';
|
|
33
|
-
import type { ApnsCredentials } from './types.js';
|
|
34
|
-
import type { PushMessage, PushNotifier, PushSendResult } from './push-notifier.js';
|
|
35
|
-
import { MAX_REASON_LEN, b64urlJson, errText, redact } from './push-notifier-shared.js';
|
|
36
|
-
|
|
37
|
-
const APNS_PROD_HOST = 'https://api.push.apple.com';
|
|
38
|
-
const APNS_SANDBOX_HOST = 'https://api.sandbox.push.apple.com';
|
|
39
|
-
/** Apple requires the provider token be 20–60 min old; refresh well inside that. */
|
|
40
|
-
const PROVIDER_TOKEN_TTL_MS = 45 * 60_000;
|
|
41
|
-
|
|
42
|
-
const debugApns = debug('sereus:cadre:push:apns');
|
|
43
|
-
|
|
44
|
-
/** Raw HTTP/2 response: APNs `:status` and the (possibly empty) JSON body. */
|
|
45
|
-
export interface ApnsResponse {
|
|
46
|
-
status: number;
|
|
47
|
-
body: string;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** A single APNs HTTP/2 request — `:path`, headers, and the JSON body. */
|
|
51
|
-
export interface ApnsRequest {
|
|
52
|
-
/** `:path`, e.g. `/3/device/{token}`. */
|
|
53
|
-
path: string;
|
|
54
|
-
headers: Record<string, string>;
|
|
55
|
-
body: string;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Low-level HTTP/2 request seam. Resolves with the `(status, body)` pair; rejects
|
|
60
|
-
* (throws) on a session/transport failure so the caller can re-establish + retry.
|
|
61
|
-
*/
|
|
62
|
-
export type Http2Requester = (req: ApnsRequest) => Promise<ApnsResponse>;
|
|
63
|
-
|
|
64
|
-
/** The HTTP/2 request seam plus its session-teardown. */
|
|
65
|
-
export interface ApnsTransport {
|
|
66
|
-
request: Http2Requester;
|
|
67
|
-
close(): Promise<void>;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface ApnsPushDeps {
|
|
71
|
-
/** HTTP/2 transport. Defaults to a real `node:http2` session manager. */
|
|
72
|
-
transport?: ApnsTransport;
|
|
73
|
-
/** Monotonic clock (ms). Defaults to `Date.now`. Injected for token-cache tests. */
|
|
74
|
-
now?: () => number;
|
|
75
|
-
/** Failure log seam (default: `debug('sereus:cadre:push:apns')`). */
|
|
76
|
-
log?: (line: string) => void;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** A cached provider JWT and the wall-clock ms after which to refresh it. */
|
|
80
|
-
interface CachedProviderToken {
|
|
81
|
-
value: string;
|
|
82
|
-
expiresAt: number;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function createApnsPushNotifier(creds: ApnsCredentials, deps: ApnsPushDeps = {}): PushNotifier {
|
|
86
|
-
const host = creds.production ? APNS_PROD_HOST : APNS_SANDBOX_HOST;
|
|
87
|
-
const transport = deps.transport ?? createHttp2Transport(host);
|
|
88
|
-
const now = deps.now ?? (() => Date.now());
|
|
89
|
-
const log = deps.log ?? ((line: string) => debugApns(line));
|
|
90
|
-
|
|
91
|
-
let cached: CachedProviderToken | null = null;
|
|
92
|
-
|
|
93
|
-
async function send(msg: PushMessage): Promise<PushSendResult> {
|
|
94
|
-
let jwt: string;
|
|
95
|
-
try {
|
|
96
|
-
jwt = providerToken(false);
|
|
97
|
-
} catch (err) {
|
|
98
|
-
log(`provider-token mint failed: ${errText(err)}`);
|
|
99
|
-
return { ok: false, unregistered: false, error: `apns jwt mint failed: ${errText(err)}` };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
let res = await tryRequest(msg, jwt);
|
|
103
|
-
if (!res) return { ok: false, unregistered: false, error: `apns transport failed (token=${redact(msg.token)})` };
|
|
104
|
-
|
|
105
|
-
// Provider token expired mid-flight — re-mint once and retry the single send.
|
|
106
|
-
if (res.status === 403 && /ExpiredProviderToken/i.test(res.body)) {
|
|
107
|
-
try {
|
|
108
|
-
jwt = providerToken(true);
|
|
109
|
-
} catch (err) {
|
|
110
|
-
log(`provider-token re-mint failed: ${errText(err)}`);
|
|
111
|
-
return { ok: false, unregistered: false, error: `apns jwt re-mint failed: ${errText(err)}` };
|
|
112
|
-
}
|
|
113
|
-
res = await tryRequest(msg, jwt);
|
|
114
|
-
if (!res) return { ok: false, unregistered: false, error: `apns transport failed (token=${redact(msg.token)})` };
|
|
115
|
-
}
|
|
116
|
-
return mapResult(res, msg.token);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Send one request with a single re-establish-on-throw retry. A thrown error is
|
|
121
|
-
* a session death / GOAWAY; the default transport recreates its session lazily
|
|
122
|
-
* on the next call, so a second attempt rides a fresh session. Returns `null`
|
|
123
|
-
* when both attempts throw.
|
|
124
|
-
*/
|
|
125
|
-
async function tryRequest(msg: PushMessage, jwt: string): Promise<ApnsResponse | null> {
|
|
126
|
-
const req = buildRequest(msg, jwt);
|
|
127
|
-
try {
|
|
128
|
-
return await transport.request(req);
|
|
129
|
-
} catch (err1) {
|
|
130
|
-
log(`http2 request failed, re-establishing once: ${errText(err1)}`);
|
|
131
|
-
try {
|
|
132
|
-
return await transport.request(req);
|
|
133
|
-
} catch (err2) {
|
|
134
|
-
log(`http2 request failed after re-establish: ${errText(err2)}`);
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function mapResult(res: ApnsResponse, deviceToken: string): PushSendResult {
|
|
141
|
-
if (res.status === 200) return { ok: true };
|
|
142
|
-
const reason = apnsReason(res.body);
|
|
143
|
-
const summary = `apns ${res.status}${reason ? ` ${reason}` : ''}`;
|
|
144
|
-
if (res.status === 410 || reason === 'Unregistered' || (res.status === 400 && reason === 'BadDeviceToken')) {
|
|
145
|
-
log(`unregistered token=${redact(deviceToken)} (${summary})`);
|
|
146
|
-
return { ok: false, unregistered: true, error: summary };
|
|
147
|
-
}
|
|
148
|
-
log(`send failed token=${redact(deviceToken)} (${summary})`);
|
|
149
|
-
return { ok: false, unregistered: false, error: summary };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function buildRequest(msg: PushMessage, jwt: string): ApnsRequest {
|
|
153
|
-
const body = JSON.stringify({
|
|
154
|
-
aps: { 'content-available': 1 },
|
|
155
|
-
type: msg.payload.type,
|
|
156
|
-
strandId: msg.payload.strandId,
|
|
157
|
-
reason: msg.payload.reason.slice(0, MAX_REASON_LEN),
|
|
158
|
-
});
|
|
159
|
-
return {
|
|
160
|
-
path: `/3/device/${msg.token}`,
|
|
161
|
-
headers: {
|
|
162
|
-
authorization: `bearer ${jwt}`,
|
|
163
|
-
'apns-topic': creds.bundleId,
|
|
164
|
-
'apns-push-type': 'background',
|
|
165
|
-
'apns-priority': '5',
|
|
166
|
-
'apns-expiration': '0',
|
|
167
|
-
},
|
|
168
|
-
body,
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function providerToken(forceRefresh: boolean): string {
|
|
173
|
-
if (!forceRefresh && cached && cached.expiresAt > now()) return cached.value;
|
|
174
|
-
const iat = Math.floor(now() / 1000);
|
|
175
|
-
const header = { alg: 'ES256', kid: creds.keyId };
|
|
176
|
-
const claims = { iss: creds.teamId, iat };
|
|
177
|
-
const signingInput = `${b64urlJson(header)}.${b64urlJson(claims)}`;
|
|
178
|
-
const sig = sign('SHA256', Buffer.from(signingInput), {
|
|
179
|
-
key: creds.privateKey,
|
|
180
|
-
dsaEncoding: 'ieee-p1363',
|
|
181
|
-
}).toString('base64url');
|
|
182
|
-
const jwt = `${signingInput}.${sig}`;
|
|
183
|
-
cached = { value: jwt, expiresAt: now() + PROVIDER_TOKEN_TTL_MS };
|
|
184
|
-
return jwt;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async function close(): Promise<void> {
|
|
188
|
-
cached = null;
|
|
189
|
-
await transport.close();
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return { send, close };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Default {@link ApnsTransport}: a single lazily-(re)established `node:http2`
|
|
197
|
-
* session to Apple's gateway. A session that errors / receives GOAWAY / closes is
|
|
198
|
-
* dropped so the next request re-establishes it. Not exercised by unit tests (no
|
|
199
|
-
* network) — tests inject a fake transport; the real path is validated out-of-band.
|
|
200
|
-
*/
|
|
201
|
-
function createHttp2Transport(host: string): ApnsTransport {
|
|
202
|
-
let session: ClientHttp2Session | null = null;
|
|
203
|
-
|
|
204
|
-
function ensureSession(): ClientHttp2Session {
|
|
205
|
-
if (session && !session.closed && !session.destroyed) return session;
|
|
206
|
-
const s = connect(host);
|
|
207
|
-
// Drop a dead session so ensureSession recreates it on the next request.
|
|
208
|
-
s.on('error', () => { if (session === s) session = null; });
|
|
209
|
-
s.on('goaway', () => { s.destroy(); });
|
|
210
|
-
s.on('close', () => { if (session === s) session = null; });
|
|
211
|
-
session = s;
|
|
212
|
-
return s;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
async function request(req: ApnsRequest): Promise<ApnsResponse> {
|
|
216
|
-
const s = ensureSession();
|
|
217
|
-
return await new Promise<ApnsResponse>((resolve, reject) => {
|
|
218
|
-
const stream = s.request({ ':method': 'POST', ':path': req.path, ...req.headers });
|
|
219
|
-
const chunks: Buffer[] = [];
|
|
220
|
-
let status = 0;
|
|
221
|
-
stream.on('response', (headers) => { status = Number(headers[':status'] ?? 0); });
|
|
222
|
-
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
223
|
-
stream.on('end', () => resolve({ status, body: Buffer.concat(chunks).toString('utf8') }));
|
|
224
|
-
stream.on('error', (err) => reject(err));
|
|
225
|
-
stream.end(req.body);
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
async function close(): Promise<void> {
|
|
230
|
-
const s = session;
|
|
231
|
-
session = null;
|
|
232
|
-
if (s) await new Promise<void>((resolve) => s.close(() => resolve()));
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return { request, close };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Extract the APNs error `reason` field from a response body, if present. */
|
|
239
|
-
function apnsReason(body: string): string | null {
|
|
240
|
-
if (!body) return null;
|
|
241
|
-
try {
|
|
242
|
-
const parsed = JSON.parse(body) as { reason?: unknown };
|
|
243
|
-
return typeof parsed.reason === 'string' ? parsed.reason : null;
|
|
244
|
-
} catch {
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* push-notifier-apns.ts — APNs HTTP/2 strand-wake delivery.
|
|
3
|
+
*
|
|
4
|
+
* Node-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. APNs
|
|
6
|
+
* mandates HTTP/2, so `node:http2` is unavoidable here — one reason the push
|
|
7
|
+
* path is injected rather than rebuilt on WebCrypto/`fetch`.
|
|
8
|
+
*
|
|
9
|
+
* Reachable only through the `@serfab/cadre-core/push-node` subpath — never from
|
|
10
|
+
* the cross-platform `./index.js` graph — so the `node:crypto` / `node:http2`
|
|
11
|
+
* imports below are safe: no RN/browser bundler ever resolves this module. (Plain
|
|
12
|
+
* named imports; the old namespace-import-to-only-warn dance is obsolete now that
|
|
13
|
+
* the module is unreachable from a browser build by construction.)
|
|
14
|
+
*
|
|
15
|
+
* The HTTP/2 call is behind an injected `Http2Requester` seam (bundled with a
|
|
16
|
+
* `close` into an {@link ApnsTransport}) so unit tests assert the request shape
|
|
17
|
+
* and map every documented response code with no real network and no credentials.
|
|
18
|
+
* The default transport owns a single lazily-(re)established `node:http2` session.
|
|
19
|
+
*
|
|
20
|
+
* Two single-shot retries guard transient failure without a retry storm: a
|
|
21
|
+
* thrown transport error (session death / GOAWAY mid-send) re-establishes the
|
|
22
|
+
* session and retries the one request once; a 403 `ExpiredProviderToken`
|
|
23
|
+
* re-mints the provider JWT and retries once. A second failure is returned as-is.
|
|
24
|
+
*
|
|
25
|
+
* Secret hygiene: the `.p8` `privateKey`, the minted JWT, and full device tokens
|
|
26
|
+
* are never logged — failure log lines carry only a status/reason and a redacted
|
|
27
|
+
* token prefix.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { sign } from 'node:crypto';
|
|
31
|
+
import { connect, type ClientHttp2Session } from 'node:http2';
|
|
32
|
+
import debug from 'debug';
|
|
33
|
+
import type { ApnsCredentials } from './types.js';
|
|
34
|
+
import type { PushMessage, PushNotifier, PushSendResult } from './push-notifier.js';
|
|
35
|
+
import { MAX_REASON_LEN, b64urlJson, errText, redact } from './push-notifier-shared.js';
|
|
36
|
+
|
|
37
|
+
const APNS_PROD_HOST = 'https://api.push.apple.com';
|
|
38
|
+
const APNS_SANDBOX_HOST = 'https://api.sandbox.push.apple.com';
|
|
39
|
+
/** Apple requires the provider token be 20–60 min old; refresh well inside that. */
|
|
40
|
+
const PROVIDER_TOKEN_TTL_MS = 45 * 60_000;
|
|
41
|
+
|
|
42
|
+
const debugApns = debug('sereus:cadre:push:apns');
|
|
43
|
+
|
|
44
|
+
/** Raw HTTP/2 response: APNs `:status` and the (possibly empty) JSON body. */
|
|
45
|
+
export interface ApnsResponse {
|
|
46
|
+
status: number;
|
|
47
|
+
body: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A single APNs HTTP/2 request — `:path`, headers, and the JSON body. */
|
|
51
|
+
export interface ApnsRequest {
|
|
52
|
+
/** `:path`, e.g. `/3/device/{token}`. */
|
|
53
|
+
path: string;
|
|
54
|
+
headers: Record<string, string>;
|
|
55
|
+
body: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Low-level HTTP/2 request seam. Resolves with the `(status, body)` pair; rejects
|
|
60
|
+
* (throws) on a session/transport failure so the caller can re-establish + retry.
|
|
61
|
+
*/
|
|
62
|
+
export type Http2Requester = (req: ApnsRequest) => Promise<ApnsResponse>;
|
|
63
|
+
|
|
64
|
+
/** The HTTP/2 request seam plus its session-teardown. */
|
|
65
|
+
export interface ApnsTransport {
|
|
66
|
+
request: Http2Requester;
|
|
67
|
+
close(): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ApnsPushDeps {
|
|
71
|
+
/** HTTP/2 transport. Defaults to a real `node:http2` session manager. */
|
|
72
|
+
transport?: ApnsTransport;
|
|
73
|
+
/** Monotonic clock (ms). Defaults to `Date.now`. Injected for token-cache tests. */
|
|
74
|
+
now?: () => number;
|
|
75
|
+
/** Failure log seam (default: `debug('sereus:cadre:push:apns')`). */
|
|
76
|
+
log?: (line: string) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A cached provider JWT and the wall-clock ms after which to refresh it. */
|
|
80
|
+
interface CachedProviderToken {
|
|
81
|
+
value: string;
|
|
82
|
+
expiresAt: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createApnsPushNotifier(creds: ApnsCredentials, deps: ApnsPushDeps = {}): PushNotifier {
|
|
86
|
+
const host = creds.production ? APNS_PROD_HOST : APNS_SANDBOX_HOST;
|
|
87
|
+
const transport = deps.transport ?? createHttp2Transport(host);
|
|
88
|
+
const now = deps.now ?? (() => Date.now());
|
|
89
|
+
const log = deps.log ?? ((line: string) => debugApns(line));
|
|
90
|
+
|
|
91
|
+
let cached: CachedProviderToken | null = null;
|
|
92
|
+
|
|
93
|
+
async function send(msg: PushMessage): Promise<PushSendResult> {
|
|
94
|
+
let jwt: string;
|
|
95
|
+
try {
|
|
96
|
+
jwt = providerToken(false);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
log(`provider-token mint failed: ${errText(err)}`);
|
|
99
|
+
return { ok: false, unregistered: false, error: `apns jwt mint failed: ${errText(err)}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let res = await tryRequest(msg, jwt);
|
|
103
|
+
if (!res) return { ok: false, unregistered: false, error: `apns transport failed (token=${redact(msg.token)})` };
|
|
104
|
+
|
|
105
|
+
// Provider token expired mid-flight — re-mint once and retry the single send.
|
|
106
|
+
if (res.status === 403 && /ExpiredProviderToken/i.test(res.body)) {
|
|
107
|
+
try {
|
|
108
|
+
jwt = providerToken(true);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
log(`provider-token re-mint failed: ${errText(err)}`);
|
|
111
|
+
return { ok: false, unregistered: false, error: `apns jwt re-mint failed: ${errText(err)}` };
|
|
112
|
+
}
|
|
113
|
+
res = await tryRequest(msg, jwt);
|
|
114
|
+
if (!res) return { ok: false, unregistered: false, error: `apns transport failed (token=${redact(msg.token)})` };
|
|
115
|
+
}
|
|
116
|
+
return mapResult(res, msg.token);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Send one request with a single re-establish-on-throw retry. A thrown error is
|
|
121
|
+
* a session death / GOAWAY; the default transport recreates its session lazily
|
|
122
|
+
* on the next call, so a second attempt rides a fresh session. Returns `null`
|
|
123
|
+
* when both attempts throw.
|
|
124
|
+
*/
|
|
125
|
+
async function tryRequest(msg: PushMessage, jwt: string): Promise<ApnsResponse | null> {
|
|
126
|
+
const req = buildRequest(msg, jwt);
|
|
127
|
+
try {
|
|
128
|
+
return await transport.request(req);
|
|
129
|
+
} catch (err1) {
|
|
130
|
+
log(`http2 request failed, re-establishing once: ${errText(err1)}`);
|
|
131
|
+
try {
|
|
132
|
+
return await transport.request(req);
|
|
133
|
+
} catch (err2) {
|
|
134
|
+
log(`http2 request failed after re-establish: ${errText(err2)}`);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function mapResult(res: ApnsResponse, deviceToken: string): PushSendResult {
|
|
141
|
+
if (res.status === 200) return { ok: true };
|
|
142
|
+
const reason = apnsReason(res.body);
|
|
143
|
+
const summary = `apns ${res.status}${reason ? ` ${reason}` : ''}`;
|
|
144
|
+
if (res.status === 410 || reason === 'Unregistered' || (res.status === 400 && reason === 'BadDeviceToken')) {
|
|
145
|
+
log(`unregistered token=${redact(deviceToken)} (${summary})`);
|
|
146
|
+
return { ok: false, unregistered: true, error: summary };
|
|
147
|
+
}
|
|
148
|
+
log(`send failed token=${redact(deviceToken)} (${summary})`);
|
|
149
|
+
return { ok: false, unregistered: false, error: summary };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildRequest(msg: PushMessage, jwt: string): ApnsRequest {
|
|
153
|
+
const body = JSON.stringify({
|
|
154
|
+
aps: { 'content-available': 1 },
|
|
155
|
+
type: msg.payload.type,
|
|
156
|
+
strandId: msg.payload.strandId,
|
|
157
|
+
reason: msg.payload.reason.slice(0, MAX_REASON_LEN),
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
path: `/3/device/${msg.token}`,
|
|
161
|
+
headers: {
|
|
162
|
+
authorization: `bearer ${jwt}`,
|
|
163
|
+
'apns-topic': creds.bundleId,
|
|
164
|
+
'apns-push-type': 'background',
|
|
165
|
+
'apns-priority': '5',
|
|
166
|
+
'apns-expiration': '0',
|
|
167
|
+
},
|
|
168
|
+
body,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function providerToken(forceRefresh: boolean): string {
|
|
173
|
+
if (!forceRefresh && cached && cached.expiresAt > now()) return cached.value;
|
|
174
|
+
const iat = Math.floor(now() / 1000);
|
|
175
|
+
const header = { alg: 'ES256', kid: creds.keyId };
|
|
176
|
+
const claims = { iss: creds.teamId, iat };
|
|
177
|
+
const signingInput = `${b64urlJson(header)}.${b64urlJson(claims)}`;
|
|
178
|
+
const sig = sign('SHA256', Buffer.from(signingInput), {
|
|
179
|
+
key: creds.privateKey,
|
|
180
|
+
dsaEncoding: 'ieee-p1363',
|
|
181
|
+
}).toString('base64url');
|
|
182
|
+
const jwt = `${signingInput}.${sig}`;
|
|
183
|
+
cached = { value: jwt, expiresAt: now() + PROVIDER_TOKEN_TTL_MS };
|
|
184
|
+
return jwt;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function close(): Promise<void> {
|
|
188
|
+
cached = null;
|
|
189
|
+
await transport.close();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { send, close };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Default {@link ApnsTransport}: a single lazily-(re)established `node:http2`
|
|
197
|
+
* session to Apple's gateway. A session that errors / receives GOAWAY / closes is
|
|
198
|
+
* dropped so the next request re-establishes it. Not exercised by unit tests (no
|
|
199
|
+
* network) — tests inject a fake transport; the real path is validated out-of-band.
|
|
200
|
+
*/
|
|
201
|
+
function createHttp2Transport(host: string): ApnsTransport {
|
|
202
|
+
let session: ClientHttp2Session | null = null;
|
|
203
|
+
|
|
204
|
+
function ensureSession(): ClientHttp2Session {
|
|
205
|
+
if (session && !session.closed && !session.destroyed) return session;
|
|
206
|
+
const s = connect(host);
|
|
207
|
+
// Drop a dead session so ensureSession recreates it on the next request.
|
|
208
|
+
s.on('error', () => { if (session === s) session = null; });
|
|
209
|
+
s.on('goaway', () => { s.destroy(); });
|
|
210
|
+
s.on('close', () => { if (session === s) session = null; });
|
|
211
|
+
session = s;
|
|
212
|
+
return s;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function request(req: ApnsRequest): Promise<ApnsResponse> {
|
|
216
|
+
const s = ensureSession();
|
|
217
|
+
return await new Promise<ApnsResponse>((resolve, reject) => {
|
|
218
|
+
const stream = s.request({ ':method': 'POST', ':path': req.path, ...req.headers });
|
|
219
|
+
const chunks: Buffer[] = [];
|
|
220
|
+
let status = 0;
|
|
221
|
+
stream.on('response', (headers) => { status = Number(headers[':status'] ?? 0); });
|
|
222
|
+
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
223
|
+
stream.on('end', () => resolve({ status, body: Buffer.concat(chunks).toString('utf8') }));
|
|
224
|
+
stream.on('error', (err) => reject(err));
|
|
225
|
+
stream.end(req.body);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function close(): Promise<void> {
|
|
230
|
+
const s = session;
|
|
231
|
+
session = null;
|
|
232
|
+
if (s) await new Promise<void>((resolve) => s.close(() => resolve()));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { request, close };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Extract the APNs error `reason` field from a response body, if present. */
|
|
239
|
+
function apnsReason(body: string): string | null {
|
|
240
|
+
if (!body) return null;
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(body) as { reason?: unknown };
|
|
243
|
+
return typeof parsed.reason === 'string' ? parsed.reason : null;
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|