@serfab/cadre-core 0.10.0 → 0.11.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/cadre-node.d.ts +203 -28
- package/dist/cadre-node.js +445 -84
- 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 +82 -117
- package/dist/control-database.js +217 -295
- 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-schema.d.ts +1 -1
- package/dist/control-schema.js +789 -774
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +15 -5
- package/dist/control-write-retry.js +19 -18
- package/dist/control-write-retry.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.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/seed-bootstrap.d.ts +7 -0
- package/dist/seed-bootstrap.js +15 -13
- package/dist/seed-bootstrap.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 +8 -8
- package/dist/strand-formation-manager.js +15 -18
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +52 -21
- package/dist/strand-instance-manager.js +100 -34
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +6 -4
- package/dist/strand-membership-writer.js +38 -36
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/types.d.ts +39 -31
- package/dist/types.js.map +1 -1
- package/package.json +8 -7
- 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 +5158 -4771
- 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 +2406 -2506
- package/src/control-formation-recorder.ts +305 -304
- package/src/control-schema.ts +801 -786
- package/src/control-stream.ts +205 -205
- package/src/control-write-retry.ts +483 -473
- package/src/delegate-admission.ts +302 -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 +1 -0
- package/src/key-store-file.ts +132 -132
- package/src/key-store.ts +99 -99
- package/src/membership-connection-gater.ts +204 -204
- 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 +164 -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 +111 -111
- package/src/relay-reservation.ts +823 -823
- package/src/seed-bootstrap.ts +15 -15
- package/src/seed-trust-policy.ts +140 -140
- package/src/strand-addr-protocol.ts +441 -441
- package/src/strand-backfill.ts +366 -366
- package/src/strand-cohort.ts +38 -77
- package/src/strand-database.ts +202 -204
- package/src/strand-formation-manager.ts +491 -494
- package/src/strand-formation-protocol.ts +692 -692
- package/src/strand-instance-manager.ts +108 -50
- package/src/strand-member-key.ts +39 -39
- package/src/strand-member-registry.ts +182 -182
- package/src/strand-membership-writer.ts +1432 -1430
- package/src/strand-solicitation.ts +472 -472
- package/src/strand-transport-key.ts +58 -58
- package/src/strand-wake-payload.ts +32 -32
- package/src/strand-wake-protocol.ts +314 -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 +1309 -1303
package/src/push-notifier-fcm.ts
CHANGED
|
@@ -1,220 +1,220 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* push-notifier-fcm.ts — FCM HTTP v1 strand-wake delivery.
|
|
3
|
-
*
|
|
4
|
-
* Node-only: mints a Google OAuth2 access token (RS256 JWT bearer grant signed
|
|
5
|
-
* with `node:crypto`) and POSTs a data message to the FCM v1 endpoint. The legacy
|
|
6
|
-
* server-key `fcm.googleapis.com/fcm/send` API is deprecated, so we use HTTP v1.
|
|
7
|
-
*
|
|
8
|
-
* Reachable only through the `@serfab/cadre-core/push-node` subpath — never from
|
|
9
|
-
* the cross-platform `./index.js` graph — so the `node:crypto` import below is
|
|
10
|
-
* safe: no RN/browser bundler ever resolves this module. (Plain named import;
|
|
11
|
-
* the old namespace-import-to-only-warn dance is obsolete now that the module is
|
|
12
|
-
* unreachable from a browser build by construction.)
|
|
13
|
-
*
|
|
14
|
-
* The network call is behind an injected `fetch`-like seam so unit tests assert
|
|
15
|
-
* the request shape and map every documented response code with no real network
|
|
16
|
-
* and no credentials. Failures are returned as {@link PushSendResult} values.
|
|
17
|
-
*
|
|
18
|
-
* Secret hygiene: the service-account `privateKey`, the minted JWT, and full
|
|
19
|
-
* device tokens are never logged — failure log lines carry only a status/error
|
|
20
|
-
* code and a redacted token prefix.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { sign } from 'node:crypto';
|
|
24
|
-
import debug from 'debug';
|
|
25
|
-
import type { FcmCredentials } from './types.js';
|
|
26
|
-
import type { PushMessage, PushNotifier, PushSendResult } from './push-notifier.js';
|
|
27
|
-
import { MAX_REASON_LEN, b64urlJson, errText, redact } from './push-notifier-shared.js';
|
|
28
|
-
|
|
29
|
-
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
|
30
|
-
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
|
|
31
|
-
const JWT_BEARER_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
|
|
32
|
-
/** Re-mint the access token this many ms before its stated expiry. */
|
|
33
|
-
const TOKEN_REFRESH_SKEW_MS = 60_000;
|
|
34
|
-
/** OAuth2 access tokens are short-lived (≤1h); assume 1h when unstated. */
|
|
35
|
-
const DEFAULT_TOKEN_TTL_MS = 3_600_000;
|
|
36
|
-
|
|
37
|
-
const debugFcm = debug('sereus:cadre:push:fcm');
|
|
38
|
-
|
|
39
|
-
/** Minimal `fetch` Response shape the FCM sender consumes. */
|
|
40
|
-
export interface FcmResponseLike {
|
|
41
|
-
status: number;
|
|
42
|
-
json(): Promise<unknown>;
|
|
43
|
-
text(): Promise<string>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Injected `fetch`-like transport (default: global `fetch`). */
|
|
47
|
-
export type FcmFetch = (
|
|
48
|
-
url: string,
|
|
49
|
-
init: { method: string; headers: Record<string, string>; body: string }
|
|
50
|
-
) => Promise<FcmResponseLike>;
|
|
51
|
-
|
|
52
|
-
export interface FcmPushDeps {
|
|
53
|
-
/** Network transport. Defaults to global `fetch`. */
|
|
54
|
-
fetch?: FcmFetch;
|
|
55
|
-
/** Monotonic clock (ms). Defaults to `Date.now`. Injected for cache tests. */
|
|
56
|
-
now?: () => number;
|
|
57
|
-
/** Failure log seam (default: `debug('sereus:cadre:push:fcm')`). */
|
|
58
|
-
log?: (line: string) => void;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** A cached OAuth2 access token and the wall-clock ms after which to re-mint. */
|
|
62
|
-
interface CachedAccessToken {
|
|
63
|
-
value: string;
|
|
64
|
-
expiresAt: number;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function createFcmPushNotifier(creds: FcmCredentials, deps: FcmPushDeps = {}): PushNotifier {
|
|
68
|
-
const doFetch: FcmFetch = deps.fetch ?? ((url, init) => fetch(url, init));
|
|
69
|
-
const now = deps.now ?? (() => Date.now());
|
|
70
|
-
const log = deps.log ?? ((line: string) => debugFcm(line));
|
|
71
|
-
const sendUrl = `https://fcm.googleapis.com/v1/projects/${creds.projectId}/messages:send`;
|
|
72
|
-
|
|
73
|
-
let cached: CachedAccessToken | null = null;
|
|
74
|
-
|
|
75
|
-
async function send(msg: PushMessage): Promise<PushSendResult> {
|
|
76
|
-
let token: string;
|
|
77
|
-
try {
|
|
78
|
-
token = await accessToken(false);
|
|
79
|
-
} catch (err) {
|
|
80
|
-
log(`oauth mint failed: ${errText(err)}`);
|
|
81
|
-
return { ok: false, unregistered: false, error: `fcm oauth mint failed: ${errText(err)}` };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const first = await postSend(msg, token);
|
|
85
|
-
if (first.status !== 401) return mapResult(first, msg.token);
|
|
86
|
-
|
|
87
|
-
// The cached access token was rejected (expired/revoked mid-flight). Re-mint
|
|
88
|
-
// once and retry the single send; a second failure is returned as-is.
|
|
89
|
-
let retryToken: string;
|
|
90
|
-
try {
|
|
91
|
-
retryToken = await accessToken(true);
|
|
92
|
-
} catch (err) {
|
|
93
|
-
log(`oauth re-mint failed: ${errText(err)}`);
|
|
94
|
-
return { ok: false, unregistered: false, error: `fcm oauth re-mint failed: ${errText(err)}` };
|
|
95
|
-
}
|
|
96
|
-
return mapResult(await postSend(msg, retryToken), msg.token);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function postSend(msg: PushMessage, token: string): Promise<{ status: number; body: string }> {
|
|
100
|
-
const body = JSON.stringify({
|
|
101
|
-
message: {
|
|
102
|
-
token: msg.token,
|
|
103
|
-
data: {
|
|
104
|
-
type: msg.payload.type,
|
|
105
|
-
strandId: msg.payload.strandId,
|
|
106
|
-
reason: msg.payload.reason.slice(0, MAX_REASON_LEN),
|
|
107
|
-
},
|
|
108
|
-
android: { priority: 'high' },
|
|
109
|
-
},
|
|
110
|
-
});
|
|
111
|
-
const res = await doFetch(sendUrl, {
|
|
112
|
-
method: 'POST',
|
|
113
|
-
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
114
|
-
body,
|
|
115
|
-
});
|
|
116
|
-
return { status: res.status, body: await safeText(res) };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function mapResult(res: { status: number; body: string }, deviceToken: string): PushSendResult {
|
|
120
|
-
if (res.status === 200) return { ok: true };
|
|
121
|
-
const summary = errorSummary(res.status, res.body);
|
|
122
|
-
if (isUnregistered(res.status, res.body)) {
|
|
123
|
-
log(`unregistered token=${redact(deviceToken)} (${summary})`);
|
|
124
|
-
return { ok: false, unregistered: true, error: summary };
|
|
125
|
-
}
|
|
126
|
-
log(`send failed token=${redact(deviceToken)} (${summary})`);
|
|
127
|
-
return { ok: false, unregistered: false, error: summary };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async function accessToken(forceRefresh: boolean): Promise<string> {
|
|
131
|
-
if (!forceRefresh && cached && cached.expiresAt > now()) return cached.value;
|
|
132
|
-
cached = null;
|
|
133
|
-
const minted = await mintAccessToken();
|
|
134
|
-
cached = { value: minted.value, expiresAt: now() + minted.ttlMs - TOKEN_REFRESH_SKEW_MS };
|
|
135
|
-
return minted.value;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async function mintAccessToken(): Promise<{ value: string; ttlMs: number }> {
|
|
139
|
-
const jwt = signOauthJwt();
|
|
140
|
-
const res = await doFetch(TOKEN_ENDPOINT, {
|
|
141
|
-
method: 'POST',
|
|
142
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
143
|
-
body: `grant_type=${encodeURIComponent(JWT_BEARER_GRANT)}&assertion=${encodeURIComponent(jwt)}`,
|
|
144
|
-
});
|
|
145
|
-
if (res.status !== 200) {
|
|
146
|
-
throw new Error(`token endpoint ${res.status}: ${await safeText(res)}`);
|
|
147
|
-
}
|
|
148
|
-
const parsed = (await res.json()) as { access_token?: unknown; expires_in?: unknown };
|
|
149
|
-
if (typeof parsed.access_token !== 'string') {
|
|
150
|
-
throw new Error('token endpoint returned no access_token');
|
|
151
|
-
}
|
|
152
|
-
const ttlMs = typeof parsed.expires_in === 'number' ? parsed.expires_in * 1000 : DEFAULT_TOKEN_TTL_MS;
|
|
153
|
-
return { value: parsed.access_token, ttlMs };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function signOauthJwt(): string {
|
|
157
|
-
const iat = Math.floor(now() / 1000);
|
|
158
|
-
const header = { alg: 'RS256', typ: 'JWT' };
|
|
159
|
-
const claims = {
|
|
160
|
-
iss: creds.clientEmail,
|
|
161
|
-
sub: creds.clientEmail,
|
|
162
|
-
scope: FCM_SCOPE,
|
|
163
|
-
aud: TOKEN_ENDPOINT,
|
|
164
|
-
iat,
|
|
165
|
-
exp: iat + 3600,
|
|
166
|
-
};
|
|
167
|
-
const signingInput = `${b64urlJson(header)}.${b64urlJson(claims)}`;
|
|
168
|
-
const sig = sign('RSA-SHA256', Buffer.from(signingInput), creds.privateKey).toString('base64url');
|
|
169
|
-
return `${signingInput}.${sig}`;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async function close(): Promise<void> {
|
|
173
|
-
cached = null;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return { send, close };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Whether an FCM v1 error response names a permanently-invalid registration
|
|
181
|
-
* token: a 404 (`UNREGISTERED`), or a 400 `INVALID_ARGUMENT` whose message names
|
|
182
|
-
* the registration token. A generic 400 (malformed message) is NOT unregistered.
|
|
183
|
-
*/
|
|
184
|
-
function isUnregistered(status: number, body: string): boolean {
|
|
185
|
-
if (status === 404) return true;
|
|
186
|
-
if (status !== 400) return false;
|
|
187
|
-
const code = fcmErrorCode(body);
|
|
188
|
-
if (code === 'UNREGISTERED') return true;
|
|
189
|
-
if (code === 'INVALID_ARGUMENT' && /registration[ -]token|not a valid fcm registration/i.test(body)) return true;
|
|
190
|
-
return false;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Pull the FCM `errorCode` (FcmError detail) or RPC `status` out of the body. */
|
|
194
|
-
function fcmErrorCode(body: string): string | null {
|
|
195
|
-
try {
|
|
196
|
-
const parsed = JSON.parse(body) as {
|
|
197
|
-
error?: { status?: string; details?: Array<{ errorCode?: string }> };
|
|
198
|
-
};
|
|
199
|
-
const err = parsed.error;
|
|
200
|
-
if (!err) return null;
|
|
201
|
-
const detailCode = err.details?.find((d) => typeof d.errorCode === 'string')?.errorCode;
|
|
202
|
-
return detailCode ?? err.status ?? null;
|
|
203
|
-
} catch {
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** A secret-free one-line error summary (status + error code, never the token). */
|
|
209
|
-
function errorSummary(status: number, body: string): string {
|
|
210
|
-
const code = fcmErrorCode(body);
|
|
211
|
-
return code ? `fcm ${status} ${code}` : `fcm ${status}`;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
async function safeText(res: FcmResponseLike): Promise<string> {
|
|
215
|
-
try {
|
|
216
|
-
return await res.text();
|
|
217
|
-
} catch {
|
|
218
|
-
return '';
|
|
219
|
-
}
|
|
220
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* push-notifier-fcm.ts — FCM HTTP v1 strand-wake delivery.
|
|
3
|
+
*
|
|
4
|
+
* Node-only: mints a Google OAuth2 access token (RS256 JWT bearer grant signed
|
|
5
|
+
* with `node:crypto`) and POSTs a data message to the FCM v1 endpoint. The legacy
|
|
6
|
+
* server-key `fcm.googleapis.com/fcm/send` API is deprecated, so we use HTTP v1.
|
|
7
|
+
*
|
|
8
|
+
* Reachable only through the `@serfab/cadre-core/push-node` subpath — never from
|
|
9
|
+
* the cross-platform `./index.js` graph — so the `node:crypto` import below is
|
|
10
|
+
* safe: no RN/browser bundler ever resolves this module. (Plain named import;
|
|
11
|
+
* the old namespace-import-to-only-warn dance is obsolete now that the module is
|
|
12
|
+
* unreachable from a browser build by construction.)
|
|
13
|
+
*
|
|
14
|
+
* The network call is behind an injected `fetch`-like seam so unit tests assert
|
|
15
|
+
* the request shape and map every documented response code with no real network
|
|
16
|
+
* and no credentials. Failures are returned as {@link PushSendResult} values.
|
|
17
|
+
*
|
|
18
|
+
* Secret hygiene: the service-account `privateKey`, the minted JWT, and full
|
|
19
|
+
* device tokens are never logged — failure log lines carry only a status/error
|
|
20
|
+
* code and a redacted token prefix.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { sign } from 'node:crypto';
|
|
24
|
+
import debug from 'debug';
|
|
25
|
+
import type { FcmCredentials } from './types.js';
|
|
26
|
+
import type { PushMessage, PushNotifier, PushSendResult } from './push-notifier.js';
|
|
27
|
+
import { MAX_REASON_LEN, b64urlJson, errText, redact } from './push-notifier-shared.js';
|
|
28
|
+
|
|
29
|
+
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
|
30
|
+
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
|
|
31
|
+
const JWT_BEARER_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
|
|
32
|
+
/** Re-mint the access token this many ms before its stated expiry. */
|
|
33
|
+
const TOKEN_REFRESH_SKEW_MS = 60_000;
|
|
34
|
+
/** OAuth2 access tokens are short-lived (≤1h); assume 1h when unstated. */
|
|
35
|
+
const DEFAULT_TOKEN_TTL_MS = 3_600_000;
|
|
36
|
+
|
|
37
|
+
const debugFcm = debug('sereus:cadre:push:fcm');
|
|
38
|
+
|
|
39
|
+
/** Minimal `fetch` Response shape the FCM sender consumes. */
|
|
40
|
+
export interface FcmResponseLike {
|
|
41
|
+
status: number;
|
|
42
|
+
json(): Promise<unknown>;
|
|
43
|
+
text(): Promise<string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Injected `fetch`-like transport (default: global `fetch`). */
|
|
47
|
+
export type FcmFetch = (
|
|
48
|
+
url: string,
|
|
49
|
+
init: { method: string; headers: Record<string, string>; body: string }
|
|
50
|
+
) => Promise<FcmResponseLike>;
|
|
51
|
+
|
|
52
|
+
export interface FcmPushDeps {
|
|
53
|
+
/** Network transport. Defaults to global `fetch`. */
|
|
54
|
+
fetch?: FcmFetch;
|
|
55
|
+
/** Monotonic clock (ms). Defaults to `Date.now`. Injected for cache tests. */
|
|
56
|
+
now?: () => number;
|
|
57
|
+
/** Failure log seam (default: `debug('sereus:cadre:push:fcm')`). */
|
|
58
|
+
log?: (line: string) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A cached OAuth2 access token and the wall-clock ms after which to re-mint. */
|
|
62
|
+
interface CachedAccessToken {
|
|
63
|
+
value: string;
|
|
64
|
+
expiresAt: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createFcmPushNotifier(creds: FcmCredentials, deps: FcmPushDeps = {}): PushNotifier {
|
|
68
|
+
const doFetch: FcmFetch = deps.fetch ?? ((url, init) => fetch(url, init));
|
|
69
|
+
const now = deps.now ?? (() => Date.now());
|
|
70
|
+
const log = deps.log ?? ((line: string) => debugFcm(line));
|
|
71
|
+
const sendUrl = `https://fcm.googleapis.com/v1/projects/${creds.projectId}/messages:send`;
|
|
72
|
+
|
|
73
|
+
let cached: CachedAccessToken | null = null;
|
|
74
|
+
|
|
75
|
+
async function send(msg: PushMessage): Promise<PushSendResult> {
|
|
76
|
+
let token: string;
|
|
77
|
+
try {
|
|
78
|
+
token = await accessToken(false);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
log(`oauth mint failed: ${errText(err)}`);
|
|
81
|
+
return { ok: false, unregistered: false, error: `fcm oauth mint failed: ${errText(err)}` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const first = await postSend(msg, token);
|
|
85
|
+
if (first.status !== 401) return mapResult(first, msg.token);
|
|
86
|
+
|
|
87
|
+
// The cached access token was rejected (expired/revoked mid-flight). Re-mint
|
|
88
|
+
// once and retry the single send; a second failure is returned as-is.
|
|
89
|
+
let retryToken: string;
|
|
90
|
+
try {
|
|
91
|
+
retryToken = await accessToken(true);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
log(`oauth re-mint failed: ${errText(err)}`);
|
|
94
|
+
return { ok: false, unregistered: false, error: `fcm oauth re-mint failed: ${errText(err)}` };
|
|
95
|
+
}
|
|
96
|
+
return mapResult(await postSend(msg, retryToken), msg.token);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function postSend(msg: PushMessage, token: string): Promise<{ status: number; body: string }> {
|
|
100
|
+
const body = JSON.stringify({
|
|
101
|
+
message: {
|
|
102
|
+
token: msg.token,
|
|
103
|
+
data: {
|
|
104
|
+
type: msg.payload.type,
|
|
105
|
+
strandId: msg.payload.strandId,
|
|
106
|
+
reason: msg.payload.reason.slice(0, MAX_REASON_LEN),
|
|
107
|
+
},
|
|
108
|
+
android: { priority: 'high' },
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const res = await doFetch(sendUrl, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
114
|
+
body,
|
|
115
|
+
});
|
|
116
|
+
return { status: res.status, body: await safeText(res) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function mapResult(res: { status: number; body: string }, deviceToken: string): PushSendResult {
|
|
120
|
+
if (res.status === 200) return { ok: true };
|
|
121
|
+
const summary = errorSummary(res.status, res.body);
|
|
122
|
+
if (isUnregistered(res.status, res.body)) {
|
|
123
|
+
log(`unregistered token=${redact(deviceToken)} (${summary})`);
|
|
124
|
+
return { ok: false, unregistered: true, error: summary };
|
|
125
|
+
}
|
|
126
|
+
log(`send failed token=${redact(deviceToken)} (${summary})`);
|
|
127
|
+
return { ok: false, unregistered: false, error: summary };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function accessToken(forceRefresh: boolean): Promise<string> {
|
|
131
|
+
if (!forceRefresh && cached && cached.expiresAt > now()) return cached.value;
|
|
132
|
+
cached = null;
|
|
133
|
+
const minted = await mintAccessToken();
|
|
134
|
+
cached = { value: minted.value, expiresAt: now() + minted.ttlMs - TOKEN_REFRESH_SKEW_MS };
|
|
135
|
+
return minted.value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function mintAccessToken(): Promise<{ value: string; ttlMs: number }> {
|
|
139
|
+
const jwt = signOauthJwt();
|
|
140
|
+
const res = await doFetch(TOKEN_ENDPOINT, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
143
|
+
body: `grant_type=${encodeURIComponent(JWT_BEARER_GRANT)}&assertion=${encodeURIComponent(jwt)}`,
|
|
144
|
+
});
|
|
145
|
+
if (res.status !== 200) {
|
|
146
|
+
throw new Error(`token endpoint ${res.status}: ${await safeText(res)}`);
|
|
147
|
+
}
|
|
148
|
+
const parsed = (await res.json()) as { access_token?: unknown; expires_in?: unknown };
|
|
149
|
+
if (typeof parsed.access_token !== 'string') {
|
|
150
|
+
throw new Error('token endpoint returned no access_token');
|
|
151
|
+
}
|
|
152
|
+
const ttlMs = typeof parsed.expires_in === 'number' ? parsed.expires_in * 1000 : DEFAULT_TOKEN_TTL_MS;
|
|
153
|
+
return { value: parsed.access_token, ttlMs };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function signOauthJwt(): string {
|
|
157
|
+
const iat = Math.floor(now() / 1000);
|
|
158
|
+
const header = { alg: 'RS256', typ: 'JWT' };
|
|
159
|
+
const claims = {
|
|
160
|
+
iss: creds.clientEmail,
|
|
161
|
+
sub: creds.clientEmail,
|
|
162
|
+
scope: FCM_SCOPE,
|
|
163
|
+
aud: TOKEN_ENDPOINT,
|
|
164
|
+
iat,
|
|
165
|
+
exp: iat + 3600,
|
|
166
|
+
};
|
|
167
|
+
const signingInput = `${b64urlJson(header)}.${b64urlJson(claims)}`;
|
|
168
|
+
const sig = sign('RSA-SHA256', Buffer.from(signingInput), creds.privateKey).toString('base64url');
|
|
169
|
+
return `${signingInput}.${sig}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function close(): Promise<void> {
|
|
173
|
+
cached = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { send, close };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether an FCM v1 error response names a permanently-invalid registration
|
|
181
|
+
* token: a 404 (`UNREGISTERED`), or a 400 `INVALID_ARGUMENT` whose message names
|
|
182
|
+
* the registration token. A generic 400 (malformed message) is NOT unregistered.
|
|
183
|
+
*/
|
|
184
|
+
function isUnregistered(status: number, body: string): boolean {
|
|
185
|
+
if (status === 404) return true;
|
|
186
|
+
if (status !== 400) return false;
|
|
187
|
+
const code = fcmErrorCode(body);
|
|
188
|
+
if (code === 'UNREGISTERED') return true;
|
|
189
|
+
if (code === 'INVALID_ARGUMENT' && /registration[ -]token|not a valid fcm registration/i.test(body)) return true;
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Pull the FCM `errorCode` (FcmError detail) or RPC `status` out of the body. */
|
|
194
|
+
function fcmErrorCode(body: string): string | null {
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(body) as {
|
|
197
|
+
error?: { status?: string; details?: Array<{ errorCode?: string }> };
|
|
198
|
+
};
|
|
199
|
+
const err = parsed.error;
|
|
200
|
+
if (!err) return null;
|
|
201
|
+
const detailCode = err.details?.find((d) => typeof d.errorCode === 'string')?.errorCode;
|
|
202
|
+
return detailCode ?? err.status ?? null;
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A secret-free one-line error summary (status + error code, never the token). */
|
|
209
|
+
function errorSummary(status: number, body: string): string {
|
|
210
|
+
const code = fcmErrorCode(body);
|
|
211
|
+
return code ? `fcm ${status} ${code}` : `fcm ${status}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function safeText(res: FcmResponseLike): Promise<string> {
|
|
215
|
+
try {
|
|
216
|
+
return await res.text();
|
|
217
|
+
} catch {
|
|
218
|
+
return '';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* push-notifier-shared.ts — internal helpers shared by the FCM and APNs
|
|
3
|
-
* push-notifier implementations. Server-only (used only by modules that already
|
|
4
|
-
* reach for `node:crypto` / `node:http2`); never re-exported from the package
|
|
5
|
-
* entry. Centralized so the secret-redaction and payload-bounding rules have a
|
|
6
|
-
* single, hardenable source rather than drifting between the two transports.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** Defensive cap on the free-form `reason` carried in a strand-wake payload. */
|
|
10
|
-
export const MAX_REASON_LEN = 256;
|
|
11
|
-
|
|
12
|
-
/** Base64url-encode the JSON form of an object (JWT header/claims segments). */
|
|
13
|
-
export function b64urlJson(obj: unknown): string {
|
|
14
|
-
return Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** A short, secret-free string for an unknown thrown value. */
|
|
18
|
-
export function errText(err: unknown): string {
|
|
19
|
-
return err instanceof Error ? err.message : String(err);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Redact a device token to a short, non-reversible prefix for debug lines — the
|
|
24
|
-
* full FCM/APNs token is a secret and must never reach a log.
|
|
25
|
-
*/
|
|
26
|
-
export function redact(token: string): string {
|
|
27
|
-
return token.length <= 8 ? '…' : `${token.slice(0, 6)}…`;
|
|
28
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* push-notifier-shared.ts — internal helpers shared by the FCM and APNs
|
|
3
|
+
* push-notifier implementations. Server-only (used only by modules that already
|
|
4
|
+
* reach for `node:crypto` / `node:http2`); never re-exported from the package
|
|
5
|
+
* entry. Centralized so the secret-redaction and payload-bounding rules have a
|
|
6
|
+
* single, hardenable source rather than drifting between the two transports.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Defensive cap on the free-form `reason` carried in a strand-wake payload. */
|
|
10
|
+
export const MAX_REASON_LEN = 256;
|
|
11
|
+
|
|
12
|
+
/** Base64url-encode the JSON form of an object (JWT header/claims segments). */
|
|
13
|
+
export function b64urlJson(obj: unknown): string {
|
|
14
|
+
return Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A short, secret-free string for an unknown thrown value. */
|
|
18
|
+
export function errText(err: unknown): string {
|
|
19
|
+
return err instanceof Error ? err.message : String(err);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Redact a device token to a short, non-reversible prefix for debug lines — the
|
|
24
|
+
* full FCM/APNs token is a secret and must never reach a log.
|
|
25
|
+
*/
|
|
26
|
+
export function redact(token: string): string {
|
|
27
|
+
return token.length <= 8 ? '…' : `${token.slice(0, 6)}…`;
|
|
28
|
+
}
|
package/src/push-notifier.ts
CHANGED
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* push-notifier.ts — the platform-push **delivery** contract for strand-wake.
|
|
3
|
-
*
|
|
4
|
-
* This module is the cross-platform-safe *interface* half: the message shape
|
|
5
|
-
* ({@link PushMessage}), the outcome value ({@link PushSendResult}), and the
|
|
6
|
-
* {@link PushNotifier} port a `CadreNode` sends over. It carries ZERO runtime
|
|
7
|
-
* dependencies and imports no implementation — so the RN/browser entry graph can
|
|
8
|
-
* reference the type without ever resolving `node:crypto` / `node:http2`.
|
|
9
|
-
*
|
|
10
|
-
* The concrete FCM/APNs implementations (`push-notifier-fcm.ts` /
|
|
11
|
-
* `push-notifier-apns.ts`) and the `createPushNotifier` router that builds them
|
|
12
|
-
* live behind the Node-only subpath `@serfab/cadre-core/push-node`
|
|
13
|
-
* (`push-node.ts`). A Node host constructs a notifier from that subpath and
|
|
14
|
-
* injects the instance into `CadreNodeConfig.push.notifier`; the cross-platform
|
|
15
|
-
* core never constructs one, so the Node-only builtins stay out of its graph.
|
|
16
|
-
*
|
|
17
|
-
* Keep this file zero-import (beyond erased type-only imports): the whole point
|
|
18
|
-
* of the seam is that referencing the interface can never drag an implementation
|
|
19
|
-
* module into a bundler's graph.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import type { PushPlatform } from './types.js';
|
|
23
|
-
import type { StrandWakePayload } from './strand-wake-payload.js';
|
|
24
|
-
|
|
25
|
-
/** One strand-wake data message addressed to one device. */
|
|
26
|
-
export interface PushMessage {
|
|
27
|
-
/** The resolved `DeviceToken.token` for the target peer. */
|
|
28
|
-
token: string;
|
|
29
|
-
/** Which channel — selects FCM vs APNs. */
|
|
30
|
-
platform: PushPlatform;
|
|
31
|
-
/** The strand-wake payload `{ type:'strand-wake', strandId, reason }`. */
|
|
32
|
-
payload: StrandWakePayload;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Outcome of a single {@link PushNotifier.send}. A delivery failure is a value,
|
|
37
|
-
* never a thrown error. `unregistered: true` means the platform reported the
|
|
38
|
-
* token is permanently invalid (FCM 404 `UNREGISTERED` / 400 naming the token;
|
|
39
|
-
* APNs 410 `Unregistered` / 400 `BadDeviceToken`) — the caller should expire the
|
|
40
|
-
* stale `DeviceToken`. Any other failure is `unregistered: false` and is treated
|
|
41
|
-
* as best-effort/transient (no retry storm).
|
|
42
|
-
*/
|
|
43
|
-
export type PushSendResult =
|
|
44
|
-
| { ok: true }
|
|
45
|
-
| { ok: false; unregistered: boolean; error: string };
|
|
46
|
-
|
|
47
|
-
export interface PushNotifier {
|
|
48
|
-
/** Send one strand-wake data message to one device. Never throws. */
|
|
49
|
-
send(msg: PushMessage): Promise<PushSendResult>;
|
|
50
|
-
/** Release transport resources (e.g. the APNs HTTP/2 session). */
|
|
51
|
-
close(): Promise<void>;
|
|
52
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* push-notifier.ts — the platform-push **delivery** contract for strand-wake.
|
|
3
|
+
*
|
|
4
|
+
* This module is the cross-platform-safe *interface* half: the message shape
|
|
5
|
+
* ({@link PushMessage}), the outcome value ({@link PushSendResult}), and the
|
|
6
|
+
* {@link PushNotifier} port a `CadreNode` sends over. It carries ZERO runtime
|
|
7
|
+
* dependencies and imports no implementation — so the RN/browser entry graph can
|
|
8
|
+
* reference the type without ever resolving `node:crypto` / `node:http2`.
|
|
9
|
+
*
|
|
10
|
+
* The concrete FCM/APNs implementations (`push-notifier-fcm.ts` /
|
|
11
|
+
* `push-notifier-apns.ts`) and the `createPushNotifier` router that builds them
|
|
12
|
+
* live behind the Node-only subpath `@serfab/cadre-core/push-node`
|
|
13
|
+
* (`push-node.ts`). A Node host constructs a notifier from that subpath and
|
|
14
|
+
* injects the instance into `CadreNodeConfig.push.notifier`; the cross-platform
|
|
15
|
+
* core never constructs one, so the Node-only builtins stay out of its graph.
|
|
16
|
+
*
|
|
17
|
+
* Keep this file zero-import (beyond erased type-only imports): the whole point
|
|
18
|
+
* of the seam is that referencing the interface can never drag an implementation
|
|
19
|
+
* module into a bundler's graph.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { PushPlatform } from './types.js';
|
|
23
|
+
import type { StrandWakePayload } from './strand-wake-payload.js';
|
|
24
|
+
|
|
25
|
+
/** One strand-wake data message addressed to one device. */
|
|
26
|
+
export interface PushMessage {
|
|
27
|
+
/** The resolved `DeviceToken.token` for the target peer. */
|
|
28
|
+
token: string;
|
|
29
|
+
/** Which channel — selects FCM vs APNs. */
|
|
30
|
+
platform: PushPlatform;
|
|
31
|
+
/** The strand-wake payload `{ type:'strand-wake', strandId, reason }`. */
|
|
32
|
+
payload: StrandWakePayload;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Outcome of a single {@link PushNotifier.send}. A delivery failure is a value,
|
|
37
|
+
* never a thrown error. `unregistered: true` means the platform reported the
|
|
38
|
+
* token is permanently invalid (FCM 404 `UNREGISTERED` / 400 naming the token;
|
|
39
|
+
* APNs 410 `Unregistered` / 400 `BadDeviceToken`) — the caller should expire the
|
|
40
|
+
* stale `DeviceToken`. Any other failure is `unregistered: false` and is treated
|
|
41
|
+
* as best-effort/transient (no retry storm).
|
|
42
|
+
*/
|
|
43
|
+
export type PushSendResult =
|
|
44
|
+
| { ok: true }
|
|
45
|
+
| { ok: false; unregistered: boolean; error: string };
|
|
46
|
+
|
|
47
|
+
export interface PushNotifier {
|
|
48
|
+
/** Send one strand-wake data message to one device. Never throws. */
|
|
49
|
+
send(msg: PushMessage): Promise<PushSendResult>;
|
|
50
|
+
/** Release transport resources (e.g. the APNs HTTP/2 session). */
|
|
51
|
+
close(): Promise<void>;
|
|
52
|
+
}
|