@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/README.md
CHANGED
|
@@ -1,12 +1,229 @@
|
|
|
1
1
|
# @freecorps/ember-push
|
|
2
2
|
|
|
3
|
-
Push notifications for Ember
|
|
3
|
+
Push notifications for Ember apps. Sends go out from your own Worker (actions, or the `push` component). There is
|
|
4
|
+
no relay to run: Ember is self-hosted per company.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
| Transport | Device tokens | Setup | Notes |
|
|
7
|
+
|---|---|---|---|
|
|
8
|
+
| `expo` (default) | `ExponentPushToken[...]` (`registerForPush()`) | none (`EXPO_ACCESS_TOKEN` optional) | Expo Push: receipts ~15 min later, no encryption |
|
|
9
|
+
| `direct` | native APNs / FCM tokens (`registerForPush({ kind: "device" })`) | APNs `.p8` + FCM service account, as Worker secrets | APNs over HTTP/2 + FCM HTTP v1 straight from the Worker; urgency, collapse ids, VoIP, **end-to-end encryption** |
|
|
10
|
+
|
|
11
|
+
The transport is chosen **per device**, by how the app registered: Expo tokens go to Expo, native tokens go direct.
|
|
12
|
+
Both kinds can coexist, for example during a migration.
|
|
13
|
+
|
|
14
|
+
## The push component
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// ember/ember.config.ts
|
|
18
|
+
import { pushComponent } from "@freecorps/ember-push";
|
|
19
|
+
export default defineApp().use(pushComponent, {
|
|
20
|
+
config: {
|
|
21
|
+
apns: { topic: "com.acme.mail" }, // the bundle id (or the APNS_TOPIC secret)
|
|
22
|
+
encryption: "required", // mail: subject and sender never in clear at Apple/Google
|
|
23
|
+
fallback: { title: "New mail", body: "Open Mail to read it" },
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ember/push.ts
|
|
28
|
+
import { PushNotifications } from "@freecorps/ember-push";
|
|
29
|
+
import { components, mutation } from "./_generated/server";
|
|
30
|
+
const push = new PushNotifications(components.push);
|
|
31
|
+
|
|
32
|
+
export const register = mutation({
|
|
33
|
+
args: { token: v.string(), platform: v.optional(v.string()), environment: v.optional(v.string()), publicKey: v.optional(v.string()) },
|
|
34
|
+
handler: (ctx, a) => push.register(ctx, { userId: me(ctx), ...a }), // provider inferred from the token
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// in the mutation that stores a new message (the push is enqueued atomically with it):
|
|
38
|
+
push.send(ctx, [recipient], { title: from, body: subject, urgency: "high", collapseId: threadHash, data: { messageId } });
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The full config, with every field optional:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
{
|
|
45
|
+
accessTokenEnv: "EXPO_ACCESS_TOKEN", receiptsDelayMs: 15 * 60_000, // Expo
|
|
46
|
+
apns: { topic, keyEnv: "APNS_KEY", keyIdEnv: "APNS_KEY_ID", teamIdEnv: "APNS_TEAM_ID", topicEnv: "APNS_TOPIC",
|
|
47
|
+
environment: "production" /* devices registered without one */, productionUrl, sandboxUrl },
|
|
48
|
+
fcm: { serviceAccountEnv: "FCM_SERVICE_ACCOUNT", sendUrl, tokenUrl },
|
|
49
|
+
encryption: "auto", // "off" | "auto" | "required"
|
|
50
|
+
fallback: { title, body, titleLocKey, locKey },
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
What a delivery does:
|
|
55
|
+
1. Reads the users' devices.
|
|
56
|
+
2. Sends each device through its transport.
|
|
57
|
+
3. Deletes devices whose token is dead, clears dead VoIP tokens, and records the APNs environment found for devices
|
|
58
|
+
registered without one.
|
|
59
|
+
4. Schedules Expo receipt checks (Expo only; APNs and FCM answer synchronously).
|
|
60
|
+
|
|
61
|
+
`sendNow` returns `{ sent, errors, removedTokens, errorCodes }`. When secrets are missing, those devices are kept;
|
|
62
|
+
the result shows `ProviderNotConfigured` and `ember logs` shows a warning naming the missing secrets.
|
|
63
|
+
|
|
64
|
+
## Without the component
|
|
65
|
+
|
|
66
|
+
Use `sendPush(tokens, message)` (Expo) or `sendToDevices(devices, message, { apns, fcm, expo, encryption, fallback })`
|
|
67
|
+
from an action, with your own `pushDevices` table (`pushDevicesTable()`, `registerPushDevice`, `pushDevicesForUsers`,
|
|
68
|
+
`removePushTokens`, `removeVoipTokens`, `setPushDeviceEnvironment`). Lower level: `sendApns`, `sendFcm`,
|
|
69
|
+
`apnsPushProvider`, `fcmPushProvider`, `sealPushContent`. See `apps/example-expo/ember/push.ts`.
|
|
70
|
+
|
|
71
|
+
## Secrets
|
|
72
|
+
|
|
73
|
+
| Secret | What |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `APNS_KEY` | The `.p8` file's text (`-----BEGIN PRIVATE KEY-----...`). Escaped `\n` and CRLF are fine |
|
|
76
|
+
| `APNS_KEY_ID` | The key's 10-character id |
|
|
77
|
+
| `APNS_TEAM_ID` | Your Apple Developer team id |
|
|
78
|
+
| `APNS_TOPIC` | The app's bundle id (or `config.apns.topic`, which is not secret) |
|
|
79
|
+
| `FCM_SERVICE_ACCOUNT` | The service account's JSON key, the whole file |
|
|
80
|
+
| `EXPO_ACCESS_TOKEN` | Optional, for Expo projects with enhanced push security |
|
|
81
|
+
|
|
82
|
+
They are read from the Worker env at send time and are never stored or logged. Keep the files outside the
|
|
83
|
+
repository. Set them with `ember env` (Worker secrets of `ember-<project>-<env>`, live at once and kept by every
|
|
84
|
+
`ember deploy`). Secret values come from stdin (or `--from-env VAR`), never the command line; plain vars such as
|
|
85
|
+
`APNS_TOPIC` are not secret, live in `ember.json`, and take their value on the command line with `--var`:
|
|
7
86
|
|
|
8
87
|
```sh
|
|
9
|
-
|
|
88
|
+
ember env set APNS_KEY --env prod < ~/secure/AuthKey_ABC123DEFG.p8
|
|
89
|
+
printf %s ABC123DEFG | ember env set APNS_KEY_ID --env prod
|
|
90
|
+
printf %s TEAM123456 | ember env set APNS_TEAM_ID --env prod
|
|
91
|
+
ember env set APNS_TOPIC com.acme.mail --var --env prod # not secret: a plain var in ember.json
|
|
92
|
+
ember env set FCM_SERVICE_ACCOUNT --env prod < ~/secure/fcm-service-account.json
|
|
93
|
+
ember env list --env prod # names only
|
|
10
94
|
```
|
|
11
95
|
|
|
12
|
-
|
|
96
|
+
In `ember dev`, put the same names in `.dev.vars` (gitignored).
|
|
97
|
+
|
|
98
|
+
**APNs needs HTTP/2.** A deployed Worker's `fetch` reaches APNs, but local workerd (`ember dev`) cannot, so APNs
|
|
99
|
+
sends fail locally with a network error. FCM works locally. Unit tests use fake endpoints (below).
|
|
100
|
+
|
|
101
|
+
## Behaviour
|
|
102
|
+
|
|
103
|
+
Urgency follows Brigade's relay as its README documents it (Brigade's `low`/`high`/`page`):
|
|
104
|
+
|
|
105
|
+
| `urgency` | APNs priority | FCM priority | TTL default | iOS interruption level |
|
|
106
|
+
|---|---|---|---|---|
|
|
107
|
+
| `low` | 5 | NORMAL | 4 h | active |
|
|
108
|
+
| `normal` (default; `priority: "normal"` means `low`) | 10 | HIGH | 1 h | active |
|
|
109
|
+
| `high` | 10 | HIGH | 15 min | time-sensitive (entitlement added by the ember-expo-push plugin) |
|
|
110
|
+
|
|
111
|
+
- `ttl` overrides the default (0 = deliver now or never; at most 30 days, 28 for FCM).
|
|
112
|
+
- `collapseId` sets `apns-collapse-id` (max 64 bytes) and the FCM `collapse_key` plus notification tag. Expo devices get it as Expo's `collapseId` and Android `tag`.
|
|
113
|
+
- `threadId`, `categoryId`, `badge` and `sound` map to their APNs/FCM fields. `contentAvailable` alone sends a
|
|
114
|
+
background push (APNs priority 5). `voip: true` sends to the device's PushKit `voipToken` (topic `<bundle>.voip`).
|
|
115
|
+
- Each device token's APNs environment is recorded at registration (development-signed builds get sandbox tokens;
|
|
116
|
+
ad hoc, TestFlight and App Store builds get production). For a device registered without one, the default host is
|
|
117
|
+
tried first, then the other one after `BadDeviceToken`. The one that works is recorded.
|
|
118
|
+
- The APNs provider token (ES256 over the `.p8`) is reused for 40 minutes. The FCM access token (RS256
|
|
119
|
+
service-account assertion) is kept until 5 minutes before it expires; concurrent sends share one exchange, and a
|
|
120
|
+
401 refetches it once for every send that saw the same token.
|
|
121
|
+
- The token layer is usable on its own, around an app's own payloads: `apnsProviderToken(credentials, now, force)`
|
|
122
|
+
and `fcmAccessToken(options, force, signal)`. `force` is `true` (always new) or the token the provider just refused
|
|
123
|
+
(new only if the cache still holds it, so concurrent sends refresh once and a token another send already replaced
|
|
124
|
+
is reused).
|
|
125
|
+
- 429 and 5xx answers and network errors are retried twice with backoff (`Retry-After` is honoured).
|
|
126
|
+
- `timeoutMs` is a deadline for each send, retries and the token it needs included; `signal` aborts it. Either way the
|
|
127
|
+
ticket is `RequestFailed`. A caller's deadline only stops that caller waiting for a token exchange other sends share.
|
|
128
|
+
- **An app's own payload** (its envelope, its keys): `AddressedPushMessage.payload = { apns?, fcmData? }` is sent as
|
|
129
|
+
given. `apns` is the whole JSON body (`aps` included; a body that is only `content-available` goes as a background
|
|
130
|
+
push); `fcmData` is a data message's `data`, string values only. Ember still sets the headers (push type, priority,
|
|
131
|
+
expiration, collapse id), the token, retries and error mapping. With `sendToDevices` and the component, a
|
|
132
|
+
`seal(device, message, provider)` hook in `PushTransports` returns that payload per direct device, or `null` for
|
|
133
|
+
Ember's own envelope; for the component, `setPushSeal(seal, installName?)` in the entry module (a function cannot
|
|
134
|
+
be part of the install config). Expo devices never reach it.
|
|
135
|
+
- `sound: { critical: true, name?, volume? }` rings as an iOS Critical Alert (APNs' sound dictionary and
|
|
136
|
+
`interruption-level: critical`; needs Apple's entitlement); Android and Expo play `name`.
|
|
137
|
+
- Error tickets from a direct send carry the provider's own `reason` (APNs' `reason`, FCM's `errorCode` or error
|
|
138
|
+
`status`) and `httpStatus`, for rules the codes below fold together (a token of another topic, a token FCM calls
|
|
139
|
+
malformed).
|
|
140
|
+
|
|
141
|
+
| Answer | Code | Device |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| APNs `Unregistered` / `ExpiredToken` (410), `BadDeviceToken`; FCM `UNREGISTERED`; a malformed token | `DeviceNotRegistered` / `InvalidToken` | **deleted** |
|
|
144
|
+
| APNs `DeviceTokenNotForTopic`, `TopicDisallowed`; FCM `SENDER_ID_MISMATCH` | `MismatchSenderId` | kept (configuration error) |
|
|
145
|
+
| APNs `InvalidProviderToken`, `ExpiredProviderToken`; FCM 401/403, `THIRD_PARTY_AUTH_ERROR` | `InvalidCredentials` | kept |
|
|
146
|
+
| FCM `INVALID_ARGUMENT` | `InvalidArgument` | kept (could be our payload) |
|
|
147
|
+
| `PayloadTooLarge`, over 4 KB | `MessageTooBig` | kept |
|
|
148
|
+
| `TooManyRequests`, `QUOTA_EXCEEDED` | `MessageRateExceeded` | kept |
|
|
149
|
+
| no secrets for that provider | `ProviderNotConfigured` | kept |
|
|
150
|
+
|
|
151
|
+
## End-to-end encryption
|
|
152
|
+
|
|
153
|
+
With `encryption: "auto"` or `"required"`, a direct device that registered a `publicKey` gets its push sealed with
|
|
154
|
+
HPKE (RFC 9180: P-256, HKDF-SHA256, AES-128-GCM, info `"freecorps.ember push v1"`). The envelope `{ v, k, enc, ct }`
|
|
155
|
+
holds the title, subtitle, body and data. It is ported from Freecorps Brigade's MIT `packages/push`.
|
|
156
|
+
- APNs gets the fallback alert, `mutable-content: 1` and `ember: <envelope>`. The Notification Service Extension
|
|
157
|
+
from `@freecorps/ember-expo-push` decrypts it on iOS 17+.
|
|
158
|
+
- FCM gets a data-only message: `{ ember, fallbackTitle, fallbackBody, channelId?, tag?, urgency }`. The app's
|
|
159
|
+
background task decrypts it and shows the notification.
|
|
160
|
+
- `required`: devices without a key, and Expo devices, get only the fallback text and no data.
|
|
161
|
+
- In clear by design: the fallback text, urgency/priority, TTL, collapse id/tag, thread id, category, badge, sound and
|
|
162
|
+
the key id. Do not put anything sensitive in those (hash a thread id, for example).
|
|
163
|
+
- Only pushes with something to show are sealed. Background and VoIP pushes go as they are under `auto` and lose
|
|
164
|
+
their `data` under `required`.
|
|
165
|
+
|
|
166
|
+
Device side: [`@freecorps/ember-expo-push`](../expo-push/README.md).
|
|
167
|
+
|
|
168
|
+
## Tests
|
|
169
|
+
|
|
170
|
+
`bun run test` in this folder:
|
|
171
|
+
- **Fake APNs and FCM servers** (`test/fakes.ts`) verify the ES256 provider token and the RS256 OAuth assertion with
|
|
172
|
+
WebCrypto, check headers, paths and payloads, and answer per token (`Unregistered`, `BadDeviceToken`, sandbox-only,
|
|
173
|
+
429, 503, `SENDER_ID_MISMATCH`...).
|
|
174
|
+
- Covered: request format, priority/TTL/interruption mapping, token reuse and refresh, error mapping and pruning,
|
|
175
|
+
environment probing, VoIP, retries, payload limits; routing and encryption across Expo/APNs/FCM; the component with
|
|
176
|
+
secrets from the env; the RFC 9180 A.3.1 vector (Brigade's test, ported) and envelope round trips.
|
|
177
|
+
- **workerd** (`vitest.workerd.config.ts`): the component in the real runtime (Worker + Shard DO). Miniflare's
|
|
178
|
+
`outboundService` routes every `fetch` to the fakes: `sendNow` from an action, and `push.send` from a mutation
|
|
179
|
+
delivered by the shard's alarm.
|
|
180
|
+
|
|
181
|
+
## Real-device testing (not done yet; no credentials were used)
|
|
182
|
+
|
|
183
|
+
What you need:
|
|
184
|
+
1. **Apple:** a paid Apple Developer account, and your **Team ID** (developer.apple.com → Membership).
|
|
185
|
+
2. **APNs key:** Certificates, IDs & Profiles → Keys → +, enable *Apple Push Notifications service (APNs)*. Download
|
|
186
|
+
`AuthKey_<KEYID>.p8` (only possible once) and note the **Key ID**. One key serves sandbox and production and every
|
|
187
|
+
app of the team.
|
|
188
|
+
3. **Bundle id** of the app, for example `com.acme.mail`. Its App ID needs *Push Notifications*, *App Groups*
|
|
189
|
+
(`group.<bundle id>.ember`) and, for `urgency: "high"`, *Time Sensitive Notifications*. The extension's App ID is
|
|
190
|
+
`<bundle id>.EmberNotificationService`, with the same App Group. EAS Build creates and syncs these from the
|
|
191
|
+
entitlements and the `appExtensions` entry the plugin writes. With local Xcode signing, enable them by hand.
|
|
192
|
+
4. **Firebase:** a project with an Android app for your package name. Download `google-services.json` (outside the
|
|
193
|
+
repository) and point `android.googleServicesFile` (or `GOOGLE_SERVICES_JSON` in the example) at it.
|
|
194
|
+
5. **FCM service account:** in Google Cloud IAM for that project, create a service account with role *Firebase Cloud
|
|
195
|
+
Messaging API Admin* (`roles/firebasecloudmessaging.admin`), make sure the *Firebase Cloud Messaging API* (v1) is
|
|
196
|
+
enabled, and create a JSON key. That key is `FCM_SERVICE_ACCOUNT`.
|
|
197
|
+
|
|
198
|
+
How to test, with the mail app or `apps/example-expo` with `EMBER_PUSH_DIRECT=1`:
|
|
199
|
+
1. Deploy the backend and set the secrets (above).
|
|
200
|
+
2. Build the app with the `@freecorps/ember-expo-push` plugin:
|
|
201
|
+
- EAS (`eas build --profile preview`): production APNs tokens.
|
|
202
|
+
- Local (`npx expo run:ios --device`): sandbox tokens.
|
|
203
|
+
- Android: any build with `google-services.json`.
|
|
204
|
+
3. Sign in and allow notifications. Check that the device row has the right `provider`, `environment` and a
|
|
205
|
+
`publicKey` (dashboard data browser, or `ember run`).
|
|
206
|
+
4. Send from another account. On a **locked** iPhone, the notification should show the real title and body (the NSE
|
|
207
|
+
decrypted it). Console.app, filtered on subsystem `<bundle id>.EmberNotificationService`, shows `push decrypted`.
|
|
208
|
+
On Android, test with the app in the foreground, in the background and swiped away (headless task).
|
|
209
|
+
5. Negative checks:
|
|
210
|
+
- Delete the app, then send: `Unregistered` or `BadDeviceToken`, and the device row is gone.
|
|
211
|
+
- Register a development build's token as `production`: `BadDeviceToken`, pruned.
|
|
212
|
+
- An iOS 16 phone, or a push sent before the app created its key: the fallback text.
|
|
213
|
+
- `urgency: "high"`: a Time Sensitive banner.
|
|
214
|
+
- Two sends with the same `collapseId`: one notification.
|
|
215
|
+
6. R12 open points to record (PLAN §18):
|
|
216
|
+
- APNs from both send paths: `sendNow` (the Worker) and `push.send` (Shard DO alarm, or the actions queue).
|
|
217
|
+
- No `TooManyProviderTokenUpdates` across many sends.
|
|
218
|
+
- Latency to the phone.
|
|
219
|
+
|
|
220
|
+
Not provided:
|
|
221
|
+
- A Brigade-relay transport: the relay's payload (`brigade` field, Brigade loc keys) is specific to Brigade's app,
|
|
222
|
+
and Ember instances send directly.
|
|
223
|
+
- Client-side PushKit (VoIP) registration: expo-notifications has no PushKit, so use a native module. The registry
|
|
224
|
+
and sending already support `voipToken`.
|
|
225
|
+
|
|
226
|
+
License: MIT. HPKE, base64url and the envelope format are adapted from Freecorps Brigade's `packages/push` (MIT).
|
|
227
|
+
The APNs, FCM, NSE and config-plugin code is written for Ember from Apple's, Google's and Expo's documentation.
|
|
228
|
+
Brigade's relay and mobile app are AGPL, so they were used only as documented behaviour (README, docs), never as
|
|
229
|
+
code.
|
package/dist/apns.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct APNs sender: token-based (`.p8`) authentication over HTTP/2, written for Ember from Apple's documentation
|
|
3
|
+
* ("Establishing a token-based connection to APNs", "Sending notification requests to APNs", "Handling notification
|
|
4
|
+
* responses from APNs", "Generating a remote notification"). Its observable behaviour — 40-minute provider-token
|
|
5
|
+
* reuse, priority/TTL per urgency, the per-device sandbox/production host and which answers drop a device — matches
|
|
6
|
+
* Freecorps Brigade's relay as its README describes it; no code is taken from the relay (AGPL).
|
|
7
|
+
*
|
|
8
|
+
* HTTP/2: APNs speaks nothing else. A deployed Worker's `fetch` reaches it (Brigade spike S1, Ember ops E2E R12);
|
|
9
|
+
* local workerd (`ember dev`) does not, and fails with a network error. Test locally with a fake `fetch`.
|
|
10
|
+
*/
|
|
11
|
+
import { type RetryOptions } from "./delivery.ts";
|
|
12
|
+
import type { AddressedPushMessage, ApnsEnvironment, PushErrorCode, PushFallback, PushProvider, PushTicket } from "./types.ts";
|
|
13
|
+
export declare const APNS_PRODUCTION_URL = "https://api.push.apple.com";
|
|
14
|
+
export declare const APNS_SANDBOX_URL = "https://api.sandbox.push.apple.com";
|
|
15
|
+
/** Payload limits (bytes): 4 KB for regular pushes, 5 KB for VoIP. */
|
|
16
|
+
export declare const APNS_MAX_PAYLOAD = 4096;
|
|
17
|
+
export declare const APNS_VOIP_MAX_PAYLOAD = 5120;
|
|
18
|
+
/**
|
|
19
|
+
* Provider tokens are reused for 40 minutes: APNs refuses one older than an hour (`ExpiredProviderToken`) and one
|
|
20
|
+
* refreshed more often than every 20 minutes (`TooManyProviderTokenUpdates`).
|
|
21
|
+
*/
|
|
22
|
+
export declare const APNS_TOKEN_REFRESH_MS: number;
|
|
23
|
+
export interface ApnsCredentials {
|
|
24
|
+
/** The `.p8` key's 10-character id (developer.apple.com → Keys). */
|
|
25
|
+
keyId: string;
|
|
26
|
+
/** The Apple Developer team id. */
|
|
27
|
+
teamId: string;
|
|
28
|
+
/** The `.p8` file's text (PKCS#8 PEM). */
|
|
29
|
+
key: string;
|
|
30
|
+
}
|
|
31
|
+
export interface ApnsOptions extends ApnsCredentials, RetryOptions {
|
|
32
|
+
/** The app's bundle id (`apns-topic`). VoIP pushes use `<topic>.voip`. */
|
|
33
|
+
topic: string;
|
|
34
|
+
/** Host for devices that registered no environment (default `production`). */
|
|
35
|
+
environment?: ApnsEnvironment;
|
|
36
|
+
/** Endpoint overrides (tests, an HTTP/2 proxy for local development). */
|
|
37
|
+
productionUrl?: string;
|
|
38
|
+
sandboxUrl?: string;
|
|
39
|
+
/** Text shown in clear for sealed pushes (default `DEFAULT_PUSH_FALLBACK`). */
|
|
40
|
+
fallback?: PushFallback;
|
|
41
|
+
/** Requests in flight at once (default 10). */
|
|
42
|
+
concurrency?: number;
|
|
43
|
+
fetch?: typeof fetch;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
/** A device token as `getDevicePushTokenAsync` returns it on iOS: hex, 32 bytes on devices (Simulator tokens are longer). */
|
|
47
|
+
export declare function isApnsToken(token: unknown): token is string;
|
|
48
|
+
/** What APNs answers mean for the caller (Apple's reason strings → Ember's codes). */
|
|
49
|
+
export declare function apnsErrorCode(status: number, reason: string | undefined): PushErrorCode;
|
|
50
|
+
/**
|
|
51
|
+
* The ES256 provider token for these credentials, reused for `APNS_TOKEN_REFRESH_MS` (per isolate). Concurrent callers
|
|
52
|
+
* share one signing (APNs answers `TooManyProviderTokenUpdates` when tokens change too often). `force` signs a new
|
|
53
|
+
* one: `true` always; a token string only if the cache still holds that token (after `ExpiredProviderToken`, so
|
|
54
|
+
* concurrent sends that saw the same stale token refresh once).
|
|
55
|
+
*/
|
|
56
|
+
export declare function apnsProviderToken(c: ApnsCredentials, now?: number, force?: boolean | string): Promise<string>;
|
|
57
|
+
/** Forgets cached provider tokens (tests; after rotating a key). */
|
|
58
|
+
export declare function clearApnsTokenCache(): void;
|
|
59
|
+
type PushType = "alert" | "background" | "voip";
|
|
60
|
+
export declare function apnsPushType(m: AddressedPushMessage): PushType;
|
|
61
|
+
/**
|
|
62
|
+
* The JSON body for one message. Custom `data` goes under `body`, where expo-notifications reads it on iOS
|
|
63
|
+
* (`notification.request.content.data`). A sealed message carries only the fallback text, `mutable-content` (so the
|
|
64
|
+
* Notification Service Extension runs) and the envelope under `ember`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function apnsPayload(m: AddressedPushMessage, fallback?: PushFallback): Record<string, unknown>;
|
|
67
|
+
/** The request headers for one message (without `authorization`). */
|
|
68
|
+
export declare function apnsHeaders(m: AddressedPushMessage, topic: string, nowMs: number): Record<string, string>;
|
|
69
|
+
/**
|
|
70
|
+
* Sends each message as its own request (APNs has no batch API), `concurrency` at a time. Never throws for message
|
|
71
|
+
* failures: they come back as error tickets with Ember's codes (`DeviceNotRegistered` for `Unregistered`, `ExpiredToken`
|
|
72
|
+
* and `BadDeviceToken`, which callers prune). A device with no recorded environment that gets `BadDeviceToken` is
|
|
73
|
+
* tried once on the other host; when that works, its ticket says which `environment` it was.
|
|
74
|
+
*/
|
|
75
|
+
export declare function sendApns(messages: AddressedPushMessage[], o: ApnsOptions): Promise<PushTicket[]>;
|
|
76
|
+
/** Direct APNs as a `PushProvider`. No receipts: APNs answers each request synchronously. */
|
|
77
|
+
export declare function apnsPushProvider(o: ApnsOptions): PushProvider;
|
|
78
|
+
export {};
|
package/dist/apns.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct APNs sender: token-based (`.p8`) authentication over HTTP/2, written for Ember from Apple's documentation
|
|
3
|
+
* ("Establishing a token-based connection to APNs", "Sending notification requests to APNs", "Handling notification
|
|
4
|
+
* responses from APNs", "Generating a remote notification"). Its observable behaviour — 40-minute provider-token
|
|
5
|
+
* reuse, priority/TTL per urgency, the per-device sandbox/production host and which answers drop a device — matches
|
|
6
|
+
* Freecorps Brigade's relay as its README describes it; no code is taken from the relay (AGPL).
|
|
7
|
+
*
|
|
8
|
+
* HTTP/2: APNs speaks nothing else. A deployed Worker's `fetch` reaches it (Brigade spike S1, Ember ops E2E R12);
|
|
9
|
+
* local workerd (`ember dev`) does not, and fails with a network error. Test locally with a fake `fetch`.
|
|
10
|
+
*/
|
|
11
|
+
import { DEFAULT_PUSH_FALLBACK, apnsSound, byteLength, deadlineSignal, fetchWithRetry, mapLimit, resolveInterruptionLevel, resolveTtlSeconds, resolveUrgency, untilAborted } from "./delivery.js";
|
|
12
|
+
import { importSigningKey, signJwt } from "./jwt.js";
|
|
13
|
+
export const APNS_PRODUCTION_URL = "https://api.push.apple.com";
|
|
14
|
+
export const APNS_SANDBOX_URL = "https://api.sandbox.push.apple.com";
|
|
15
|
+
/** Payload limits (bytes): 4 KB for regular pushes, 5 KB for VoIP. */
|
|
16
|
+
export const APNS_MAX_PAYLOAD = 4096;
|
|
17
|
+
export const APNS_VOIP_MAX_PAYLOAD = 5120;
|
|
18
|
+
/**
|
|
19
|
+
* Provider tokens are reused for 40 minutes: APNs refuses one older than an hour (`ExpiredProviderToken`) and one
|
|
20
|
+
* refreshed more often than every 20 minutes (`TooManyProviderTokenUpdates`).
|
|
21
|
+
*/
|
|
22
|
+
export const APNS_TOKEN_REFRESH_MS = 40 * 60_000;
|
|
23
|
+
/** A device token as `getDevicePushTokenAsync` returns it on iOS: hex, 32 bytes on devices (Simulator tokens are longer). */
|
|
24
|
+
export function isApnsToken(token) {
|
|
25
|
+
return typeof token === "string" && token.length % 2 === 0 && /^[0-9a-fA-F]{64,200}$/.test(token);
|
|
26
|
+
}
|
|
27
|
+
/** What APNs answers mean for the caller (Apple's reason strings → Ember's codes). */
|
|
28
|
+
export function apnsErrorCode(status, reason) {
|
|
29
|
+
switch (reason) {
|
|
30
|
+
case "Unregistered":
|
|
31
|
+
case "ExpiredToken":
|
|
32
|
+
case "BadDeviceToken":
|
|
33
|
+
return "DeviceNotRegistered";
|
|
34
|
+
case "DeviceTokenNotForTopic":
|
|
35
|
+
case "TopicDisallowed":
|
|
36
|
+
case "BadTopic":
|
|
37
|
+
case "MissingTopic":
|
|
38
|
+
return "MismatchSenderId";
|
|
39
|
+
case "ExpiredProviderToken":
|
|
40
|
+
case "InvalidProviderToken":
|
|
41
|
+
case "MissingProviderToken":
|
|
42
|
+
case "Forbidden":
|
|
43
|
+
case "BadCertificate":
|
|
44
|
+
case "BadCertificateEnvironment":
|
|
45
|
+
case "BadEnvironmentKeyInToken":
|
|
46
|
+
case "UnrelatedKeyIdInToken":
|
|
47
|
+
return "InvalidCredentials";
|
|
48
|
+
case "PayloadTooLarge":
|
|
49
|
+
return "MessageTooBig";
|
|
50
|
+
case "TooManyRequests":
|
|
51
|
+
case "TooManyProviderTokenUpdates":
|
|
52
|
+
return "MessageRateExceeded";
|
|
53
|
+
}
|
|
54
|
+
if (status === 410)
|
|
55
|
+
return "DeviceNotRegistered";
|
|
56
|
+
if (status === 403)
|
|
57
|
+
return "InvalidCredentials";
|
|
58
|
+
if (status === 413)
|
|
59
|
+
return "MessageTooBig";
|
|
60
|
+
if (status === 429)
|
|
61
|
+
return "MessageRateExceeded";
|
|
62
|
+
return "RequestFailed";
|
|
63
|
+
}
|
|
64
|
+
const providerTokens = new Map();
|
|
65
|
+
const signing = new Map();
|
|
66
|
+
/**
|
|
67
|
+
* The ES256 provider token for these credentials, reused for `APNS_TOKEN_REFRESH_MS` (per isolate). Concurrent callers
|
|
68
|
+
* share one signing (APNs answers `TooManyProviderTokenUpdates` when tokens change too often). `force` signs a new
|
|
69
|
+
* one: `true` always; a token string only if the cache still holds that token (after `ExpiredProviderToken`, so
|
|
70
|
+
* concurrent sends that saw the same stale token refresh once).
|
|
71
|
+
*/
|
|
72
|
+
export async function apnsProviderToken(c, now = Date.now(), force = false) {
|
|
73
|
+
const cacheKey = `${c.teamId}\u0000${c.keyId}\u0000${c.key}`;
|
|
74
|
+
const hit = providerTokens.get(cacheKey);
|
|
75
|
+
const fresh = !!hit && now - hit.issuedAt < APNS_TOKEN_REFRESH_MS && now >= hit.issuedAt;
|
|
76
|
+
const refresh = force === true || (typeof force === "string" && hit?.token === force);
|
|
77
|
+
if (fresh && !refresh)
|
|
78
|
+
return hit.token;
|
|
79
|
+
const pending = signing.get(cacheKey);
|
|
80
|
+
if (pending)
|
|
81
|
+
return pending;
|
|
82
|
+
const p = (async () => {
|
|
83
|
+
const key = await importSigningKey(c.key, "ES256");
|
|
84
|
+
const token = await signJwt("ES256", key, { iss: c.teamId, iat: Math.floor(now / 1000) }, { kid: c.keyId });
|
|
85
|
+
providerTokens.set(cacheKey, { token, issuedAt: now });
|
|
86
|
+
return token;
|
|
87
|
+
})().finally(() => signing.delete(cacheKey));
|
|
88
|
+
signing.set(cacheKey, p);
|
|
89
|
+
return p;
|
|
90
|
+
}
|
|
91
|
+
/** Forgets cached provider tokens (tests; after rotating a key). */
|
|
92
|
+
export function clearApnsTokenCache() {
|
|
93
|
+
providerTokens.clear();
|
|
94
|
+
signing.clear();
|
|
95
|
+
}
|
|
96
|
+
export function apnsPushType(m) {
|
|
97
|
+
if (m.voip)
|
|
98
|
+
return "voip";
|
|
99
|
+
if (m.payload?.apns) {
|
|
100
|
+
// The app's own body: an alert unless it is only `content-available`.
|
|
101
|
+
const aps = (m.payload.apns.aps ?? {});
|
|
102
|
+
const shows = aps.alert !== undefined || aps.sound !== undefined || aps.badge !== undefined;
|
|
103
|
+
return !shows && aps["content-available"] === 1 ? "background" : "alert";
|
|
104
|
+
}
|
|
105
|
+
if (m.sealed)
|
|
106
|
+
return "alert";
|
|
107
|
+
const visible = !!(m.title || m.subtitle || m.body || m.sound || m.badge !== undefined);
|
|
108
|
+
return !visible && m.contentAvailable ? "background" : "alert";
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The JSON body for one message. Custom `data` goes under `body`, where expo-notifications reads it on iOS
|
|
112
|
+
* (`notification.request.content.data`). A sealed message carries only the fallback text, `mutable-content` (so the
|
|
113
|
+
* Notification Service Extension runs) and the envelope under `ember`.
|
|
114
|
+
*/
|
|
115
|
+
export function apnsPayload(m, fallback = DEFAULT_PUSH_FALLBACK) {
|
|
116
|
+
// The app built its own body (its envelope, its keys): sent as given.
|
|
117
|
+
if (m.payload?.apns)
|
|
118
|
+
return m.payload.apns;
|
|
119
|
+
if (m.voip) {
|
|
120
|
+
const out = { aps: {} };
|
|
121
|
+
if (m.sealed)
|
|
122
|
+
out.ember = m.sealed;
|
|
123
|
+
else if (m.data)
|
|
124
|
+
out.body = m.data;
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
const aps = {};
|
|
128
|
+
if (m.sealed) {
|
|
129
|
+
const alert = {};
|
|
130
|
+
if (fallback.titleLocKey)
|
|
131
|
+
alert["title-loc-key"] = fallback.titleLocKey;
|
|
132
|
+
else if (fallback.title)
|
|
133
|
+
alert.title = fallback.title;
|
|
134
|
+
if (fallback.locKey)
|
|
135
|
+
alert["loc-key"] = fallback.locKey;
|
|
136
|
+
else if (fallback.body)
|
|
137
|
+
alert.body = fallback.body;
|
|
138
|
+
aps.alert = alert;
|
|
139
|
+
aps["mutable-content"] = 1;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const alert = {};
|
|
143
|
+
if (m.title)
|
|
144
|
+
alert.title = m.title;
|
|
145
|
+
if (m.subtitle)
|
|
146
|
+
alert.subtitle = m.subtitle;
|
|
147
|
+
if (m.body)
|
|
148
|
+
alert.body = m.body;
|
|
149
|
+
if (Object.keys(alert).length)
|
|
150
|
+
aps.alert = alert;
|
|
151
|
+
if (m.mutableContent)
|
|
152
|
+
aps["mutable-content"] = 1;
|
|
153
|
+
}
|
|
154
|
+
const sound = apnsSound(m.sound);
|
|
155
|
+
if (sound)
|
|
156
|
+
aps.sound = sound;
|
|
157
|
+
if (m.badge !== undefined)
|
|
158
|
+
aps.badge = m.badge;
|
|
159
|
+
if (m.threadId)
|
|
160
|
+
aps["thread-id"] = m.threadId;
|
|
161
|
+
if (m.categoryId)
|
|
162
|
+
aps.category = m.categoryId;
|
|
163
|
+
if (m.contentAvailable)
|
|
164
|
+
aps["content-available"] = 1;
|
|
165
|
+
const level = resolveInterruptionLevel(m);
|
|
166
|
+
if (level && aps.alert)
|
|
167
|
+
aps["interruption-level"] = level;
|
|
168
|
+
const out = { aps };
|
|
169
|
+
if (m.sealed)
|
|
170
|
+
out.ember = m.sealed;
|
|
171
|
+
else if (m.data)
|
|
172
|
+
out.body = m.data;
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
/** The request headers for one message (without `authorization`). */
|
|
176
|
+
export function apnsHeaders(m, topic, nowMs) {
|
|
177
|
+
const type = apnsPushType(m);
|
|
178
|
+
const ttl = resolveTtlSeconds(m);
|
|
179
|
+
// Background pushes must go at priority 5 (APNs refuses 10 for them).
|
|
180
|
+
const priority = type === "background" || resolveUrgency(m) === "low" ? "5" : "10";
|
|
181
|
+
const h = {
|
|
182
|
+
"content-type": "application/json",
|
|
183
|
+
"apns-topic": type === "voip" ? `${topic}.voip` : topic,
|
|
184
|
+
"apns-push-type": type,
|
|
185
|
+
"apns-priority": priority,
|
|
186
|
+
"apns-expiration": ttl === 0 ? "0" : String(Math.floor(nowMs / 1000) + ttl),
|
|
187
|
+
};
|
|
188
|
+
if (m.collapseId)
|
|
189
|
+
h["apns-collapse-id"] = m.collapseId;
|
|
190
|
+
return h;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Sends each message as its own request (APNs has no batch API), `concurrency` at a time. Never throws for message
|
|
194
|
+
* failures: they come back as error tickets with Ember's codes (`DeviceNotRegistered` for `Unregistered`, `ExpiredToken`
|
|
195
|
+
* and `BadDeviceToken`, which callers prune). A device with no recorded environment that gets `BadDeviceToken` is
|
|
196
|
+
* tried once on the other host; when that works, its ticket says which `environment` it was.
|
|
197
|
+
*/
|
|
198
|
+
export async function sendApns(messages, o) {
|
|
199
|
+
const fetchFn = (o.fetch ?? globalThis.fetch)?.bind(globalThis);
|
|
200
|
+
if (!fetchFn)
|
|
201
|
+
throw new Error("@freecorps/ember-push: no fetch available; pass options.fetch");
|
|
202
|
+
const now = o.now ?? Date.now;
|
|
203
|
+
const urlFor = (env) => (env === "sandbox" ? (o.sandboxUrl ?? APNS_SANDBOX_URL) : (o.productionUrl ?? APNS_PRODUCTION_URL));
|
|
204
|
+
const attempt = async (m, env, body, signal) => {
|
|
205
|
+
let refreshed = false;
|
|
206
|
+
let stale = false;
|
|
207
|
+
for (;;) {
|
|
208
|
+
const jwt = await untilAborted(apnsProviderToken(o, now(), stale), signal);
|
|
209
|
+
const res = await fetchWithRetry(fetchFn, `${urlFor(env)}/3/device/${m.to}`, () => ({ method: "POST", headers: { ...apnsHeaders(m, o.topic, now()), authorization: `bearer ${jwt}` }, body }), o, signal);
|
|
210
|
+
const id = res.headers.get("apns-id") ?? undefined;
|
|
211
|
+
if (res.ok) {
|
|
212
|
+
await res.body?.cancel().catch(() => { });
|
|
213
|
+
return { status: res.status, ...(id ? { id } : {}) };
|
|
214
|
+
}
|
|
215
|
+
let reason;
|
|
216
|
+
try {
|
|
217
|
+
reason = (await res.json())?.reason;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
reason = undefined;
|
|
221
|
+
}
|
|
222
|
+
if (reason === "ExpiredProviderToken" && !refreshed) {
|
|
223
|
+
refreshed = true;
|
|
224
|
+
stale = jwt;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (reason === "InvalidProviderToken")
|
|
228
|
+
clearApnsTokenCache(); // a fixed secret is picked up on the next send
|
|
229
|
+
return { status: res.status, ...(reason ? { reason } : {}), ...(id ? { id } : {}) };
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
const sendOne = async (m) => {
|
|
233
|
+
if (!isApnsToken(m.to))
|
|
234
|
+
return { token: String(m.to), status: "error", error: "InvalidToken", message: "not an APNs device token (hex)" };
|
|
235
|
+
if (m.collapseId && byteLength(m.collapseId) > 64)
|
|
236
|
+
return { token: m.to, status: "error", error: "RequestFailed", message: "collapseId is over 64 bytes (APNs BadCollapseId)" };
|
|
237
|
+
const body = JSON.stringify(apnsPayload(m, o.fallback));
|
|
238
|
+
const limit = m.voip ? APNS_VOIP_MAX_PAYLOAD : APNS_MAX_PAYLOAD;
|
|
239
|
+
if (byteLength(body) > limit)
|
|
240
|
+
return { token: m.to, status: "error", error: "MessageTooBig", message: `APNs payload over ${limit} bytes` };
|
|
241
|
+
const env = m.environment ?? o.environment ?? "production";
|
|
242
|
+
let r;
|
|
243
|
+
let usedEnv = env;
|
|
244
|
+
// One deadline for the whole send, both hosts included.
|
|
245
|
+
const signal = deadlineSignal(o);
|
|
246
|
+
try {
|
|
247
|
+
r = await attempt(m, env, body, signal);
|
|
248
|
+
if (!m.environment && r.reason === "BadDeviceToken") {
|
|
249
|
+
const other = env === "production" ? "sandbox" : "production";
|
|
250
|
+
const second = await attempt(m, other, body, signal);
|
|
251
|
+
if (second.status === 200 || second.reason !== "BadDeviceToken") {
|
|
252
|
+
r = second;
|
|
253
|
+
usedEnv = other;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (e) {
|
|
258
|
+
// A bad .p8 (importSigningKey) is the configuration's fault, not the network's.
|
|
259
|
+
const err = e;
|
|
260
|
+
if (err.credentials)
|
|
261
|
+
return { token: m.to, status: "error", error: "InvalidCredentials", message: `APNs key: ${err.message}` };
|
|
262
|
+
return { token: m.to, status: "error", error: "RequestFailed", message: `APNs unreachable: ${err.message}` };
|
|
263
|
+
}
|
|
264
|
+
if (r.status === 200) {
|
|
265
|
+
const ticket = { token: m.to, status: "ok", id: r.id ?? "" };
|
|
266
|
+
if (!m.environment && usedEnv !== env)
|
|
267
|
+
ticket.environment = usedEnv;
|
|
268
|
+
return ticket;
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
token: m.to,
|
|
272
|
+
status: "error",
|
|
273
|
+
error: apnsErrorCode(r.status, r.reason),
|
|
274
|
+
message: `APNs ${r.status} ${r.reason ?? ""}`.trim(),
|
|
275
|
+
...(r.reason ? { reason: r.reason } : {}),
|
|
276
|
+
httpStatus: r.status,
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
return mapLimit(messages, o.concurrency ?? 10, sendOne);
|
|
280
|
+
}
|
|
281
|
+
/** Direct APNs as a `PushProvider`. No receipts: APNs answers each request synchronously. */
|
|
282
|
+
export function apnsPushProvider(o) {
|
|
283
|
+
return { name: "apns", isToken: isApnsToken, send: (messages) => sendApns(messages, o) };
|
|
284
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Base64url without padding (RFC 4648, section 5), the encoding of every binary field in a push. */
|
|
2
|
+
export declare function toBase64url(value: Uint8Array): string;
|
|
3
|
+
/** Decodes base64url, padded or not. Throws on any other character. */
|
|
4
|
+
export declare function fromBase64url(value: string): Uint8Array<ArrayBuffer>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Ported from Freecorps Brigade, packages/push/src/base64url.ts (MIT License, Copyright (c) 2026 Freecorps Tecnologia da
|
|
2
|
+
// Informação LTDA; https://github.com/freecorps/brigade/tree/main/packages/push). Unchanged.
|
|
3
|
+
/** Base64url without padding (RFC 4648, section 5), the encoding of every binary field in a push. */
|
|
4
|
+
export function toBase64url(value) {
|
|
5
|
+
let binary = "";
|
|
6
|
+
for (const byte of value)
|
|
7
|
+
binary += String.fromCharCode(byte);
|
|
8
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
9
|
+
}
|
|
10
|
+
/** Decodes base64url, padded or not. Throws on any other character. */
|
|
11
|
+
export function fromBase64url(value) {
|
|
12
|
+
if (!/^[A-Za-z0-9_-]*={0,2}$/.test(value))
|
|
13
|
+
throw new Error("invalid base64url");
|
|
14
|
+
const binary = atob(value.replace(/=+$/, "").replace(/-/g, "+").replace(/_/g, "/"));
|
|
15
|
+
const out = new Uint8Array(binary.length);
|
|
16
|
+
for (let i = 0; i < binary.length; i++)
|
|
17
|
+
out[i] = binary.charCodeAt(i);
|
|
18
|
+
return out;
|
|
19
|
+
}
|