@freecorps/ember-push 0.1.0 → 0.2.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/dist/fcm.d.ts ADDED
@@ -0,0 +1,68 @@
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
+ /** The fields of a Google service-account JSON key that sending needs. */
21
+ export interface ServiceAccount {
22
+ project_id: string;
23
+ client_email: string;
24
+ private_key: string;
25
+ private_key_id?: string;
26
+ token_uri?: string;
27
+ }
28
+ export interface FcmOptions extends RetryOptions {
29
+ /** The service account's JSON key: the file's text (as kept in a secret) or the parsed object. */
30
+ serviceAccount: string | ServiceAccount;
31
+ /** Endpoint overrides (tests, proxies). The token URL defaults to the key's `token_uri`. */
32
+ sendUrl?: string;
33
+ tokenUrl?: string;
34
+ /** Text shown for sealed pushes when the app cannot decrypt (sent in clear as `fallbackTitle`/`fallbackBody`). */
35
+ fallback?: PushFallback;
36
+ /** Requests in flight at once (default 10). */
37
+ concurrency?: number;
38
+ fetch?: typeof fetch;
39
+ now?: () => number;
40
+ }
41
+ /** Parses and checks a service-account key; throws a message naming what is missing (never echoing the key). */
42
+ export declare function parseServiceAccount(value: string | ServiceAccount): ServiceAccount;
43
+ /** A registration token as `getDevicePushTokenAsync` returns it on Android. */
44
+ export declare function isFcmToken(token: unknown): token is string;
45
+ /** FCM's error codes (`google.firebase.fcm.v1.FcmError`, falling back to the HTTP status) → Ember's codes. */
46
+ export declare function fcmErrorCode(status: number, errorCode: string | undefined): PushErrorCode;
47
+ /** Forgets cached access tokens (tests; after rotating a key). */
48
+ export declare function clearFcmTokenCache(): void;
49
+ /**
50
+ * An OAuth access token for the service account (scope `firebase.messaging`): an RS256 assertion exchanged at the
51
+ * token endpoint, cached per isolate until `FCM_TOKEN_MARGIN_MS` before it expires.
52
+ */
53
+ export declare function fcmAccessToken(o: FcmOptions, force?: boolean): Promise<string>;
54
+ /**
55
+ * The v1 `message` for one push. In clear: a `notification` (title/body; the system shows it while the app is in the
56
+ * background) and `data.body` = JSON of `data`, where expo-notifications reads it on Android. Sealed: a data-only
57
+ * message, `{ ember: <envelope JSON>, fallbackTitle, fallbackBody, channelId?, tag? }`, which the app's background
58
+ * task decrypts and shows (`@freecorps/ember-expo-push`). Background (`contentAvailable` with nothing to show): data only.
59
+ */
60
+ export declare function fcmMessage(m: AddressedPushMessage, fallback?: PushFallback): Record<string, unknown>;
61
+ /**
62
+ * Sends each message as its own v1 request, `concurrency` at a time. Never throws for message failures: they come
63
+ * back as error tickets (`DeviceNotRegistered` for `UNREGISTERED`, which callers prune). A broken service account
64
+ * fails every message with `InvalidCredentials`.
65
+ */
66
+ export declare function sendFcm(messages: AddressedPushMessage[], o: FcmOptions): Promise<PushTicket[]>;
67
+ /** Direct FCM as a `PushProvider`. No receipts: FCM answers each request synchronously. */
68
+ export declare function fcmPushProvider(o: FcmOptions): PushProvider;
package/dist/fcm.js ADDED
@@ -0,0 +1,215 @@
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, fetchWithRetry, mapLimit, resolveTtlSeconds, resolveUrgency } 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
+ /** Parses and checks a service-account key; throws a message naming what is missing (never echoing the key). */
22
+ export function parseServiceAccount(value) {
23
+ let sa = value;
24
+ if (typeof value === "string") {
25
+ try {
26
+ sa = JSON.parse(value);
27
+ }
28
+ catch {
29
+ throw new Error("FCM service account is not valid JSON (expected the key file's contents)");
30
+ }
31
+ }
32
+ const o = sa;
33
+ const missing = ["project_id", "client_email", "private_key"].filter((k) => typeof o?.[k] !== "string" || !o[k]);
34
+ if (missing.length)
35
+ throw new Error(`FCM service account is missing ${missing.join(", ")}`);
36
+ return o;
37
+ }
38
+ /** A registration token as `getDevicePushTokenAsync` returns it on Android. */
39
+ export function isFcmToken(token) {
40
+ return typeof token === "string" && /^[\w\-:.]{20,4096}$/.test(token) && !isExpoPushToken(token);
41
+ }
42
+ /** FCM's error codes (`google.firebase.fcm.v1.FcmError`, falling back to the HTTP status) → Ember's codes. */
43
+ export function fcmErrorCode(status, errorCode) {
44
+ switch (errorCode) {
45
+ case "UNREGISTERED":
46
+ return "DeviceNotRegistered";
47
+ case "SENDER_ID_MISMATCH":
48
+ return "MismatchSenderId";
49
+ case "QUOTA_EXCEEDED":
50
+ return "MessageRateExceeded";
51
+ case "THIRD_PARTY_AUTH_ERROR":
52
+ return "InvalidCredentials";
53
+ case "INVALID_ARGUMENT":
54
+ // Also a malformed token, but just as often a bad payload: never prune on it.
55
+ return "InvalidArgument";
56
+ }
57
+ if (status === 401 || status === 403)
58
+ return "InvalidCredentials";
59
+ if (status === 429)
60
+ return "MessageRateExceeded";
61
+ return "RequestFailed";
62
+ }
63
+ const accessTokens = new Map();
64
+ /** Forgets cached access tokens (tests; after rotating a key). */
65
+ export function clearFcmTokenCache() {
66
+ accessTokens.clear();
67
+ inFlight.clear();
68
+ }
69
+ /**
70
+ * An OAuth access token for the service account (scope `firebase.messaging`): an RS256 assertion exchanged at the
71
+ * token endpoint, cached per isolate until `FCM_TOKEN_MARGIN_MS` before it expires.
72
+ */
73
+ export async function fcmAccessToken(o, force = false) {
74
+ const sa = parseServiceAccount(o.serviceAccount);
75
+ const now = (o.now ?? Date.now)();
76
+ const tokenUrl = o.tokenUrl ?? sa.token_uri ?? GOOGLE_TOKEN_URL;
77
+ const cacheKey = `${sa.client_email}\u0000${sa.private_key_id ?? ""}\u0000${tokenUrl}`;
78
+ const hit = accessTokens.get(cacheKey);
79
+ if (!force && hit && now < hit.expiresAt - FCM_TOKEN_MARGIN_MS)
80
+ return hit.token;
81
+ // Concurrent sends share one exchange instead of each fetching a token.
82
+ const pending = inFlight.get(cacheKey);
83
+ if (pending)
84
+ return pending;
85
+ const p = exchange(o, sa, tokenUrl, now).then((t) => {
86
+ accessTokens.set(cacheKey, t);
87
+ return t.token;
88
+ });
89
+ inFlight.set(cacheKey, p);
90
+ try {
91
+ return await p;
92
+ }
93
+ finally {
94
+ inFlight.delete(cacheKey);
95
+ }
96
+ }
97
+ const inFlight = new Map();
98
+ async function exchange(o, sa, tokenUrl, now) {
99
+ const key = await importSigningKey(sa.private_key, "RS256");
100
+ const iat = Math.floor(now / 1000);
101
+ 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 } : {});
102
+ const fetchFn = (o.fetch ?? globalThis.fetch).bind(globalThis);
103
+ const res = await fetchWithRetry(fetchFn, tokenUrl, () => ({
104
+ method: "POST",
105
+ headers: { "content-type": "application/x-www-form-urlencoded" },
106
+ body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }).toString(),
107
+ }), o);
108
+ const body = (await res.json().catch(() => ({})));
109
+ if (!res.ok || !body.access_token) {
110
+ throw Object.assign(new Error(`Google token endpoint: ${res.status} ${body.error ?? ""} ${body.error_description ?? ""}`.trim()), { credentials: res.status === 400 || res.status === 401 });
111
+ }
112
+ return { token: body.access_token, expiresAt: now + (body.expires_in ?? 3600) * 1000 };
113
+ }
114
+ /**
115
+ * The v1 `message` for one push. In clear: a `notification` (title/body; the system shows it while the app is in the
116
+ * background) and `data.body` = JSON of `data`, where expo-notifications reads it on Android. Sealed: a data-only
117
+ * message, `{ ember: <envelope JSON>, fallbackTitle, fallbackBody, channelId?, tag? }`, which the app's background
118
+ * task decrypts and shows (`@freecorps/ember-expo-push`). Background (`contentAvailable` with nothing to show): data only.
119
+ */
120
+ export function fcmMessage(m, fallback = DEFAULT_PUSH_FALLBACK) {
121
+ const urgency = resolveUrgency(m);
122
+ const ttl = Math.min(FCM_MAX_TTL_SECONDS, resolveTtlSeconds(m));
123
+ const android = { priority: urgency === "low" ? "NORMAL" : "HIGH", ttl: `${ttl}s` };
124
+ if (m.collapseId)
125
+ android.collapse_key = m.collapseId;
126
+ const message = { token: m.to, android };
127
+ if (m.sealed) {
128
+ const data = { ember: JSON.stringify(m.sealed), urgency };
129
+ if (fallback.title)
130
+ data.fallbackTitle = fallback.title;
131
+ if (fallback.body)
132
+ data.fallbackBody = fallback.body;
133
+ if (m.channelId)
134
+ data.channelId = m.channelId;
135
+ if (m.collapseId)
136
+ data.tag = m.collapseId;
137
+ message.data = data;
138
+ return message;
139
+ }
140
+ if (m.title || m.body) {
141
+ const notification = {};
142
+ if (m.title)
143
+ notification.title = m.title;
144
+ if (m.body)
145
+ notification.body = m.body;
146
+ message.notification = notification;
147
+ const an = {};
148
+ if (m.channelId)
149
+ an.channel_id = m.channelId;
150
+ if (m.sound)
151
+ an.sound = m.sound;
152
+ if (m.collapseId)
153
+ an.tag = m.collapseId;
154
+ if (m.badge !== undefined)
155
+ an.notification_count = m.badge;
156
+ if (Object.keys(an).length)
157
+ android.notification = an;
158
+ }
159
+ if (m.data)
160
+ message.data = { body: JSON.stringify(m.data) };
161
+ return message;
162
+ }
163
+ /**
164
+ * Sends each message as its own v1 request, `concurrency` at a time. Never throws for message failures: they come
165
+ * back as error tickets (`DeviceNotRegistered` for `UNREGISTERED`, which callers prune). A broken service account
166
+ * fails every message with `InvalidCredentials`.
167
+ */
168
+ export async function sendFcm(messages, o) {
169
+ const fetchFn = (o.fetch ?? globalThis.fetch)?.bind(globalThis);
170
+ if (!fetchFn)
171
+ throw new Error("@freecorps/ember-push: no fetch available; pass options.fetch");
172
+ let sa;
173
+ try {
174
+ sa = parseServiceAccount(o.serviceAccount);
175
+ }
176
+ catch (e) {
177
+ return messages.map((m) => ({ token: String(m.to), status: "error", error: "InvalidCredentials", message: e.message }));
178
+ }
179
+ const url = o.sendUrl ?? FCM_SEND_URL(sa.project_id);
180
+ const sendOne = async (m) => {
181
+ if (!isFcmToken(m.to))
182
+ return { token: String(m.to), status: "error", error: "InvalidToken", message: "not an FCM registration token" };
183
+ const message = fcmMessage(m, o.fallback);
184
+ const body = JSON.stringify({ message });
185
+ if (byteLength(JSON.stringify({ n: message.notification, d: message.data })) > FCM_MAX_PAYLOAD) {
186
+ return { token: m.to, status: "error", error: "MessageTooBig", message: `FCM payload over ${FCM_MAX_PAYLOAD} bytes` };
187
+ }
188
+ let refreshed = false;
189
+ try {
190
+ for (;;) {
191
+ const access = await fcmAccessToken(o, refreshed);
192
+ const res = await fetchWithRetry(fetchFn, url, () => ({ method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${access}` }, body }), o);
193
+ const json = (await res.json().catch(() => ({})));
194
+ if (res.ok)
195
+ return { token: m.to, status: "ok", id: json.name ?? "" };
196
+ if (res.status === 401 && !refreshed) {
197
+ refreshed = true;
198
+ continue;
199
+ }
200
+ const errorCode = json.error?.details?.find((d) => d.errorCode)?.errorCode ?? json.error?.status;
201
+ return { token: m.to, status: "error", error: fcmErrorCode(res.status, errorCode), message: `FCM ${res.status} ${errorCode ?? ""}: ${json.error?.message ?? ""}`.trim() };
202
+ }
203
+ }
204
+ catch (e) {
205
+ const err = e;
206
+ const message = err.message.startsWith("Google") ? err.message : err.credentials ? `FCM service account key: ${err.message}` : `FCM unreachable: ${err.message}`;
207
+ return { token: m.to, status: "error", error: err.credentials ? "InvalidCredentials" : "RequestFailed", message };
208
+ }
209
+ };
210
+ return mapLimit(messages, o.concurrency ?? 10, sendOne);
211
+ }
212
+ /** Direct FCM as a `PushProvider`. No receipts: FCM answers each request synchronously. */
213
+ export function fcmPushProvider(o) {
214
+ return { name: "fcm", isToken: isFcmToken, send: (messages) => sendFcm(messages, o) };
215
+ }
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 {};
package/dist/hpke.js ADDED
@@ -0,0 +1,153 @@
1
+ // Ported from Freecorps Brigade, packages/push/src/hpke.ts (MIT License, Copyright (c) 2026 Freecorps Tecnologia da
2
+ // Informação LTDA; https://github.com/freecorps/brigade/tree/main/packages/push). Kept byte-for-byte compatible with it:
3
+ // same suite, same RFC 9180 steps, so Brigade's test vectors and native decoders apply unchanged.
4
+ /**
5
+ * HPKE (RFC 9180), base mode, single-shot, with one cipher suite: DHKEM(P-256, HKDF-SHA256), HKDF-SHA256 and
6
+ * AES-128-GCM. Only WebCrypto, so it runs unchanged in Workers, browsers and Node. iOS opens it with CryptoKit
7
+ * (`HPKE.Ciphersuite(kem: .P256_HKDF_SHA256, kdf: .HKDF_SHA256, aead: .AES_GCM_128)`, iOS 17+) and Android
8
+ * with the pure-JS decoder in `@freecorps/ember-expo-push`. Why this suite: Brigade's spike S1
9
+ * (https://github.com/freecorps/brigade/blob/main/docs/spikes/s1-apns-encryption.md).
10
+ */
11
+ import { toBase64url } from "./base64url.js";
12
+ const KEM_ID = 0x0010; // DHKEM(P-256, HKDF-SHA256)
13
+ const KDF_ID = 0x0001; // HKDF-SHA256
14
+ const AEAD_ID = 0x0001; // AES-128-GCM
15
+ const MODE_BASE = 0x00;
16
+ /** Hash, shared secret, AES key and nonce lengths. */
17
+ const N_H = 32;
18
+ const N_SECRET = 32;
19
+ const N_K = 16;
20
+ const N_N = 12;
21
+ /** An uncompressed P-256 point (0x04 || x || y): the encapsulated key and every public key. */
22
+ export const P256_PUBLIC_KEY_LENGTH = 65;
23
+ const ECDH = { name: "ECDH", namedCurve: "P-256" };
24
+ const encoder = new TextEncoder();
25
+ /** Encrypts `plaintext` to the holder of the private key for `recipientPublicKey` (uncompressed P-256). */
26
+ export async function seal(recipientPublicKey, plaintext, options = {}) {
27
+ const pkR = await importPublicKey(recipientPublicKey);
28
+ const ephemeral = options.ephemeral ??
29
+ (await crypto.subtle.generateKey(ECDH, false, ["deriveBits"]));
30
+ const enc = new Uint8Array((await crypto.subtle.exportKey("raw", ephemeral.publicKey)));
31
+ const dh = await deriveDh(ephemeral.privateKey, pkR);
32
+ const sharedSecret = await extractAndExpand(dh, concat(enc, recipientPublicKey));
33
+ const { key, baseNonce } = await keySchedule(sharedSecret, options.info);
34
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv: baseNonce, additionalData: bytes(options.aad) }, key, bytes(plaintext));
35
+ return { enc, ct: new Uint8Array(ct) };
36
+ }
37
+ /** Decrypts what `seal` produced. Throws if the key, `info`, `aad` or any byte does not match. */
38
+ export async function open(recipient, sealed, options = {}) {
39
+ const pkE = await importPublicKey(sealed.enc);
40
+ const dh = await deriveDh(recipient.privateKey, pkE);
41
+ const sharedSecret = await extractAndExpand(dh, concat(sealed.enc, recipient.publicKey));
42
+ const { key, baseNonce } = await keySchedule(sharedSecret, options.info);
43
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: baseNonce, additionalData: bytes(options.aad) }, key, bytes(sealed.ct));
44
+ return new Uint8Array(pt);
45
+ }
46
+ /** A fresh recipient key pair. The private key is not extractable. */
47
+ export async function generateRecipient() {
48
+ const pair = (await crypto.subtle.generateKey(ECDH, false, ["deriveBits"]));
49
+ const publicKey = new Uint8Array((await crypto.subtle.exportKey("raw", pair.publicKey)));
50
+ return { privateKey: pair.privateKey, publicKey };
51
+ }
52
+ /**
53
+ * Imports a P-256 key pair from its raw private scalar (32 bytes) and uncompressed public key, the form CryptoKit
54
+ * (`rawRepresentation`, `x963Representation`) and the RFC 9180 test vectors use.
55
+ */
56
+ export async function importKeyPair(privateKey, publicKey) {
57
+ if (privateKey.length !== 32)
58
+ throw new Error("P-256 private key must be 32 bytes");
59
+ const { x, y } = await publicJwk(publicKey);
60
+ const jwk = { kty: "EC", crv: "P-256", x, y, d: toBase64url(privateKey) };
61
+ const priv = await crypto.subtle.importKey("jwk", jwk, ECDH, false, ["deriveBits"]);
62
+ return { privateKey: priv, publicKey: await importPublicKey(publicKey) };
63
+ }
64
+ async function importPublicKey(raw) {
65
+ // Uncompressed only: WebCrypto's raw import also takes the 33-byte compressed form, which the KEM context
66
+ // would then hash differently from the other side.
67
+ if (raw.length !== P256_PUBLIC_KEY_LENGTH || raw[0] !== 0x04) {
68
+ throw new Error("expected an uncompressed P-256 public key (65 bytes)");
69
+ }
70
+ return crypto.subtle.importKey("raw", bytes(raw), ECDH, true, []);
71
+ }
72
+ async function publicJwk(raw) {
73
+ const jwk = (await crypto.subtle.exportKey("jwk", await importPublicKey(raw)));
74
+ if (!jwk.x || !jwk.y)
75
+ throw new Error("invalid P-256 public key");
76
+ return { x: jwk.x, y: jwk.y };
77
+ }
78
+ async function deriveDh(privateKey, publicKey) {
79
+ // The x coordinate of the shared point (RFC 9180, section 7.1.1).
80
+ return new Uint8Array(
81
+ // Cast: DOM lib types call the peer key `public`, @cloudflare/workers-types `$public`; runtimes read `public`.
82
+ await crypto.subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256));
83
+ }
84
+ // RFC 9180, section 4.1: DHKEM's ExtractAndExpand.
85
+ async function extractAndExpand(dh, kemContext) {
86
+ const suite = concat(encoder.encode("KEM"), i2osp(KEM_ID, 2));
87
+ const eaePrk = await labeledExtract(suite, new Uint8Array(0), "eae_prk", dh);
88
+ return labeledExpand(suite, eaePrk, "shared_secret", kemContext, N_SECRET);
89
+ }
90
+ // RFC 9180, section 5.1, base mode (no PSK).
91
+ async function keySchedule(sharedSecret, info = new Uint8Array(0)) {
92
+ const suite = concat(encoder.encode("HPKE"), i2osp(KEM_ID, 2), i2osp(KDF_ID, 2), i2osp(AEAD_ID, 2));
93
+ const empty = new Uint8Array(0);
94
+ const pskIdHash = await labeledExtract(suite, empty, "psk_id_hash", empty);
95
+ const infoHash = await labeledExtract(suite, empty, "info_hash", bytes(info));
96
+ const context = concat(Uint8Array.of(MODE_BASE), pskIdHash, infoHash);
97
+ const secret = await labeledExtract(suite, sharedSecret, "secret", empty);
98
+ const keyBytes = await labeledExpand(suite, secret, "key", context, N_K);
99
+ const baseNonce = await labeledExpand(suite, secret, "base_nonce", context, N_N);
100
+ // Single-shot: one message at sequence number 0, so the nonce is the base nonce.
101
+ const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, [
102
+ "encrypt",
103
+ "decrypt",
104
+ ]);
105
+ return { key, baseNonce };
106
+ }
107
+ function labeledExtract(suite, salt, label, ikm) {
108
+ return hkdfExtract(salt, concat(encoder.encode("HPKE-v1"), suite, encoder.encode(label), ikm));
109
+ }
110
+ function labeledExpand(suite, prk, label, info, length) {
111
+ const labeled = concat(i2osp(length, 2), encoder.encode("HPKE-v1"), suite, encoder.encode(label), info);
112
+ return hkdfExpand(prk, labeled, length);
113
+ }
114
+ // RFC 5869 with SHA-256. WebCrypto's HKDF fuses extract and expand, and HPKE labels each step on its own.
115
+ async function hkdfExtract(salt, ikm) {
116
+ // An empty salt means HashLen zero bytes; WebCrypto rejects a zero-length HMAC key, and HMAC pads either way.
117
+ return hmac(salt.length === 0 ? new Uint8Array(N_H) : salt, ikm);
118
+ }
119
+ async function hkdfExpand(prk, info, length) {
120
+ const out = new Uint8Array(length);
121
+ let block = new Uint8Array(0);
122
+ for (let i = 1, offset = 0; offset < length; i++) {
123
+ block = await hmac(prk, concat(block, info, Uint8Array.of(i)));
124
+ out.set(block.subarray(0, Math.min(N_H, length - offset)), offset);
125
+ offset += N_H;
126
+ }
127
+ return out;
128
+ }
129
+ async function hmac(key, data) {
130
+ const k = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, [
131
+ "sign",
132
+ ]);
133
+ return new Uint8Array(await crypto.subtle.sign("HMAC", k, data));
134
+ }
135
+ function i2osp(value, length) {
136
+ const out = new Uint8Array(length);
137
+ for (let i = length - 1, v = value; i >= 0; i--, v >>>= 8)
138
+ out[i] = v & 0xff;
139
+ return out;
140
+ }
141
+ function concat(...parts) {
142
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
143
+ let offset = 0;
144
+ for (const part of parts) {
145
+ out.set(part, offset);
146
+ offset += part.length;
147
+ }
148
+ return out;
149
+ }
150
+ /** A copy backed by a plain ArrayBuffer, which WebCrypto's types require. */
151
+ function bytes(value = new Uint8Array(0)) {
152
+ return new Uint8Array(value);
153
+ }
package/dist/index.d.ts CHANGED
@@ -5,8 +5,20 @@
5
5
  * - Sending (`sendPush`, `checkPushReceipts`): plain `fetch`, so call them from an **action** (PLAN D6: I/O only in
6
6
  * actions). Typical flow: a mutation schedules an action → the action reads tokens with `ctx.runQuery`, calls
7
7
  * `sendPush`, deletes `unregisteredTokens` with `ctx.runMutation`, and schedules a receipts check ~15 min later.
8
- * - `PushProvider` keeps the backend pluggable: Expo Push today; direct APNs/FCM later.
8
+ * - Transports: Expo Push (`sendPush`, zero setup) or direct APNs + FCM (`sendApns`, `sendFcm`, `sendToDevices`), with
9
+ * optional end-to-end encryption (HPKE envelopes, `sealPushContent`; the device side is `@freecorps/ember-expo-push`).
10
+ * - Or install it as a component (`pushComponent`, PLAN §12): devices in the component's own table, sends enqueued
11
+ * from mutations (atomically), receipts checked later, dead tokens pruned. See `PushNotifications`.
9
12
  */
10
13
  export * from "./types.ts";
14
+ export { fromBase64url, toBase64url } from "./base64url.ts";
15
+ export { generateRecipient, importKeyPair, open, seal, P256_PUBLIC_KEY_LENGTH, type HpkeOptions, type HpkeRecipient, type HpkeSealed, type SealOptions } from "./hpke.ts";
16
+ export { EMBER_PUSH_ENVELOPE_VERSION, EMBER_PUSH_HPKE_INFO, PUSH_KEY_ID, PUSH_PLAINTEXT_MAX_BYTES, isDevicePublicKey, openPushEnvelope, parseDevicePublicKey, parseOpenedContent, parsePushEnvelope, pushKeyId, sealPushContent, type EncryptedPushContent, type OpenedPushContent, type PushEnvelope, } from "./envelope.ts";
11
17
  export { EXPO_MAX_PAYLOAD, EXPO_PUSH_URL, EXPO_RECEIPTS_CHUNK, EXPO_RECEIPTS_URL, EXPO_SEND_CHUNK, checkPushReceipts, expoPushProvider, isExpoPushToken, payloadSize, sendExpoMessages, sendPush, toExpoMessage, type ExpoPushOptions, } from "./expo.ts";
12
- export { PUSH_DEVICES_TABLE, pushDevicesForUser, pushDevicesTable, pushTokensForUsers, registerPushDevice, removePushTokens, unregisterPushDevice, type PushDevice, type RegisterPushDeviceInput, } from "./devices.ts";
18
+ export { PUSH_DEVICES_TABLE, inferPushProvider, isPushProviderName, pushDevicesForUser, pushDevicesForUsers, pushDevicesTable, pushTokensForUsers, registerPushDevice, removePushTokens, removeVoipTokens, setPushDeviceEnvironment, unregisterPushDevice, type PushDevice, type PushProviderName, type RegisterPushDeviceInput, } from "./devices.ts";
19
+ export { apnsOptionsFromEnv, fcmOptionsFromEnv, pushComponent, pushFunctions, PushNotifications, type PushComponentApi, type PushInstallConfig, type DeliveryResult } from "./component.ts";
20
+ export { decodeJwt, importSigningKey, pemToDer, signJwt, verifyJwt, type CredentialsError, type JwtAlgorithm } from "./jwt.ts";
21
+ export { DEFAULT_PUSH_FALLBACK, PUSH_MAX_TTL_SECONDS, PUSH_TTL_DEFAULTS, resolveInterruptionLevel, resolveTtlSeconds, resolveUrgency } from "./delivery.ts";
22
+ export { APNS_MAX_PAYLOAD, APNS_PRODUCTION_URL, APNS_SANDBOX_URL, APNS_TOKEN_REFRESH_MS, APNS_VOIP_MAX_PAYLOAD, apnsErrorCode, apnsHeaders, apnsPayload, apnsProviderToken, apnsPushProvider, apnsPushType, clearApnsTokenCache, isApnsToken, sendApns, type ApnsCredentials, type ApnsOptions, } from "./apns.ts";
23
+ export { FCM_MAX_PAYLOAD, FCM_MAX_TTL_SECONDS, FCM_SCOPE, FCM_SEND_URL, FCM_TOKEN_MARGIN_MS, GOOGLE_TOKEN_URL, clearFcmTokenCache, fcmAccessToken, fcmErrorCode, fcmMessage, fcmPushProvider, isFcmToken, parseServiceAccount, sendFcm, type FcmOptions, type ServiceAccount, } from "./fcm.ts";
24
+ export { sendToDevices, type DeviceSendResult, type PushTarget, type PushTransports } from "./direct.ts";
package/dist/index.js CHANGED
@@ -5,8 +5,20 @@
5
5
  * - Sending (`sendPush`, `checkPushReceipts`): plain `fetch`, so call them from an **action** (PLAN D6: I/O only in
6
6
  * actions). Typical flow: a mutation schedules an action → the action reads tokens with `ctx.runQuery`, calls
7
7
  * `sendPush`, deletes `unregisteredTokens` with `ctx.runMutation`, and schedules a receipts check ~15 min later.
8
- * - `PushProvider` keeps the backend pluggable: Expo Push today; direct APNs/FCM later.
8
+ * - Transports: Expo Push (`sendPush`, zero setup) or direct APNs + FCM (`sendApns`, `sendFcm`, `sendToDevices`), with
9
+ * optional end-to-end encryption (HPKE envelopes, `sealPushContent`; the device side is `@freecorps/ember-expo-push`).
10
+ * - Or install it as a component (`pushComponent`, PLAN §12): devices in the component's own table, sends enqueued
11
+ * from mutations (atomically), receipts checked later, dead tokens pruned. See `PushNotifications`.
9
12
  */
10
13
  export * from "./types.js";
14
+ export { fromBase64url, toBase64url } from "./base64url.js";
15
+ export { generateRecipient, importKeyPair, open, seal, P256_PUBLIC_KEY_LENGTH } from "./hpke.js";
16
+ export { EMBER_PUSH_ENVELOPE_VERSION, EMBER_PUSH_HPKE_INFO, PUSH_KEY_ID, PUSH_PLAINTEXT_MAX_BYTES, isDevicePublicKey, openPushEnvelope, parseDevicePublicKey, parseOpenedContent, parsePushEnvelope, pushKeyId, sealPushContent, } from "./envelope.js";
11
17
  export { EXPO_MAX_PAYLOAD, EXPO_PUSH_URL, EXPO_RECEIPTS_CHUNK, EXPO_RECEIPTS_URL, EXPO_SEND_CHUNK, checkPushReceipts, expoPushProvider, isExpoPushToken, payloadSize, sendExpoMessages, sendPush, toExpoMessage, } from "./expo.js";
12
- export { PUSH_DEVICES_TABLE, pushDevicesForUser, pushDevicesTable, pushTokensForUsers, registerPushDevice, removePushTokens, unregisterPushDevice, } from "./devices.js";
18
+ export { PUSH_DEVICES_TABLE, inferPushProvider, isPushProviderName, pushDevicesForUser, pushDevicesForUsers, pushDevicesTable, pushTokensForUsers, registerPushDevice, removePushTokens, removeVoipTokens, setPushDeviceEnvironment, unregisterPushDevice, } from "./devices.js";
19
+ export { apnsOptionsFromEnv, fcmOptionsFromEnv, pushComponent, pushFunctions, PushNotifications } from "./component.js";
20
+ export { decodeJwt, importSigningKey, pemToDer, signJwt, verifyJwt } from "./jwt.js";
21
+ export { DEFAULT_PUSH_FALLBACK, PUSH_MAX_TTL_SECONDS, PUSH_TTL_DEFAULTS, resolveInterruptionLevel, resolveTtlSeconds, resolveUrgency } from "./delivery.js";
22
+ export { APNS_MAX_PAYLOAD, APNS_PRODUCTION_URL, APNS_SANDBOX_URL, APNS_TOKEN_REFRESH_MS, APNS_VOIP_MAX_PAYLOAD, apnsErrorCode, apnsHeaders, apnsPayload, apnsProviderToken, apnsPushProvider, apnsPushType, clearApnsTokenCache, isApnsToken, sendApns, } from "./apns.js";
23
+ export { FCM_MAX_PAYLOAD, FCM_MAX_TTL_SECONDS, FCM_SCOPE, FCM_SEND_URL, FCM_TOKEN_MARGIN_MS, GOOGLE_TOKEN_URL, clearFcmTokenCache, fcmAccessToken, fcmErrorCode, fcmMessage, fcmPushProvider, isFcmToken, parseServiceAccount, sendFcm, } from "./fcm.js";
24
+ export { sendToDevices } from "./direct.js";
package/dist/jwt.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ export type JwtAlgorithm = "ES256" | "RS256";
2
+ /**
3
+ * The DER bytes of a PEM `PRIVATE KEY` (PKCS#8), the form of an Apple `.p8` file and of a Google service account's
4
+ * `private_key`. Tolerates what secrets go through: `\n` written as two characters, `\r\n`, surrounding whitespace and
5
+ * quotes. Throws for an encrypted or non-PKCS#8 key (`BEGIN EC PRIVATE KEY`, `BEGIN RSA PRIVATE KEY`).
6
+ */
7
+ export declare function pemToDer(pem: string): Uint8Array<ArrayBuffer>;
8
+ /** An error about the configured key itself (not the network): senders report it as `InvalidCredentials`. */
9
+ export type CredentialsError = Error & {
10
+ credentials: true;
11
+ };
12
+ /**
13
+ * Imports a PKCS#8 PEM private key for signing with `alg`. The key is not extractable. Throws a `CredentialsError`
14
+ * (`credentials: true`) for anything wrong with the key: not PEM, not PKCS#8, bad base64, wrong curve or type.
15
+ */
16
+ export declare function importSigningKey(pem: string, alg: JwtAlgorithm): Promise<CryptoKey>;
17
+ /** `base64url(header).base64url(claims).base64url(signature)`, with `alg` and `typ: "JWT"` set in the header. */
18
+ export declare function signJwt(alg: JwtAlgorithm, key: CryptoKey, claims: Record<string, unknown>, header?: Record<string, unknown>): Promise<string>;
19
+ /** Splits a compact JWS into its decoded header, claims and raw signature (no verification). For tests and fakes. */
20
+ export declare function decodeJwt(token: string): {
21
+ header: Record<string, unknown>;
22
+ claims: Record<string, unknown>;
23
+ signature: Uint8Array<ArrayBuffer>;
24
+ signingInput: string;
25
+ };
26
+ /** Verifies a compact JWS with a public key (`verify` usage). For tests and fakes. */
27
+ export declare function verifyJwt(token: string, alg: JwtAlgorithm, publicKey: CryptoKey): Promise<boolean>;
package/dist/jwt.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Compact JWS signing on WebCrypto, for the two tokens direct push needs (RFC 7515/7518):
3
+ *
4
+ * - **ES256** — the APNs provider token, signed with the `.p8` key from Apple (a PKCS#8 P-256 key). WebCrypto's ECDSA
5
+ * signature is already the raw `r ‖ s` (64 bytes) that JWS wants, so no DER conversion.
6
+ * - **RS256** — the assertion Google's OAuth token endpoint exchanges for an FCM access token, signed with the
7
+ * service account's `private_key` (PKCS#8 RSA).
8
+ *
9
+ * Written for Ember from the RFCs and Apple/Google documentation; no Node APIs, so it runs in Workers.
10
+ */
11
+ import { fromBase64url, toBase64url } from "./base64url.js";
12
+ const ALGORITHMS = {
13
+ ES256: { importParams: { name: "ECDSA", namedCurve: "P-256" }, signParams: { name: "ECDSA", hash: "SHA-256" } },
14
+ RS256: { importParams: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, signParams: { name: "RSASSA-PKCS1-v1_5" } },
15
+ };
16
+ const encoder = new TextEncoder();
17
+ /**
18
+ * The DER bytes of a PEM `PRIVATE KEY` (PKCS#8), the form of an Apple `.p8` file and of a Google service account's
19
+ * `private_key`. Tolerates what secrets go through: `\n` written as two characters, `\r\n`, surrounding whitespace and
20
+ * quotes. Throws for an encrypted or non-PKCS#8 key (`BEGIN EC PRIVATE KEY`, `BEGIN RSA PRIVATE KEY`).
21
+ */
22
+ export function pemToDer(pem) {
23
+ const text = pem.trim().replace(/^"|"$/g, "").replace(/\\n/g, "\n").replace(/\r/g, "");
24
+ const m = /-----BEGIN ([A-Z ]+)-----([\s\S]*?)-----END \1-----/.exec(text);
25
+ if (!m)
26
+ throw new Error("not a PEM key (expected -----BEGIN PRIVATE KEY-----)");
27
+ if (m[1] !== "PRIVATE KEY")
28
+ throw new Error(`expected a PKCS#8 "PRIVATE KEY", got "${m[1]}" (convert it with \`openssl pkcs8 -topk8 -nocrypt\`)`);
29
+ const b64 = m[2].replace(/\s+/g, "");
30
+ const bin = atob(b64);
31
+ const out = new Uint8Array(bin.length);
32
+ for (let i = 0; i < bin.length; i++)
33
+ out[i] = bin.charCodeAt(i);
34
+ return out;
35
+ }
36
+ const credentialsError = (message) => Object.assign(new Error(message), { credentials: true });
37
+ /**
38
+ * Imports a PKCS#8 PEM private key for signing with `alg`. The key is not extractable. Throws a `CredentialsError`
39
+ * (`credentials: true`) for anything wrong with the key: not PEM, not PKCS#8, bad base64, wrong curve or type.
40
+ */
41
+ export async function importSigningKey(pem, alg) {
42
+ let der;
43
+ try {
44
+ der = pemToDer(pem);
45
+ }
46
+ catch (e) {
47
+ throw credentialsError(e.message);
48
+ }
49
+ try {
50
+ return await crypto.subtle.importKey("pkcs8", der, ALGORITHMS[alg].importParams, false, ["sign"]);
51
+ }
52
+ catch (e) {
53
+ throw credentialsError(`cannot import the ${alg === "ES256" ? "P-256 (.p8)" : "RSA"} private key: ${e.message}`);
54
+ }
55
+ }
56
+ const b64json = (value) => toBase64url(encoder.encode(JSON.stringify(value)));
57
+ /** `base64url(header).base64url(claims).base64url(signature)`, with `alg` and `typ: "JWT"` set in the header. */
58
+ export async function signJwt(alg, key, claims, header = {}) {
59
+ const signingInput = `${b64json({ alg, typ: "JWT", ...header })}.${b64json(claims)}`;
60
+ const signature = await crypto.subtle.sign(ALGORITHMS[alg].signParams, key, encoder.encode(signingInput));
61
+ return `${signingInput}.${toBase64url(new Uint8Array(signature))}`;
62
+ }
63
+ /** Splits a compact JWS into its decoded header, claims and raw signature (no verification). For tests and fakes. */
64
+ export function decodeJwt(token) {
65
+ const parts = token.split(".");
66
+ if (parts.length !== 3)
67
+ throw new Error("not a compact JWS");
68
+ const json = (s) => JSON.parse(new TextDecoder().decode(fromBase64url(s)));
69
+ return { header: json(parts[0]), claims: json(parts[1]), signature: fromBase64url(parts[2]), signingInput: `${parts[0]}.${parts[1]}` };
70
+ }
71
+ /** Verifies a compact JWS with a public key (`verify` usage). For tests and fakes. */
72
+ export async function verifyJwt(token, alg, publicKey) {
73
+ const { header, signature, signingInput } = decodeJwt(token);
74
+ if (header.alg !== alg)
75
+ return false;
76
+ return crypto.subtle.verify(ALGORITHMS[alg].signParams, publicKey, signature, encoder.encode(signingInput));
77
+ }