@freecorps/ember-push 0.1.0 → 0.3.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 +222 -5
- package/dist/apns.d.ts +78 -0
- package/dist/apns.js +284 -0
- package/dist/base64url.d.ts +4 -0
- package/dist/base64url.js +19 -0
- package/dist/component.d.ts +295 -0
- package/dist/component.js +254 -0
- package/dist/delivery.d.ts +47 -0
- package/dist/delivery.js +126 -0
- package/dist/devices.d.ts +38 -2
- package/dist/devices.js +77 -12
- package/dist/direct.d.ts +75 -0
- package/dist/direct.js +150 -0
- package/dist/envelope.d.ts +62 -0
- package/dist/envelope.js +121 -0
- package/dist/expo.d.ts +7 -6
- package/dist/expo.js +22 -2
- package/dist/fcm.d.ts +77 -0
- package/dist/fcm.js +244 -0
- package/dist/hpke.d.ts +37 -0
- package/dist/hpke.js +153 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.js +14 -2
- package/dist/jwt.d.ts +27 -0
- package/dist/jwt.js +77 -0
- package/dist/types.d.ts +92 -5
- package/dist/types.js +0 -4
- package/package.json +13 -9
package/dist/envelope.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Adapted from Freecorps Brigade, packages/push/src/envelope.ts (MIT License, Copyright (c) 2026 Freecorps Tecnologia
|
|
2
|
+
// da Informação LTDA; https://github.com/freecorps/brigade/tree/main/packages/push): the envelope shape (`v`, `k`,
|
|
3
|
+
// `enc`, `ct`, base64url), the key id, the plaintext limit and the validation of untrusted input come from there. The
|
|
4
|
+
// content is Ember's (title/subtitle/body/data instead of Brigade's incident fields) and the HPKE `info` is Ember's own,
|
|
5
|
+
// so a Brigade ciphertext never opens as an Ember push or the other way round.
|
|
6
|
+
import { fromBase64url, toBase64url } from "./base64url.js";
|
|
7
|
+
import { open, P256_PUBLIC_KEY_LENGTH, seal } from "./hpke.js";
|
|
8
|
+
export const EMBER_PUSH_ENVELOPE_VERSION = 1;
|
|
9
|
+
/**
|
|
10
|
+
* HPKE `info`: binds every ciphertext to this protocol and version. The iOS Notification Service Extension and the
|
|
11
|
+
* Android decoder in `@freecorps/ember-expo-push` use the same string. Change it (and `v`) together everywhere.
|
|
12
|
+
*/
|
|
13
|
+
export const EMBER_PUSH_HPKE_INFO = "freecorps.ember push v1";
|
|
14
|
+
/**
|
|
15
|
+
* Plaintext limit in UTF-8 bytes (Brigade's): about 2.8 KB once sealed and base64url-encoded, which leaves room in
|
|
16
|
+
* APNs' and FCM's 4 KB for the fallback text and the other fields.
|
|
17
|
+
*/
|
|
18
|
+
export const PUSH_PLAINTEXT_MAX_BYTES = 2048;
|
|
19
|
+
/** A key id: 8 base64url characters (`pushKeyId`). */
|
|
20
|
+
export const PUSH_KEY_ID = /^[A-Za-z0-9_-]{8}$/;
|
|
21
|
+
const encoder = new TextEncoder();
|
|
22
|
+
const info = encoder.encode(EMBER_PUSH_HPKE_INFO);
|
|
23
|
+
/**
|
|
24
|
+
* Seals `content` for a device, given its public key as the device registered it (base64url of the uncompressed
|
|
25
|
+
* P-256 point, 65 bytes). Throws over `PUSH_PLAINTEXT_MAX_BYTES` or for a malformed key.
|
|
26
|
+
*/
|
|
27
|
+
export async function sealPushContent(devicePublicKey, content, opts = {}) {
|
|
28
|
+
const key = parseDevicePublicKey(devicePublicKey);
|
|
29
|
+
const iat = Math.floor((opts.now ?? Date.now()) / 1000);
|
|
30
|
+
const { title, subtitle, body, data } = content;
|
|
31
|
+
const plaintext = encoder.encode(JSON.stringify({ title, subtitle, body, data, iat }));
|
|
32
|
+
if (plaintext.length > PUSH_PLAINTEXT_MAX_BYTES) {
|
|
33
|
+
throw new Error(`push content is ${plaintext.length} bytes, over ${PUSH_PLAINTEXT_MAX_BYTES}`);
|
|
34
|
+
}
|
|
35
|
+
const sealed = await seal(key, plaintext, { info });
|
|
36
|
+
return { v: EMBER_PUSH_ENVELOPE_VERSION, k: await pushKeyId(key), enc: toBase64url(sealed.enc), ct: toBase64url(sealed.ct) };
|
|
37
|
+
}
|
|
38
|
+
/** Opens an envelope (what the device does; for tests and JS clients with WebCrypto). Throws if it does not open. */
|
|
39
|
+
export async function openPushEnvelope(recipient, envelope) {
|
|
40
|
+
const plaintext = await open(recipient, { enc: fromBase64url(envelope.enc), ct: fromBase64url(envelope.ct) }, { info });
|
|
41
|
+
return parseOpenedContent(JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(plaintext)));
|
|
42
|
+
}
|
|
43
|
+
/** The plaintext of an envelope as a device accepts it; throws for anything else. */
|
|
44
|
+
export function parseOpenedContent(value) {
|
|
45
|
+
if (!isRecord(value) || !Number.isSafeInteger(value.iat))
|
|
46
|
+
throw new Error("push content has no iat");
|
|
47
|
+
const out = { iat: value.iat };
|
|
48
|
+
for (const f of ["title", "subtitle", "body"]) {
|
|
49
|
+
const s = value[f];
|
|
50
|
+
if (s === undefined || s === null)
|
|
51
|
+
continue;
|
|
52
|
+
if (typeof s !== "string")
|
|
53
|
+
throw new Error(`push content ${f} is not a string`);
|
|
54
|
+
out[f] = s;
|
|
55
|
+
}
|
|
56
|
+
if (value.data !== undefined && value.data !== null) {
|
|
57
|
+
if (!isRecord(value.data))
|
|
58
|
+
throw new Error("push content data is not an object");
|
|
59
|
+
out.data = value.data;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The id of a device key, as an envelope names it (`k`): base64url of the first 6 bytes of SHA-256 over the
|
|
65
|
+
* uncompressed public key, 8 characters (Brigade's `pushKeyId`). Only a hint for which key to try; the AEAD decides.
|
|
66
|
+
*/
|
|
67
|
+
export async function pushKeyId(publicKey) {
|
|
68
|
+
const key = typeof publicKey === "string" ? parseDevicePublicKey(publicKey) : publicKey;
|
|
69
|
+
if (key.length !== P256_PUBLIC_KEY_LENGTH || key[0] !== 0x04)
|
|
70
|
+
throw new Error("device public key must be an uncompressed P-256 point");
|
|
71
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new Uint8Array(key)));
|
|
72
|
+
return toBase64url(digest.subarray(0, 6));
|
|
73
|
+
}
|
|
74
|
+
/** Accepts an envelope from untrusted JSON (or the JSON string FCM data carries), or returns null. */
|
|
75
|
+
export function parsePushEnvelope(value) {
|
|
76
|
+
if (typeof value === "string") {
|
|
77
|
+
try {
|
|
78
|
+
value = JSON.parse(value);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!isRecord(value) || value.v !== EMBER_PUSH_ENVELOPE_VERSION)
|
|
85
|
+
return null;
|
|
86
|
+
const { k, enc, ct } = value;
|
|
87
|
+
if (typeof k !== "string" || !PUSH_KEY_ID.test(k) || typeof enc !== "string" || typeof ct !== "string")
|
|
88
|
+
return null;
|
|
89
|
+
try {
|
|
90
|
+
// 16 bytes is the GCM tag alone: an empty plaintext.
|
|
91
|
+
if (fromBase64url(enc).length !== P256_PUBLIC_KEY_LENGTH || fromBase64url(ct).length < 16)
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return { v: EMBER_PUSH_ENVELOPE_VERSION, k, enc, ct };
|
|
98
|
+
}
|
|
99
|
+
/** A device public key from its base64url form; throws unless it is an uncompressed P-256 point. */
|
|
100
|
+
export function parseDevicePublicKey(value) {
|
|
101
|
+
const key = fromBase64url(value);
|
|
102
|
+
if (key.length !== P256_PUBLIC_KEY_LENGTH || key[0] !== 0x04) {
|
|
103
|
+
throw new Error("device public key must be an uncompressed P-256 point (65 bytes, base64url)");
|
|
104
|
+
}
|
|
105
|
+
return key;
|
|
106
|
+
}
|
|
107
|
+
/** Cheap shape check for registration (the curve check happens when sealing). */
|
|
108
|
+
export function isDevicePublicKey(value) {
|
|
109
|
+
if (typeof value !== "string")
|
|
110
|
+
return false;
|
|
111
|
+
try {
|
|
112
|
+
parseDevicePublicKey(value);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function isRecord(value) {
|
|
120
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
121
|
+
}
|
package/dist/expo.d.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Expo Push Service client (plain HTTPS + JSON, so it runs in a Worker action). Facts (docs.expo.dev, checked in
|
|
3
|
-
* CLOUDFLARE.md §8): 100 messages per request, 600 notifications/s per project, 4096-byte payload, receipts kept
|
|
4
|
-
* 24 h, optional access token ("enhanced push security").
|
|
5
|
-
*/
|
|
6
1
|
import type { AddressedPushMessage, PushMessage, PushProvider, PushTicket, ReceiptCheck, SendPushResult } from "./types.ts";
|
|
7
2
|
export declare const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
|
|
8
3
|
export declare const EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
|
|
@@ -31,7 +26,13 @@ export interface ExpoPushOptions {
|
|
|
31
26
|
}
|
|
32
27
|
/** `ExponentPushToken[...]` / `ExpoPushToken[...]` (or the older bare UUID form). */
|
|
33
28
|
export declare function isExpoPushToken(token: unknown): token is string;
|
|
34
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* The JSON Expo expects for one message. `collapseId`, `threadId` and `interruptionLevel` are Expo fields too and go
|
|
31
|
+
* as they are; `collapseId` is also Expo's Android `tag` (unless one is set) so an Android notification already shown
|
|
32
|
+
* is replaced, as with direct FCM. Fields only the direct transports understand (`urgency`, `encryption`, `voip`,
|
|
33
|
+
* `environment`, `sealed`) are not sent; `urgency` becomes Expo's `priority` when that is not set (`low` → `normal`,
|
|
34
|
+
* else `high`).
|
|
35
|
+
*/
|
|
35
36
|
export declare function toExpoMessage(m: AddressedPushMessage): Record<string, unknown>;
|
|
36
37
|
/** Payload size Expo checks against 4096 B: the notification fields plus `data`. */
|
|
37
38
|
export declare function payloadSize(m: PushMessage): number;
|
package/dist/expo.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expo Push Service client (plain HTTPS + JSON, so it runs in a Worker action). Facts (docs.expo.dev, checked in
|
|
3
|
+
* CLOUDFLARE.md §8): 100 messages per request, 600 notifications/s per project, 4096-byte payload, receipts kept
|
|
4
|
+
* 24 h, optional access token ("enhanced push security").
|
|
5
|
+
*/
|
|
6
|
+
import { soundName } from "./delivery.js";
|
|
1
7
|
export const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
|
|
2
8
|
export const EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
|
|
3
9
|
/** Messages per `send` request. */
|
|
@@ -13,15 +19,29 @@ export function isExpoPushToken(token) {
|
|
|
13
19
|
return (/^Expo(?:nent)?PushToken\[[^\]]+\]$/.test(token) ||
|
|
14
20
|
/^[a-z\d]{8}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{12}$/i.test(token));
|
|
15
21
|
}
|
|
16
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* The JSON Expo expects for one message. `collapseId`, `threadId` and `interruptionLevel` are Expo fields too and go
|
|
24
|
+
* as they are; `collapseId` is also Expo's Android `tag` (unless one is set) so an Android notification already shown
|
|
25
|
+
* is replaced, as with direct FCM. Fields only the direct transports understand (`urgency`, `encryption`, `voip`,
|
|
26
|
+
* `environment`, `sealed`) are not sent; `urgency` becomes Expo's `priority` when that is not set (`low` → `normal`,
|
|
27
|
+
* else `high`).
|
|
28
|
+
*/
|
|
17
29
|
export function toExpoMessage(m) {
|
|
18
|
-
const { contentAvailable, ...rest } = m;
|
|
30
|
+
const { contentAvailable, urgency, encryption, voip, environment, sealed, payload, ...rest } = m;
|
|
31
|
+
void payload; // the app's own APNs/FCM payload: Expo builds its own
|
|
19
32
|
const out = {};
|
|
20
33
|
for (const [k, val] of Object.entries(rest))
|
|
21
34
|
if (val !== undefined)
|
|
22
35
|
out[k] = val;
|
|
36
|
+
// Expo takes a sound name: a Critical Alert sound plays its name there.
|
|
37
|
+
if (typeof out.sound === "object" && out.sound !== null)
|
|
38
|
+
out.sound = soundName(out.sound) ?? "default";
|
|
23
39
|
if (contentAvailable)
|
|
24
40
|
out._contentAvailable = true;
|
|
41
|
+
if (urgency && out.priority === undefined)
|
|
42
|
+
out.priority = urgency === "low" ? "normal" : "high";
|
|
43
|
+
if (typeof out.collapseId === "string" && out.tag === undefined)
|
|
44
|
+
out.tag = out.collapseId;
|
|
25
45
|
return out;
|
|
26
46
|
}
|
|
27
47
|
const utf8Length = (s) => new TextEncoder().encode(s).length;
|
package/dist/fcm.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct FCM sender (HTTP v1 API), written for Ember from Google's documentation ("Authorize send requests",
|
|
3
|
+
* "Using OAuth 2.0 for Server to Server Applications", "FCM HTTP v1 API" and its `FcmError` codes). Behaviour matches
|
|
4
|
+
* Freecorps Brigade's relay as its README describes it (access token kept until 5 minutes before it expires and
|
|
5
|
+
* refetched on a 401; `UNREGISTERED` drops the device, `SENDER_ID_MISMATCH` does not; data-only messages for sealed
|
|
6
|
+
* pushes); no code is taken from the relay (AGPL).
|
|
7
|
+
*
|
|
8
|
+
* FCM speaks HTTP/1.1, so unlike APNs this also works from local workerd (`ember dev`).
|
|
9
|
+
*/
|
|
10
|
+
import { type RetryOptions } from "./delivery.ts";
|
|
11
|
+
import type { AddressedPushMessage, PushErrorCode, PushFallback, PushProvider, PushTicket } from "./types.ts";
|
|
12
|
+
export declare const FCM_SEND_URL: (projectId: string) => string;
|
|
13
|
+
export declare const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
14
|
+
export declare const FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging";
|
|
15
|
+
/** FCM's own limits: 4096 bytes of message payload, TTL at most 28 days. */
|
|
16
|
+
export declare const FCM_MAX_PAYLOAD = 4096;
|
|
17
|
+
export declare const FCM_MAX_TTL_SECONDS: number;
|
|
18
|
+
/** An access token is refetched this long before it expires. */
|
|
19
|
+
export declare const FCM_TOKEN_MARGIN_MS: number;
|
|
20
|
+
/**
|
|
21
|
+
* The longest a token exchange may take, retries included. It is shared by every send waiting for it, so it has its
|
|
22
|
+
* own limit, never a caller's `timeoutMs` or `signal` (a caller with a short deadline only stops its own wait).
|
|
23
|
+
*/
|
|
24
|
+
export declare const FCM_EXCHANGE_TIMEOUT_MS = 30000;
|
|
25
|
+
/** The fields of a Google service-account JSON key that sending needs. */
|
|
26
|
+
export interface ServiceAccount {
|
|
27
|
+
project_id: string;
|
|
28
|
+
client_email: string;
|
|
29
|
+
private_key: string;
|
|
30
|
+
private_key_id?: string;
|
|
31
|
+
token_uri?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface FcmOptions extends RetryOptions {
|
|
34
|
+
/** The service account's JSON key: the file's text (as kept in a secret) or the parsed object. */
|
|
35
|
+
serviceAccount: string | ServiceAccount;
|
|
36
|
+
/** Endpoint overrides (tests, proxies). The token URL defaults to the key's `token_uri`. */
|
|
37
|
+
sendUrl?: string;
|
|
38
|
+
tokenUrl?: string;
|
|
39
|
+
/** Text shown for sealed pushes when the app cannot decrypt (sent in clear as `fallbackTitle`/`fallbackBody`). */
|
|
40
|
+
fallback?: PushFallback;
|
|
41
|
+
/** Requests in flight at once (default 10). */
|
|
42
|
+
concurrency?: number;
|
|
43
|
+
fetch?: typeof fetch;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
/** Parses and checks a service-account key; throws a message naming what is missing (never echoing the key). */
|
|
47
|
+
export declare function parseServiceAccount(value: string | ServiceAccount): ServiceAccount;
|
|
48
|
+
/** A registration token as `getDevicePushTokenAsync` returns it on Android. */
|
|
49
|
+
export declare function isFcmToken(token: unknown): token is string;
|
|
50
|
+
/** FCM's error codes (`google.firebase.fcm.v1.FcmError`, falling back to the HTTP status) → Ember's codes. */
|
|
51
|
+
export declare function fcmErrorCode(status: number, errorCode: string | undefined): PushErrorCode;
|
|
52
|
+
/** Forgets cached access tokens (tests; after rotating a key). */
|
|
53
|
+
export declare function clearFcmTokenCache(): void;
|
|
54
|
+
/**
|
|
55
|
+
* An OAuth access token for the service account (scope `firebase.messaging`): an RS256 assertion exchanged at the
|
|
56
|
+
* token endpoint, cached per isolate until `FCM_TOKEN_MARGIN_MS` before it expires. `force` exchanges again: `true`
|
|
57
|
+
* always; a token string only if the cache still holds that token (after FCM answered 401 to it, so concurrent sends
|
|
58
|
+
* that saw the same refused token exchange once, and a token another send already replaced is used as is).
|
|
59
|
+
* Concurrent callers share one exchange. `signal` (default: `o.signal` and `o.timeoutMs`) bounds this caller's wait
|
|
60
|
+
* only; the shared exchange runs under its own `FCM_EXCHANGE_TIMEOUT_MS`, whoever started it.
|
|
61
|
+
*/
|
|
62
|
+
export declare function fcmAccessToken(o: FcmOptions, force?: boolean | string, signal?: AbortSignal | undefined): Promise<string>;
|
|
63
|
+
/**
|
|
64
|
+
* The v1 `message` for one push. In clear: a `notification` (title/body; the system shows it while the app is in the
|
|
65
|
+
* background) and `data.body` = JSON of `data`, where expo-notifications reads it on Android. Sealed: a data-only
|
|
66
|
+
* message, `{ ember: <envelope JSON>, fallbackTitle, fallbackBody, channelId?, tag? }`, which the app's background
|
|
67
|
+
* task decrypts and shows (`@freecorps/ember-expo-push`). Background (`contentAvailable` with nothing to show): data only.
|
|
68
|
+
*/
|
|
69
|
+
export declare function fcmMessage(m: AddressedPushMessage, fallback?: PushFallback): Record<string, unknown>;
|
|
70
|
+
/**
|
|
71
|
+
* Sends each message as its own v1 request, `concurrency` at a time. Never throws for message failures: they come
|
|
72
|
+
* back as error tickets (`DeviceNotRegistered` for `UNREGISTERED`, which callers prune). A broken service account
|
|
73
|
+
* fails every message with `InvalidCredentials`.
|
|
74
|
+
*/
|
|
75
|
+
export declare function sendFcm(messages: AddressedPushMessage[], o: FcmOptions): Promise<PushTicket[]>;
|
|
76
|
+
/** Direct FCM as a `PushProvider`. No receipts: FCM answers each request synchronously. */
|
|
77
|
+
export declare function fcmPushProvider(o: FcmOptions): PushProvider;
|
package/dist/fcm.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct FCM sender (HTTP v1 API), written for Ember from Google's documentation ("Authorize send requests",
|
|
3
|
+
* "Using OAuth 2.0 for Server to Server Applications", "FCM HTTP v1 API" and its `FcmError` codes). Behaviour matches
|
|
4
|
+
* Freecorps Brigade's relay as its README describes it (access token kept until 5 minutes before it expires and
|
|
5
|
+
* refetched on a 401; `UNREGISTERED` drops the device, `SENDER_ID_MISMATCH` does not; data-only messages for sealed
|
|
6
|
+
* pushes); no code is taken from the relay (AGPL).
|
|
7
|
+
*
|
|
8
|
+
* FCM speaks HTTP/1.1, so unlike APNs this also works from local workerd (`ember dev`).
|
|
9
|
+
*/
|
|
10
|
+
import { DEFAULT_PUSH_FALLBACK, byteLength, deadlineSignal, soundName, fetchWithRetry, mapLimit, resolveTtlSeconds, resolveUrgency, untilAborted } from "./delivery.js";
|
|
11
|
+
import { isExpoPushToken } from "./expo.js";
|
|
12
|
+
import { importSigningKey, signJwt } from "./jwt.js";
|
|
13
|
+
export const FCM_SEND_URL = (projectId) => `https://fcm.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/messages:send`;
|
|
14
|
+
export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
15
|
+
export const FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging";
|
|
16
|
+
/** FCM's own limits: 4096 bytes of message payload, TTL at most 28 days. */
|
|
17
|
+
export const FCM_MAX_PAYLOAD = 4096;
|
|
18
|
+
export const FCM_MAX_TTL_SECONDS = 28 * 24 * 3600;
|
|
19
|
+
/** An access token is refetched this long before it expires. */
|
|
20
|
+
export const FCM_TOKEN_MARGIN_MS = 5 * 60_000;
|
|
21
|
+
/**
|
|
22
|
+
* The longest a token exchange may take, retries included. It is shared by every send waiting for it, so it has its
|
|
23
|
+
* own limit, never a caller's `timeoutMs` or `signal` (a caller with a short deadline only stops its own wait).
|
|
24
|
+
*/
|
|
25
|
+
export const FCM_EXCHANGE_TIMEOUT_MS = 30_000;
|
|
26
|
+
/** Parses and checks a service-account key; throws a message naming what is missing (never echoing the key). */
|
|
27
|
+
export function parseServiceAccount(value) {
|
|
28
|
+
let sa = value;
|
|
29
|
+
if (typeof value === "string") {
|
|
30
|
+
try {
|
|
31
|
+
sa = JSON.parse(value);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new Error("FCM service account is not valid JSON (expected the key file's contents)");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const o = sa;
|
|
38
|
+
const missing = ["project_id", "client_email", "private_key"].filter((k) => typeof o?.[k] !== "string" || !o[k]);
|
|
39
|
+
if (missing.length)
|
|
40
|
+
throw new Error(`FCM service account is missing ${missing.join(", ")}`);
|
|
41
|
+
return o;
|
|
42
|
+
}
|
|
43
|
+
/** A registration token as `getDevicePushTokenAsync` returns it on Android. */
|
|
44
|
+
export function isFcmToken(token) {
|
|
45
|
+
return typeof token === "string" && /^[\w\-:.]{20,4096}$/.test(token) && !isExpoPushToken(token);
|
|
46
|
+
}
|
|
47
|
+
/** FCM's error codes (`google.firebase.fcm.v1.FcmError`, falling back to the HTTP status) → Ember's codes. */
|
|
48
|
+
export function fcmErrorCode(status, errorCode) {
|
|
49
|
+
switch (errorCode) {
|
|
50
|
+
case "UNREGISTERED":
|
|
51
|
+
return "DeviceNotRegistered";
|
|
52
|
+
case "SENDER_ID_MISMATCH":
|
|
53
|
+
return "MismatchSenderId";
|
|
54
|
+
case "QUOTA_EXCEEDED":
|
|
55
|
+
return "MessageRateExceeded";
|
|
56
|
+
case "THIRD_PARTY_AUTH_ERROR":
|
|
57
|
+
return "InvalidCredentials";
|
|
58
|
+
case "INVALID_ARGUMENT":
|
|
59
|
+
// Also a malformed token, but just as often a bad payload: never prune on it.
|
|
60
|
+
return "InvalidArgument";
|
|
61
|
+
}
|
|
62
|
+
if (status === 401 || status === 403)
|
|
63
|
+
return "InvalidCredentials";
|
|
64
|
+
if (status === 429)
|
|
65
|
+
return "MessageRateExceeded";
|
|
66
|
+
return "RequestFailed";
|
|
67
|
+
}
|
|
68
|
+
const accessTokens = new Map();
|
|
69
|
+
/** Forgets cached access tokens (tests; after rotating a key). */
|
|
70
|
+
export function clearFcmTokenCache() {
|
|
71
|
+
accessTokens.clear();
|
|
72
|
+
inFlight.clear();
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* An OAuth access token for the service account (scope `firebase.messaging`): an RS256 assertion exchanged at the
|
|
76
|
+
* token endpoint, cached per isolate until `FCM_TOKEN_MARGIN_MS` before it expires. `force` exchanges again: `true`
|
|
77
|
+
* always; a token string only if the cache still holds that token (after FCM answered 401 to it, so concurrent sends
|
|
78
|
+
* that saw the same refused token exchange once, and a token another send already replaced is used as is).
|
|
79
|
+
* Concurrent callers share one exchange. `signal` (default: `o.signal` and `o.timeoutMs`) bounds this caller's wait
|
|
80
|
+
* only; the shared exchange runs under its own `FCM_EXCHANGE_TIMEOUT_MS`, whoever started it.
|
|
81
|
+
*/
|
|
82
|
+
export async function fcmAccessToken(o, force = false, signal = deadlineSignal(o)) {
|
|
83
|
+
const sa = parseServiceAccount(o.serviceAccount);
|
|
84
|
+
const now = (o.now ?? Date.now)();
|
|
85
|
+
const tokenUrl = o.tokenUrl ?? sa.token_uri ?? GOOGLE_TOKEN_URL;
|
|
86
|
+
const cacheKey = `${sa.client_email}\u0000${sa.private_key_id ?? ""}\u0000${tokenUrl}`;
|
|
87
|
+
const hit = accessTokens.get(cacheKey);
|
|
88
|
+
const fresh = !!hit && now < hit.expiresAt - FCM_TOKEN_MARGIN_MS;
|
|
89
|
+
const refresh = force === true || (typeof force === "string" && hit?.token === force);
|
|
90
|
+
if (fresh && !refresh)
|
|
91
|
+
return hit.token;
|
|
92
|
+
// Concurrent sends share one exchange instead of each fetching a token.
|
|
93
|
+
let p = inFlight.get(cacheKey);
|
|
94
|
+
if (!p) {
|
|
95
|
+
// Never the starter's deadline: other sends wait on this exchange too.
|
|
96
|
+
const started = exchange(o, sa, tokenUrl, now, AbortSignal.timeout(FCM_EXCHANGE_TIMEOUT_MS)).then((t) => {
|
|
97
|
+
accessTokens.set(cacheKey, t);
|
|
98
|
+
return t.token;
|
|
99
|
+
});
|
|
100
|
+
p = started;
|
|
101
|
+
inFlight.set(cacheKey, started);
|
|
102
|
+
started.then(() => inFlight.delete(cacheKey), () => inFlight.delete(cacheKey));
|
|
103
|
+
}
|
|
104
|
+
return untilAborted(p, signal);
|
|
105
|
+
}
|
|
106
|
+
const inFlight = new Map();
|
|
107
|
+
async function exchange(o, sa, tokenUrl, now, signal) {
|
|
108
|
+
const key = await importSigningKey(sa.private_key, "RS256");
|
|
109
|
+
const iat = Math.floor(now / 1000);
|
|
110
|
+
const assertion = await signJwt("RS256", key, { iss: sa.client_email, scope: FCM_SCOPE, aud: tokenUrl, iat, exp: iat + 3600 }, sa.private_key_id ? { kid: sa.private_key_id } : {});
|
|
111
|
+
const fetchFn = (o.fetch ?? globalThis.fetch).bind(globalThis);
|
|
112
|
+
const res = await fetchWithRetry(fetchFn, tokenUrl, () => ({
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
115
|
+
body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }).toString(),
|
|
116
|
+
}), o, signal);
|
|
117
|
+
const body = (await res.json().catch(() => ({})));
|
|
118
|
+
if (!res.ok || !body.access_token) {
|
|
119
|
+
throw Object.assign(new Error(`Google token endpoint: ${res.status} ${body.error ?? ""} ${body.error_description ?? ""}`.trim()), { credentials: res.status === 400 || res.status === 401 });
|
|
120
|
+
}
|
|
121
|
+
return { token: body.access_token, expiresAt: now + (body.expires_in ?? 3600) * 1000 };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The v1 `message` for one push. In clear: a `notification` (title/body; the system shows it while the app is in the
|
|
125
|
+
* background) and `data.body` = JSON of `data`, where expo-notifications reads it on Android. Sealed: a data-only
|
|
126
|
+
* message, `{ ember: <envelope JSON>, fallbackTitle, fallbackBody, channelId?, tag? }`, which the app's background
|
|
127
|
+
* task decrypts and shows (`@freecorps/ember-expo-push`). Background (`contentAvailable` with nothing to show): data only.
|
|
128
|
+
*/
|
|
129
|
+
export function fcmMessage(m, fallback = DEFAULT_PUSH_FALLBACK) {
|
|
130
|
+
const urgency = resolveUrgency(m);
|
|
131
|
+
const ttl = Math.min(FCM_MAX_TTL_SECONDS, resolveTtlSeconds(m));
|
|
132
|
+
const android = { priority: urgency === "low" ? "NORMAL" : "HIGH", ttl: `${ttl}s` };
|
|
133
|
+
if (m.collapseId)
|
|
134
|
+
android.collapse_key = m.collapseId;
|
|
135
|
+
const message = { token: m.to, android };
|
|
136
|
+
// The app built its own data message (its envelope, its keys): sent as given, with Ember's priority, TTL and collapse key.
|
|
137
|
+
if (m.payload?.fcmData) {
|
|
138
|
+
message.data = m.payload.fcmData;
|
|
139
|
+
return message;
|
|
140
|
+
}
|
|
141
|
+
if (m.sealed) {
|
|
142
|
+
const data = { ember: JSON.stringify(m.sealed), urgency };
|
|
143
|
+
if (fallback.title)
|
|
144
|
+
data.fallbackTitle = fallback.title;
|
|
145
|
+
if (fallback.body)
|
|
146
|
+
data.fallbackBody = fallback.body;
|
|
147
|
+
if (m.channelId)
|
|
148
|
+
data.channelId = m.channelId;
|
|
149
|
+
if (m.collapseId)
|
|
150
|
+
data.tag = m.collapseId;
|
|
151
|
+
message.data = data;
|
|
152
|
+
return message;
|
|
153
|
+
}
|
|
154
|
+
if (m.title || m.body) {
|
|
155
|
+
const notification = {};
|
|
156
|
+
if (m.title)
|
|
157
|
+
notification.title = m.title;
|
|
158
|
+
if (m.body)
|
|
159
|
+
notification.body = m.body;
|
|
160
|
+
message.notification = notification;
|
|
161
|
+
const an = {};
|
|
162
|
+
if (m.channelId)
|
|
163
|
+
an.channel_id = m.channelId;
|
|
164
|
+
const sound = soundName(m.sound);
|
|
165
|
+
if (sound)
|
|
166
|
+
an.sound = sound;
|
|
167
|
+
if (m.collapseId)
|
|
168
|
+
an.tag = m.collapseId;
|
|
169
|
+
if (m.badge !== undefined)
|
|
170
|
+
an.notification_count = m.badge;
|
|
171
|
+
if (Object.keys(an).length)
|
|
172
|
+
android.notification = an;
|
|
173
|
+
}
|
|
174
|
+
if (m.data)
|
|
175
|
+
message.data = { body: JSON.stringify(m.data) };
|
|
176
|
+
return message;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Sends each message as its own v1 request, `concurrency` at a time. Never throws for message failures: they come
|
|
180
|
+
* back as error tickets (`DeviceNotRegistered` for `UNREGISTERED`, which callers prune). A broken service account
|
|
181
|
+
* fails every message with `InvalidCredentials`.
|
|
182
|
+
*/
|
|
183
|
+
export async function sendFcm(messages, o) {
|
|
184
|
+
const fetchFn = (o.fetch ?? globalThis.fetch)?.bind(globalThis);
|
|
185
|
+
if (!fetchFn)
|
|
186
|
+
throw new Error("@freecorps/ember-push: no fetch available; pass options.fetch");
|
|
187
|
+
let sa;
|
|
188
|
+
try {
|
|
189
|
+
sa = parseServiceAccount(o.serviceAccount);
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
return messages.map((m) => ({ token: String(m.to), status: "error", error: "InvalidCredentials", message: e.message }));
|
|
193
|
+
}
|
|
194
|
+
const url = o.sendUrl ?? FCM_SEND_URL(sa.project_id);
|
|
195
|
+
const sendOne = async (m) => {
|
|
196
|
+
if (!isFcmToken(m.to))
|
|
197
|
+
return { token: String(m.to), status: "error", error: "InvalidToken", message: "not an FCM registration token" };
|
|
198
|
+
const own = m.payload?.fcmData;
|
|
199
|
+
if (own && Object.values(own).some((x) => typeof x !== "string")) {
|
|
200
|
+
return { token: m.to, status: "error", error: "InvalidArgument", message: "payload.fcmData values must be strings (FCM data messages)" };
|
|
201
|
+
}
|
|
202
|
+
const message = fcmMessage(m, o.fallback);
|
|
203
|
+
const body = JSON.stringify({ message });
|
|
204
|
+
if (byteLength(JSON.stringify({ n: message.notification, d: message.data })) > FCM_MAX_PAYLOAD) {
|
|
205
|
+
return { token: m.to, status: "error", error: "MessageTooBig", message: `FCM payload over ${FCM_MAX_PAYLOAD} bytes` };
|
|
206
|
+
}
|
|
207
|
+
// One deadline for the whole send: the access token and every try.
|
|
208
|
+
const signal = deadlineSignal(o);
|
|
209
|
+
let refused;
|
|
210
|
+
try {
|
|
211
|
+
for (;;) {
|
|
212
|
+
const access = await fcmAccessToken(o, refused ?? false, signal);
|
|
213
|
+
const res = await fetchWithRetry(fetchFn, url, () => ({ method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${access}` }, body }), o, signal);
|
|
214
|
+
const json = (await res.json().catch(() => ({})));
|
|
215
|
+
if (res.ok)
|
|
216
|
+
return { token: m.to, status: "ok", id: json.name ?? "" };
|
|
217
|
+
if (res.status === 401 && refused === undefined) {
|
|
218
|
+
// A new token, unless another send already replaced this one; never this refused one again.
|
|
219
|
+
refused = access;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const errorCode = json.error?.details?.find((d) => d.errorCode)?.errorCode ?? json.error?.status;
|
|
223
|
+
return {
|
|
224
|
+
token: m.to,
|
|
225
|
+
status: "error",
|
|
226
|
+
error: fcmErrorCode(res.status, errorCode),
|
|
227
|
+
message: `FCM ${res.status} ${errorCode ?? ""}: ${json.error?.message ?? ""}`.trim(),
|
|
228
|
+
...(errorCode ? { reason: errorCode } : {}),
|
|
229
|
+
httpStatus: res.status,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
const err = e;
|
|
235
|
+
const message = err.message.startsWith("Google") ? err.message : err.credentials ? `FCM service account key: ${err.message}` : `FCM unreachable: ${err.message}`;
|
|
236
|
+
return { token: m.to, status: "error", error: err.credentials ? "InvalidCredentials" : "RequestFailed", message };
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
return mapLimit(messages, o.concurrency ?? 10, sendOne);
|
|
240
|
+
}
|
|
241
|
+
/** Direct FCM as a `PushProvider`. No receipts: FCM answers each request synchronously. */
|
|
242
|
+
export function fcmPushProvider(o) {
|
|
243
|
+
return { name: "fcm", isToken: isFcmToken, send: (messages) => sendFcm(messages, o) };
|
|
244
|
+
}
|
package/dist/hpke.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
type Bytes = Uint8Array<ArrayBuffer>;
|
|
2
|
+
/** An uncompressed P-256 point (0x04 || x || y): the encapsulated key and every public key. */
|
|
3
|
+
export declare const P256_PUBLIC_KEY_LENGTH = 65;
|
|
4
|
+
export interface HpkeSealed {
|
|
5
|
+
/** The sender's ephemeral public key (65 bytes). */
|
|
6
|
+
enc: Bytes;
|
|
7
|
+
/** Ciphertext followed by the 16-byte GCM tag. */
|
|
8
|
+
ct: Bytes;
|
|
9
|
+
}
|
|
10
|
+
export interface HpkeRecipient {
|
|
11
|
+
privateKey: CryptoKey;
|
|
12
|
+
/** The recipient's public key, uncompressed (65 bytes); part of the KEM context. */
|
|
13
|
+
publicKey: Uint8Array;
|
|
14
|
+
}
|
|
15
|
+
export interface HpkeOptions {
|
|
16
|
+
info?: Uint8Array;
|
|
17
|
+
aad?: Uint8Array;
|
|
18
|
+
}
|
|
19
|
+
export interface SealOptions extends HpkeOptions {
|
|
20
|
+
/** Test vectors only: a fixed ephemeral key pair instead of a fresh one. Never reuse one in real use. */
|
|
21
|
+
ephemeral?: CryptoKeyPair;
|
|
22
|
+
}
|
|
23
|
+
/** Encrypts `plaintext` to the holder of the private key for `recipientPublicKey` (uncompressed P-256). */
|
|
24
|
+
export declare function seal(recipientPublicKey: Uint8Array, plaintext: Uint8Array, options?: SealOptions): Promise<HpkeSealed>;
|
|
25
|
+
/** Decrypts what `seal` produced. Throws if the key, `info`, `aad` or any byte does not match. */
|
|
26
|
+
export declare function open(recipient: HpkeRecipient, sealed: {
|
|
27
|
+
enc: Uint8Array;
|
|
28
|
+
ct: Uint8Array;
|
|
29
|
+
}, options?: HpkeOptions): Promise<Bytes>;
|
|
30
|
+
/** A fresh recipient key pair. The private key is not extractable. */
|
|
31
|
+
export declare function generateRecipient(): Promise<HpkeRecipient>;
|
|
32
|
+
/**
|
|
33
|
+
* Imports a P-256 key pair from its raw private scalar (32 bytes) and uncompressed public key, the form CryptoKit
|
|
34
|
+
* (`rawRepresentation`, `x963Representation`) and the RFC 9180 test vectors use.
|
|
35
|
+
*/
|
|
36
|
+
export declare function importKeyPair(privateKey: Uint8Array, publicKey: Uint8Array): Promise<CryptoKeyPair>;
|
|
37
|
+
export {};
|