@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/README.md +204 -5
- package/dist/apns.d.ts +78 -0
- package/dist/apns.js +265 -0
- package/dist/base64url.d.ts +4 -0
- package/dist/base64url.js +19 -0
- package/dist/component.d.ts +288 -0
- package/dist/component.js +238 -0
- package/dist/delivery.d.ts +30 -0
- package/dist/delivery.js +69 -0
- package/dist/devices.d.ts +38 -2
- package/dist/devices.js +77 -12
- package/dist/direct.d.ts +69 -0
- package/dist/direct.js +136 -0
- package/dist/envelope.d.ts +62 -0
- package/dist/envelope.js +121 -0
- package/dist/expo.d.ts +7 -1
- package/dist/expo.js +12 -2
- package/dist/fcm.d.ts +68 -0
- package/dist/fcm.js +215 -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 +59 -3
- package/dist/types.js +0 -4
- package/package.json +13 -9
package/dist/devices.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import type { ApnsEnvironment } from "./types.ts";
|
|
1
2
|
export declare const PUSH_DEVICES_TABLE = "pushDevices";
|
|
2
|
-
|
|
3
|
+
export type PushProviderName = "expo" | "apns" | "fcm";
|
|
4
|
+
/** Whether `p` is a provider `sendToDevices` knows (`expo`, `apns`, `fcm`; exact case). */
|
|
5
|
+
export declare function isPushProviderName(p: unknown): p is PushProviderName;
|
|
6
|
+
/**
|
|
7
|
+
* `{ userId, token, provider, platform?, deviceName?, environment?, voipToken?, publicKey?, updatedAt }` with
|
|
8
|
+
* `by_user` and `by_token` indexes. The direct-transport fields are optional, so an existing table gains them with no
|
|
9
|
+
* migration.
|
|
10
|
+
*/
|
|
3
11
|
export declare function pushDevicesTable(): import("@freecorps/ember-server").TableDefinition<import("@freecorps/ember-values").ObjectValidator<{
|
|
4
12
|
userId: import("@freecorps/ember-values").Validator<string, "required", never>;
|
|
5
13
|
token: import("@freecorps/ember-values").Validator<string, "required", never>;
|
|
@@ -7,6 +15,12 @@ export declare function pushDevicesTable(): import("@freecorps/ember-server").Ta
|
|
|
7
15
|
provider: import("@freecorps/ember-values").Validator<string, "required", never>;
|
|
8
16
|
platform: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
9
17
|
deviceName: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
18
|
+
/** APNs environment of an iOS token: "sandbox" | "production". */
|
|
19
|
+
environment: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
20
|
+
/** iOS PushKit token (VoIP pushes, `voip: true`). */
|
|
21
|
+
voipToken: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
22
|
+
/** Device public key for sealed pushes (uncompressed P-256, base64url). */
|
|
23
|
+
publicKey: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
10
24
|
/** ms since epoch of the last registration (stale devices can be pruned). */
|
|
11
25
|
updatedAt: import("@freecorps/ember-values").Validator<number, "required", never>;
|
|
12
26
|
}>, {
|
|
@@ -21,6 +35,9 @@ export interface PushDevice {
|
|
|
21
35
|
provider: string;
|
|
22
36
|
platform?: string;
|
|
23
37
|
deviceName?: string;
|
|
38
|
+
environment?: ApnsEnvironment;
|
|
39
|
+
voipToken?: string;
|
|
40
|
+
publicKey?: string;
|
|
24
41
|
updatedAt: number;
|
|
25
42
|
}
|
|
26
43
|
/**
|
|
@@ -43,13 +60,20 @@ type WriterDb = Db & {
|
|
|
43
60
|
export interface RegisterPushDeviceInput {
|
|
44
61
|
userId: string;
|
|
45
62
|
token: string;
|
|
63
|
+
/** Default: inferred from the token (Expo token → `expo`, hex → `apns`, `platform: "android"` → `fcm`), else `expo`. */
|
|
46
64
|
provider?: string;
|
|
47
65
|
platform?: string;
|
|
48
66
|
deviceName?: string;
|
|
67
|
+
environment?: string;
|
|
68
|
+
voipToken?: string;
|
|
69
|
+
publicKey?: string;
|
|
49
70
|
}
|
|
71
|
+
/** The provider a token belongs to when the caller did not say. */
|
|
72
|
+
export declare function inferPushProvider(token: string, platform?: string): PushProviderName;
|
|
50
73
|
/**
|
|
51
74
|
* Upserts a device by token. A token re-registered by another user (shared device, account switch) moves to that
|
|
52
|
-
* user. Returns the device id.
|
|
75
|
+
* user. Fields left out keep their stored value. Returns the device id. Throws for an `environment` other than
|
|
76
|
+
* `sandbox`/`production`, a `provider` other than `expo`/`apns`/`fcm` or a malformed `publicKey`, so a bad registration fails at the client instead of at send time.
|
|
53
77
|
*/
|
|
54
78
|
export declare function registerPushDevice(ctx: {
|
|
55
79
|
db: WriterDb;
|
|
@@ -59,7 +83,19 @@ export declare function registerPushDevice(ctx: {
|
|
|
59
83
|
export declare function unregisterPushDevice(db: WriterDb, token: string, table?: string): boolean;
|
|
60
84
|
/** Removes every device with one of `tokens` (e.g. `unregisteredTokens` from `sendPush`/`checkPushReceipts`). */
|
|
61
85
|
export declare function removePushTokens(db: WriterDb, tokens: Iterable<string>, table?: string): number;
|
|
86
|
+
/** Clears dead VoIP tokens (`unregisteredVoipTokens` from `sendToDevices`); the devices keep their push token. */
|
|
87
|
+
export declare function removeVoipTokens(db: WriterDb, entries: Iterable<{
|
|
88
|
+
userId: string;
|
|
89
|
+
voipToken: string;
|
|
90
|
+
}>, table?: string): number;
|
|
91
|
+
/** Records the APNs environment a send discovered for a device registered without one. */
|
|
92
|
+
export declare function setPushDeviceEnvironment(db: WriterDb, token: string, environment: ApnsEnvironment, table?: string): boolean;
|
|
62
93
|
export declare function pushDevicesForUser(db: Db, userId: string, table?: string): PushDevice[];
|
|
94
|
+
/** Devices of these users (one per token), optionally of one provider: what `sendToDevices` takes. */
|
|
95
|
+
export declare function pushDevicesForUsers(db: Db, userIds: Iterable<string>, opts?: {
|
|
96
|
+
provider?: string;
|
|
97
|
+
table?: string;
|
|
98
|
+
}): PushDevice[];
|
|
63
99
|
/** Distinct tokens of these users, optionally of one provider. */
|
|
64
100
|
export declare function pushTokensForUsers(db: Db, userIds: Iterable<string>, opts?: {
|
|
65
101
|
provider?: string;
|
package/dist/devices.js
CHANGED
|
@@ -10,11 +10,26 @@
|
|
|
10
10
|
* args: { token: v.string(), platform: v.optional(v.string()) },
|
|
11
11
|
* handler: (ctx, args) => registerPushDevice(ctx, { userId: requireUser(ctx).subject, ...args }),
|
|
12
12
|
* });
|
|
13
|
+
*
|
|
14
|
+
* A device is one push token: an Expo token (`provider: "expo"`), or a native one for the direct transport (`apns`
|
|
15
|
+
* with its `environment`, `fcm`). Direct devices may also carry a PushKit `voipToken` and the `publicKey` their pushes
|
|
16
|
+
* are sealed to (end-to-end encryption, `@freecorps/ember-expo-push`).
|
|
13
17
|
*/
|
|
14
18
|
import { defineTable } from "@freecorps/ember-server";
|
|
15
19
|
import { v } from "@freecorps/ember-values";
|
|
20
|
+
import { isApnsToken } from "./apns.js";
|
|
21
|
+
import { isDevicePublicKey } from "./envelope.js";
|
|
22
|
+
import { isExpoPushToken } from "./expo.js";
|
|
16
23
|
export const PUSH_DEVICES_TABLE = "pushDevices";
|
|
17
|
-
/** `
|
|
24
|
+
/** Whether `p` is a provider `sendToDevices` knows (`expo`, `apns`, `fcm`; exact case). */
|
|
25
|
+
export function isPushProviderName(p) {
|
|
26
|
+
return p === "expo" || p === "apns" || p === "fcm";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* `{ userId, token, provider, platform?, deviceName?, environment?, voipToken?, publicKey?, updatedAt }` with
|
|
30
|
+
* `by_user` and `by_token` indexes. The direct-transport fields are optional, so an existing table gains them with no
|
|
31
|
+
* migration.
|
|
32
|
+
*/
|
|
18
33
|
export function pushDevicesTable() {
|
|
19
34
|
return defineTable({
|
|
20
35
|
userId: v.string(),
|
|
@@ -23,6 +38,12 @@ export function pushDevicesTable() {
|
|
|
23
38
|
provider: v.string(),
|
|
24
39
|
platform: v.optional(v.string()),
|
|
25
40
|
deviceName: v.optional(v.string()),
|
|
41
|
+
/** APNs environment of an iOS token: "sandbox" | "production". */
|
|
42
|
+
environment: v.optional(v.string()),
|
|
43
|
+
/** iOS PushKit token (VoIP pushes, `voip: true`). */
|
|
44
|
+
voipToken: v.optional(v.string()),
|
|
45
|
+
/** Device public key for sealed pushes (uncompressed P-256, base64url). */
|
|
46
|
+
publicKey: v.optional(v.string()),
|
|
26
47
|
/** ms since epoch of the last registration (stale devices can be pruned). */
|
|
27
48
|
updatedAt: v.number(),
|
|
28
49
|
})
|
|
@@ -30,23 +51,42 @@ export function pushDevicesTable() {
|
|
|
30
51
|
.index("by_token", ["token"]);
|
|
31
52
|
}
|
|
32
53
|
const byToken = (db, table, token) => db.query(table).withIndex("by_token", (q) => q.eq("token", token)).unique();
|
|
54
|
+
/** The provider a token belongs to when the caller did not say. */
|
|
55
|
+
export function inferPushProvider(token, platform) {
|
|
56
|
+
if (isExpoPushToken(token))
|
|
57
|
+
return "expo";
|
|
58
|
+
if (isApnsToken(token))
|
|
59
|
+
return "apns";
|
|
60
|
+
if (platform === "android")
|
|
61
|
+
return "fcm";
|
|
62
|
+
return "expo";
|
|
63
|
+
}
|
|
33
64
|
/**
|
|
34
65
|
* Upserts a device by token. A token re-registered by another user (shared device, account switch) moves to that
|
|
35
|
-
* user. Returns the device id.
|
|
66
|
+
* user. Fields left out keep their stored value. Returns the device id. Throws for an `environment` other than
|
|
67
|
+
* `sandbox`/`production`, a `provider` other than `expo`/`apns`/`fcm` or a malformed `publicKey`, so a bad registration fails at the client instead of at send time.
|
|
36
68
|
*/
|
|
37
69
|
export function registerPushDevice(ctx, input, table = PUSH_DEVICES_TABLE) {
|
|
38
70
|
if (!input.token)
|
|
39
71
|
throw new Error("registerPushDevice: empty token");
|
|
72
|
+
if (input.environment !== undefined && input.environment !== "sandbox" && input.environment !== "production") {
|
|
73
|
+
throw new Error(`registerPushDevice: environment must be "sandbox" or "production", got ${JSON.stringify(input.environment)}`);
|
|
74
|
+
}
|
|
75
|
+
if (input.provider !== undefined && !isPushProviderName(input.provider)) {
|
|
76
|
+
throw new Error(`registerPushDevice: provider must be "expo", "apns" or "fcm", got ${JSON.stringify(input.provider)}`);
|
|
77
|
+
}
|
|
78
|
+
if (input.publicKey !== undefined && !isDevicePublicKey(input.publicKey)) {
|
|
79
|
+
throw new Error("registerPushDevice: publicKey must be an uncompressed P-256 point (65 bytes, base64url)");
|
|
80
|
+
}
|
|
40
81
|
const fields = {
|
|
41
82
|
userId: input.userId,
|
|
42
83
|
token: input.token,
|
|
43
|
-
provider: input.provider ??
|
|
84
|
+
provider: input.provider ?? inferPushProvider(input.token, input.platform),
|
|
44
85
|
updatedAt: ctx.now,
|
|
45
86
|
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
fields.deviceName = input.deviceName;
|
|
87
|
+
for (const f of ["platform", "deviceName", "environment", "voipToken", "publicKey"])
|
|
88
|
+
if (input[f] !== undefined)
|
|
89
|
+
fields[f] = input[f];
|
|
50
90
|
const existing = byToken(ctx.db, table, input.token);
|
|
51
91
|
if (existing) {
|
|
52
92
|
ctx.db.patch(existing._id, fields);
|
|
@@ -69,15 +109,40 @@ export function removePushTokens(db, tokens, table = PUSH_DEVICES_TABLE) {
|
|
|
69
109
|
n++;
|
|
70
110
|
return n;
|
|
71
111
|
}
|
|
112
|
+
/** Clears dead VoIP tokens (`unregisteredVoipTokens` from `sendToDevices`); the devices keep their push token. */
|
|
113
|
+
export function removeVoipTokens(db, entries, table = PUSH_DEVICES_TABLE) {
|
|
114
|
+
let n = 0;
|
|
115
|
+
for (const e of entries) {
|
|
116
|
+
for (const d of pushDevicesForUser(db, e.userId, table)) {
|
|
117
|
+
if (d.voipToken !== e.voipToken)
|
|
118
|
+
continue;
|
|
119
|
+
db.patch(d._id, { voipToken: undefined }); // undefined removes the field (Convex semantics)
|
|
120
|
+
n++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return n;
|
|
124
|
+
}
|
|
125
|
+
/** Records the APNs environment a send discovered for a device registered without one. */
|
|
126
|
+
export function setPushDeviceEnvironment(db, token, environment, table = PUSH_DEVICES_TABLE) {
|
|
127
|
+
const existing = byToken(db, table, token);
|
|
128
|
+
if (!existing || existing.environment === environment)
|
|
129
|
+
return false;
|
|
130
|
+
db.patch(existing._id, { environment });
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
72
133
|
export function pushDevicesForUser(db, userId, table = PUSH_DEVICES_TABLE) {
|
|
73
134
|
return db.query(table).withIndex("by_user", (q) => q.eq("userId", userId)).collect();
|
|
74
135
|
}
|
|
75
|
-
/**
|
|
76
|
-
export function
|
|
77
|
-
const out = new
|
|
136
|
+
/** Devices of these users (one per token), optionally of one provider: what `sendToDevices` takes. */
|
|
137
|
+
export function pushDevicesForUsers(db, userIds, opts = {}) {
|
|
138
|
+
const out = new Map();
|
|
78
139
|
for (const u of new Set(userIds))
|
|
79
140
|
for (const d of pushDevicesForUser(db, u, opts.table))
|
|
80
141
|
if (!opts.provider || d.provider === opts.provider)
|
|
81
|
-
out.
|
|
82
|
-
return [...out];
|
|
142
|
+
out.set(d.token, d);
|
|
143
|
+
return [...out.values()];
|
|
144
|
+
}
|
|
145
|
+
/** Distinct tokens of these users, optionally of one provider. */
|
|
146
|
+
export function pushTokensForUsers(db, userIds, opts = {}) {
|
|
147
|
+
return pushDevicesForUsers(db, userIds, opts).map((d) => d.token);
|
|
83
148
|
}
|
package/dist/direct.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending to registered devices through whichever transport each one uses: Expo Push for Expo tokens, APNs or FCM
|
|
3
|
+
* directly for native tokens (`provider` in the device table). Content is sealed per device when the device has a
|
|
4
|
+
* public key and encryption allows it (see `PushEncryption`); APNs and FCM then see only the envelope and the fallback
|
|
5
|
+
* text.
|
|
6
|
+
*
|
|
7
|
+
* const devices = await ctx.runQuery(internal.push.devicesOf, { userIds });
|
|
8
|
+
* const r = await sendToDevices(devices, { title: "Ana Souza", body: "Re: orçamento", urgency: "high" }, {
|
|
9
|
+
* apns: { keyId: env.APNS_KEY_ID, teamId: env.APNS_TEAM_ID, key: env.APNS_KEY, topic: "com.acme.mail" },
|
|
10
|
+
* fcm: { serviceAccount: env.FCM_SERVICE_ACCOUNT },
|
|
11
|
+
* encryption: "required",
|
|
12
|
+
* });
|
|
13
|
+
* // r.unregisteredTokens / r.unregisteredVoipTokens → prune; r.environments → record; r.expoTickets → receipts later
|
|
14
|
+
*/
|
|
15
|
+
import { type ApnsOptions } from "./apns.ts";
|
|
16
|
+
import { type ExpoPushOptions } from "./expo.ts";
|
|
17
|
+
import { type FcmOptions } from "./fcm.ts";
|
|
18
|
+
import type { ApnsEnvironment, PushEncryption, PushFallback, PushMessage, PushTicket } from "./types.ts";
|
|
19
|
+
/** What `sendToDevices` needs of a device (a `PushDevice` row fits). */
|
|
20
|
+
export interface PushTarget {
|
|
21
|
+
token: string;
|
|
22
|
+
provider?: string;
|
|
23
|
+
platform?: string;
|
|
24
|
+
environment?: string;
|
|
25
|
+
publicKey?: string;
|
|
26
|
+
voipToken?: string;
|
|
27
|
+
userId?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface PushTransports {
|
|
30
|
+
/** Expo Push options; `false` refuses Expo devices. Default `{}` (enabled, no access token). */
|
|
31
|
+
expo?: ExpoPushOptions | false;
|
|
32
|
+
/** Direct APNs; without it, `apns` devices get `ProviderNotConfigured` tickets (never pruned). */
|
|
33
|
+
apns?: ApnsOptions;
|
|
34
|
+
/** Direct FCM; without it, `fcm` devices get `ProviderNotConfigured` tickets. */
|
|
35
|
+
fcm?: FcmOptions;
|
|
36
|
+
/** Default `auto`; a message's `encryption` wins. */
|
|
37
|
+
encryption?: PushEncryption;
|
|
38
|
+
/** What sealed pushes show in clear (and `required` sends to devices without a key). */
|
|
39
|
+
fallback?: PushFallback;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
}
|
|
42
|
+
export interface DeviceSendResult {
|
|
43
|
+
/** One per target (VoIP sends: one per target with a VoIP token), in order. */
|
|
44
|
+
tickets: PushTicket[];
|
|
45
|
+
/** Push tokens to delete (`DeviceNotRegistered` or a malformed token). */
|
|
46
|
+
unregisteredTokens: string[];
|
|
47
|
+
/** VoIP tokens APNs no longer accepts: clear them (`removeVoipTokens`), the device keeps its push token. */
|
|
48
|
+
unregisteredVoipTokens: Array<{
|
|
49
|
+
userId: string;
|
|
50
|
+
voipToken: string;
|
|
51
|
+
}>;
|
|
52
|
+
/** APNs environments discovered for devices registered without one: record them (`setPushDeviceEnvironment`). */
|
|
53
|
+
environments: Array<{
|
|
54
|
+
token: string;
|
|
55
|
+
environment: ApnsEnvironment;
|
|
56
|
+
}>;
|
|
57
|
+
/** Expo `ok` tickets, for `checkPushReceipts` later (APNs/FCM answers are final). */
|
|
58
|
+
expoTickets: PushTicket[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Sends `message` to every target (duplicate tokens once). Never throws for per-device failures: they come back as
|
|
62
|
+
* error tickets (a provider other than `expo`/`apns`/`fcm` is `ProviderNotConfigured`, like one with no credentials). Encryption, per device:
|
|
63
|
+
* - `off`: clear;
|
|
64
|
+
* - `auto`: sealed when the device is `apns`/`fcm` and registered a public key, else clear;
|
|
65
|
+
* - `required`: sealed, or the fallback text only (no data) for an Expo device or one without a key.
|
|
66
|
+
* Only pushes with something to show (title/subtitle/body) are sealed; background (`contentAvailable` only) and VoIP
|
|
67
|
+
* pushes go as they are under `auto`, and lose their `data` under `required`.
|
|
68
|
+
*/
|
|
69
|
+
export declare function sendToDevices(targets: Iterable<PushTarget>, message: PushMessage, t?: PushTransports): Promise<DeviceSendResult>;
|
package/dist/direct.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending to registered devices through whichever transport each one uses: Expo Push for Expo tokens, APNs or FCM
|
|
3
|
+
* directly for native tokens (`provider` in the device table). Content is sealed per device when the device has a
|
|
4
|
+
* public key and encryption allows it (see `PushEncryption`); APNs and FCM then see only the envelope and the fallback
|
|
5
|
+
* text.
|
|
6
|
+
*
|
|
7
|
+
* const devices = await ctx.runQuery(internal.push.devicesOf, { userIds });
|
|
8
|
+
* const r = await sendToDevices(devices, { title: "Ana Souza", body: "Re: orçamento", urgency: "high" }, {
|
|
9
|
+
* apns: { keyId: env.APNS_KEY_ID, teamId: env.APNS_TEAM_ID, key: env.APNS_KEY, topic: "com.acme.mail" },
|
|
10
|
+
* fcm: { serviceAccount: env.FCM_SERVICE_ACCOUNT },
|
|
11
|
+
* encryption: "required",
|
|
12
|
+
* });
|
|
13
|
+
* // r.unregisteredTokens / r.unregisteredVoipTokens → prune; r.environments → record; r.expoTickets → receipts later
|
|
14
|
+
*/
|
|
15
|
+
import { sendApns } from "./apns.js";
|
|
16
|
+
import { DEFAULT_PUSH_FALLBACK } from "./delivery.js";
|
|
17
|
+
import { sealPushContent } from "./envelope.js";
|
|
18
|
+
import { sendExpoMessages } from "./expo.js";
|
|
19
|
+
import { sendFcm } from "./fcm.js";
|
|
20
|
+
import { inferPushProvider, isPushProviderName } from "./devices.js";
|
|
21
|
+
const hasVisibleContent = (m) => !!(m.title || m.subtitle || m.body);
|
|
22
|
+
/** The fallback-only version of a message: no title/subtitle/body/data beyond the fallback text. */
|
|
23
|
+
function fallbackOnly(m, fallback) {
|
|
24
|
+
const { title, subtitle, body, data, ...rest } = m;
|
|
25
|
+
void title;
|
|
26
|
+
void subtitle;
|
|
27
|
+
void body;
|
|
28
|
+
void data;
|
|
29
|
+
const out = { ...rest };
|
|
30
|
+
if (fallback.title)
|
|
31
|
+
out.title = fallback.title;
|
|
32
|
+
if (fallback.body)
|
|
33
|
+
out.body = fallback.body;
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Sends `message` to every target (duplicate tokens once). Never throws for per-device failures: they come back as
|
|
38
|
+
* error tickets (a provider other than `expo`/`apns`/`fcm` is `ProviderNotConfigured`, like one with no credentials). Encryption, per device:
|
|
39
|
+
* - `off`: clear;
|
|
40
|
+
* - `auto`: sealed when the device is `apns`/`fcm` and registered a public key, else clear;
|
|
41
|
+
* - `required`: sealed, or the fallback text only (no data) for an Expo device or one without a key.
|
|
42
|
+
* Only pushes with something to show (title/subtitle/body) are sealed; background (`contentAvailable` only) and VoIP
|
|
43
|
+
* pushes go as they are under `auto`, and lose their `data` under `required`.
|
|
44
|
+
*/
|
|
45
|
+
export async function sendToDevices(targets, message, t = {}) {
|
|
46
|
+
const mode = message.encryption ?? t.encryption ?? "auto";
|
|
47
|
+
const fallback = t.fallback ?? DEFAULT_PUSH_FALLBACK;
|
|
48
|
+
const now = t.now ?? Date.now;
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
// `provider` is whatever the device row holds: anything but expo/apns/fcm gets an error ticket below.
|
|
51
|
+
const list = [];
|
|
52
|
+
for (const target of targets) {
|
|
53
|
+
const provider = target.provider ?? inferPushProvider(target.token, target.platform);
|
|
54
|
+
let to = target.token;
|
|
55
|
+
if (message.voip) {
|
|
56
|
+
if (provider !== "apns" || !target.voipToken)
|
|
57
|
+
continue;
|
|
58
|
+
to = target.voipToken;
|
|
59
|
+
}
|
|
60
|
+
if (seen.has(to))
|
|
61
|
+
continue;
|
|
62
|
+
seen.add(to);
|
|
63
|
+
list.push({ target, provider, to });
|
|
64
|
+
}
|
|
65
|
+
const tickets = new Array(list.length);
|
|
66
|
+
const groups = { expo: [], apns: [], fcm: [] };
|
|
67
|
+
await Promise.all(list.map(async ({ target, provider, to }, i) => {
|
|
68
|
+
if (!isPushProviderName(provider)) {
|
|
69
|
+
tickets[i] = { token: to, status: "error", error: "ProviderNotConfigured", message: `unknown push provider ${JSON.stringify(provider)} (expected expo, apns or fcm)` };
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let m = { ...message, to };
|
|
73
|
+
if (provider === "apns" && (target.environment === "sandbox" || target.environment === "production"))
|
|
74
|
+
m.environment = target.environment;
|
|
75
|
+
const direct = provider === "apns" || provider === "fcm";
|
|
76
|
+
const sealable = direct && !message.voip && hasVisibleContent(message);
|
|
77
|
+
if (mode !== "off" && sealable && target.publicKey) {
|
|
78
|
+
try {
|
|
79
|
+
const content = {};
|
|
80
|
+
for (const f of ["title", "subtitle", "body", "data"])
|
|
81
|
+
if (message[f] !== undefined)
|
|
82
|
+
content[f] = message[f];
|
|
83
|
+
m.sealed = await sealPushContent(target.publicKey, content, { now: now() });
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
const msg = e.message;
|
|
87
|
+
tickets[i] = { token: to, status: "error", error: /over \d+/.test(msg) ? "MessageTooBig" : "RequestFailed", message: `cannot seal: ${msg}` };
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else if (mode === "required") {
|
|
92
|
+
const { data, ...withoutData } = message;
|
|
93
|
+
void data;
|
|
94
|
+
const base = hasVisibleContent(message) ? fallbackOnly(message, fallback) : withoutData;
|
|
95
|
+
m = { ...base, to, ...(m.environment ? { environment: m.environment } : {}) };
|
|
96
|
+
}
|
|
97
|
+
groups[provider].push({ i, m });
|
|
98
|
+
}));
|
|
99
|
+
const run = async (provider, send) => {
|
|
100
|
+
const g = groups[provider];
|
|
101
|
+
if (!g.length)
|
|
102
|
+
return;
|
|
103
|
+
if (!send) {
|
|
104
|
+
for (const { i, m } of g)
|
|
105
|
+
tickets[i] = { token: m.to, status: "error", error: "ProviderNotConfigured", message: `no ${provider} credentials configured` };
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const out = await send(g.map((x) => x.m));
|
|
109
|
+
g.forEach(({ i }, k) => (tickets[i] = out[k]));
|
|
110
|
+
};
|
|
111
|
+
await Promise.all([
|
|
112
|
+
run("expo", t.expo === false ? null : (ms) => sendExpoMessages(ms, t.expo || {})),
|
|
113
|
+
run("apns", t.apns ? (ms) => sendApns(ms, { ...t.apns, fallback: t.apns.fallback ?? fallback }) : null),
|
|
114
|
+
run("fcm", t.fcm ? (ms) => sendFcm(ms, { ...t.fcm, fallback: t.fcm.fallback ?? fallback }) : null),
|
|
115
|
+
]);
|
|
116
|
+
const result = { tickets, unregisteredTokens: [], unregisteredVoipTokens: [], environments: [], expoTickets: [] };
|
|
117
|
+
const dead = new Set();
|
|
118
|
+
tickets.forEach((ticket, i) => {
|
|
119
|
+
const { target, provider } = list[i];
|
|
120
|
+
if (ticket.status === "ok") {
|
|
121
|
+
if (provider === "expo")
|
|
122
|
+
result.expoTickets.push(ticket);
|
|
123
|
+
if (ticket.environment && !message.voip)
|
|
124
|
+
result.environments.push({ token: target.token, environment: ticket.environment });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (ticket.error !== "DeviceNotRegistered" && ticket.error !== "InvalidToken")
|
|
128
|
+
return;
|
|
129
|
+
if (message.voip)
|
|
130
|
+
result.unregisteredVoipTokens.push({ userId: target.userId ?? "", voipToken: ticket.token });
|
|
131
|
+
else
|
|
132
|
+
dead.add(target.token);
|
|
133
|
+
});
|
|
134
|
+
result.unregisteredTokens = [...dead];
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type HpkeRecipient } from "./hpke.ts";
|
|
2
|
+
/**
|
|
3
|
+
* What is sealed for one device: everything a person would read. APNs and FCM only see the envelope (and the
|
|
4
|
+
* fallback text the sender chose, which should say nothing).
|
|
5
|
+
*/
|
|
6
|
+
export interface EncryptedPushContent {
|
|
7
|
+
title?: string;
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
body?: string;
|
|
10
|
+
/** Delivered to the app after decryption (`notification.request.content.data` in expo-notifications). */
|
|
11
|
+
data?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
/** What opening an envelope gives: the content and when the server sealed it (Unix seconds). */
|
|
14
|
+
export interface OpenedPushContent extends EncryptedPushContent {
|
|
15
|
+
iat: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The encrypted push as it travels, in the APNs payload's `ember` field or the FCM data's `ember` field (as a JSON
|
|
19
|
+
* string): HPKE's encapsulated key and ciphertext, base64url, and `k`, the id of the device key it was sealed to, so a
|
|
20
|
+
* device holding a rotated key tries the right one.
|
|
21
|
+
*/
|
|
22
|
+
export interface PushEnvelope {
|
|
23
|
+
v: typeof EMBER_PUSH_ENVELOPE_VERSION;
|
|
24
|
+
k: string;
|
|
25
|
+
enc: string;
|
|
26
|
+
ct: string;
|
|
27
|
+
}
|
|
28
|
+
export declare const EMBER_PUSH_ENVELOPE_VERSION = 1;
|
|
29
|
+
/**
|
|
30
|
+
* HPKE `info`: binds every ciphertext to this protocol and version. The iOS Notification Service Extension and the
|
|
31
|
+
* Android decoder in `@freecorps/ember-expo-push` use the same string. Change it (and `v`) together everywhere.
|
|
32
|
+
*/
|
|
33
|
+
export declare const EMBER_PUSH_HPKE_INFO = "freecorps.ember push v1";
|
|
34
|
+
/**
|
|
35
|
+
* Plaintext limit in UTF-8 bytes (Brigade's): about 2.8 KB once sealed and base64url-encoded, which leaves room in
|
|
36
|
+
* APNs' and FCM's 4 KB for the fallback text and the other fields.
|
|
37
|
+
*/
|
|
38
|
+
export declare const PUSH_PLAINTEXT_MAX_BYTES = 2048;
|
|
39
|
+
/** A key id: 8 base64url characters (`pushKeyId`). */
|
|
40
|
+
export declare const PUSH_KEY_ID: RegExp;
|
|
41
|
+
/**
|
|
42
|
+
* Seals `content` for a device, given its public key as the device registered it (base64url of the uncompressed
|
|
43
|
+
* P-256 point, 65 bytes). Throws over `PUSH_PLAINTEXT_MAX_BYTES` or for a malformed key.
|
|
44
|
+
*/
|
|
45
|
+
export declare function sealPushContent(devicePublicKey: string, content: EncryptedPushContent, opts?: {
|
|
46
|
+
now?: number;
|
|
47
|
+
}): Promise<PushEnvelope>;
|
|
48
|
+
/** Opens an envelope (what the device does; for tests and JS clients with WebCrypto). Throws if it does not open. */
|
|
49
|
+
export declare function openPushEnvelope(recipient: HpkeRecipient, envelope: PushEnvelope): Promise<OpenedPushContent>;
|
|
50
|
+
/** The plaintext of an envelope as a device accepts it; throws for anything else. */
|
|
51
|
+
export declare function parseOpenedContent(value: unknown): OpenedPushContent;
|
|
52
|
+
/**
|
|
53
|
+
* The id of a device key, as an envelope names it (`k`): base64url of the first 6 bytes of SHA-256 over the
|
|
54
|
+
* uncompressed public key, 8 characters (Brigade's `pushKeyId`). Only a hint for which key to try; the AEAD decides.
|
|
55
|
+
*/
|
|
56
|
+
export declare function pushKeyId(publicKey: string | Uint8Array): Promise<string>;
|
|
57
|
+
/** Accepts an envelope from untrusted JSON (or the JSON string FCM data carries), or returns null. */
|
|
58
|
+
export declare function parsePushEnvelope(value: unknown): PushEnvelope | null;
|
|
59
|
+
/** A device public key from its base64url form; throws unless it is an uncompressed P-256 point. */
|
|
60
|
+
export declare function parseDevicePublicKey(value: string): Uint8Array<ArrayBuffer>;
|
|
61
|
+
/** Cheap shape check for registration (the curve check happens when sealing). */
|
|
62
|
+
export declare function isDevicePublicKey(value: unknown): value is string;
|
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
|
@@ -31,7 +31,13 @@ export interface ExpoPushOptions {
|
|
|
31
31
|
}
|
|
32
32
|
/** `ExponentPushToken[...]` / `ExpoPushToken[...]` (or the older bare UUID form). */
|
|
33
33
|
export declare function isExpoPushToken(token: unknown): token is string;
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* The JSON Expo expects for one message. `collapseId`, `threadId` and `interruptionLevel` are Expo fields too and go
|
|
36
|
+
* as they are; `collapseId` is also Expo's Android `tag` (unless one is set) so an Android notification already shown
|
|
37
|
+
* is replaced, as with direct FCM. Fields only the direct transports understand (`urgency`, `encryption`, `voip`,
|
|
38
|
+
* `environment`, `sealed`) are not sent; `urgency` becomes Expo's `priority` when that is not set (`low` → `normal`,
|
|
39
|
+
* else `high`).
|
|
40
|
+
*/
|
|
35
41
|
export declare function toExpoMessage(m: AddressedPushMessage): Record<string, unknown>;
|
|
36
42
|
/** Payload size Expo checks against 4096 B: the notification fields plus `data`. */
|
|
37
43
|
export declare function payloadSize(m: PushMessage): number;
|
package/dist/expo.js
CHANGED
|
@@ -13,15 +13,25 @@ export function isExpoPushToken(token) {
|
|
|
13
13
|
return (/^Expo(?:nent)?PushToken\[[^\]]+\]$/.test(token) ||
|
|
14
14
|
/^[a-z\d]{8}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{12}$/i.test(token));
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* The JSON Expo expects for one message. `collapseId`, `threadId` and `interruptionLevel` are Expo fields too and go
|
|
18
|
+
* as they are; `collapseId` is also Expo's Android `tag` (unless one is set) so an Android notification already shown
|
|
19
|
+
* is replaced, as with direct FCM. Fields only the direct transports understand (`urgency`, `encryption`, `voip`,
|
|
20
|
+
* `environment`, `sealed`) are not sent; `urgency` becomes Expo's `priority` when that is not set (`low` → `normal`,
|
|
21
|
+
* else `high`).
|
|
22
|
+
*/
|
|
17
23
|
export function toExpoMessage(m) {
|
|
18
|
-
const { contentAvailable, ...rest } = m;
|
|
24
|
+
const { contentAvailable, urgency, encryption, voip, environment, sealed, ...rest } = m;
|
|
19
25
|
const out = {};
|
|
20
26
|
for (const [k, val] of Object.entries(rest))
|
|
21
27
|
if (val !== undefined)
|
|
22
28
|
out[k] = val;
|
|
23
29
|
if (contentAvailable)
|
|
24
30
|
out._contentAvailable = true;
|
|
31
|
+
if (urgency && out.priority === undefined)
|
|
32
|
+
out.priority = urgency === "low" ? "normal" : "high";
|
|
33
|
+
if (typeof out.collapseId === "string" && out.tag === undefined)
|
|
34
|
+
out.tag = out.collapseId;
|
|
25
35
|
return out;
|
|
26
36
|
}
|
|
27
37
|
const utf8Length = (s) => new TextEncoder().encode(s).length;
|