@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.
@@ -0,0 +1,126 @@
1
+ /** What a device shows for a sealed push it cannot open (and, with `encryption: "required"`, a device with no key). */
2
+ export const DEFAULT_PUSH_FALLBACK = { title: "New notification", body: "Open the app to see it." };
3
+ export const PUSH_TTL_DEFAULTS = { low: 4 * 3600, normal: 3600, high: 15 * 60 };
4
+ /** APNs keeps an undelivered push about 30 days at most (and FCM's limit is 28 days, which `fcm.ts` clamps to). */
5
+ export const PUSH_MAX_TTL_SECONDS = 30 * 24 * 3600;
6
+ export function resolveUrgency(m) {
7
+ if (m.urgency)
8
+ return m.urgency;
9
+ return m.priority === "normal" ? "low" : "normal";
10
+ }
11
+ /** Seconds the push service keeps trying: `ttl` if given (clamped to 0..30 days), else the urgency's default. */
12
+ export function resolveTtlSeconds(m) {
13
+ if (m.ttl === undefined || m.ttl === null || !Number.isFinite(m.ttl))
14
+ return PUSH_TTL_DEFAULTS[resolveUrgency(m)];
15
+ return Math.min(PUSH_MAX_TTL_SECONDS, Math.max(0, Math.floor(m.ttl)));
16
+ }
17
+ export function resolveInterruptionLevel(m) {
18
+ if (m.interruptionLevel)
19
+ return m.interruptionLevel;
20
+ if (isCriticalSound(m.sound))
21
+ return "critical";
22
+ return resolveUrgency(m) === "high" ? "time-sensitive" : undefined;
23
+ }
24
+ /** Whether `sound` asks for a Critical Alert. */
25
+ export function isCriticalSound(sound) {
26
+ return typeof sound === "object" && sound !== null && sound.critical === true;
27
+ }
28
+ /** APNs' `sound` value: the name, or the critical-sound dictionary. */
29
+ export function apnsSound(sound) {
30
+ if (!sound)
31
+ return undefined;
32
+ if (!isCriticalSound(sound))
33
+ return sound;
34
+ const volume = sound.volume === undefined ? 1 : Math.min(1, Math.max(0, sound.volume));
35
+ return { critical: 1, name: sound.name ?? "default", volume };
36
+ }
37
+ /** The sound name for transports with no critical alerts (Android, Expo). */
38
+ export function soundName(sound) {
39
+ if (!sound)
40
+ return undefined;
41
+ return isCriticalSound(sound) ? (sound.name ?? "default") : sound;
42
+ }
43
+ const utf8 = new TextEncoder();
44
+ export const byteLength = (s) => utf8.encode(s).length;
45
+ /** Runs `fn` over `items` with at most `limit` in flight, keeping the result order. */
46
+ export async function mapLimit(items, limit, fn) {
47
+ const out = new Array(items.length);
48
+ let next = 0;
49
+ const worker = async () => {
50
+ for (;;) {
51
+ const i = next++;
52
+ if (i >= items.length)
53
+ return;
54
+ out[i] = await fn(items[i], i);
55
+ }
56
+ };
57
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
58
+ return out;
59
+ }
60
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
61
+ /** The signal a send runs under: `signal` and `timeoutMs` together (undefined when neither is set). */
62
+ export function deadlineSignal(o) {
63
+ const parts = [];
64
+ if (o.signal)
65
+ parts.push(o.signal);
66
+ if (o.timeoutMs !== undefined)
67
+ parts.push(AbortSignal.timeout(Math.max(0, o.timeoutMs)));
68
+ if (parts.length <= 1)
69
+ return parts[0];
70
+ return AbortSignal.any(parts);
71
+ }
72
+ /** `p`, or a rejection with `signal`'s reason once it aborts (`p` itself goes on; its outcome is still observed). */
73
+ export function untilAborted(p, signal) {
74
+ if (!signal)
75
+ return p;
76
+ if (signal.aborted) {
77
+ p.catch(() => { });
78
+ return Promise.reject(signal.reason);
79
+ }
80
+ return new Promise((resolve, reject) => {
81
+ const onAbort = () => reject(signal.reason);
82
+ signal.addEventListener("abort", onAbort, { once: true });
83
+ p.then((v) => {
84
+ signal.removeEventListener("abort", onAbort);
85
+ resolve(v);
86
+ }, (e) => {
87
+ signal.removeEventListener("abort", onAbort);
88
+ reject(e);
89
+ });
90
+ });
91
+ }
92
+ /**
93
+ * `fetch` with retries for transient answers. A `Retry-After` header (seconds) wins over the backoff. The last
94
+ * response is returned whatever its status; a network error on the last try throws.
95
+ */
96
+ export async function fetchWithRetry(fetchFn, url, init, o = {},
97
+ // `null`: no deadline at all (a token exchange other sends share, which has its own); omitted: `o`'s.
98
+ deadline = deadlineSignal(o)) {
99
+ const signal = deadline ?? undefined;
100
+ const retries = o.retries ?? 2;
101
+ const sleep = (ms) => untilAborted((o.sleep ?? defaultSleep)(ms), signal);
102
+ let delay = o.retryDelayMs ?? 1000;
103
+ for (let attempt = 0;; attempt++) {
104
+ signal?.throwIfAborted();
105
+ let res;
106
+ try {
107
+ res = await fetchFn(url, signal ? { ...init(), signal } : init());
108
+ }
109
+ catch (e) {
110
+ // Aborted (the caller's signal or deadline): not a network error to retry.
111
+ if (signal?.aborted || attempt >= retries)
112
+ throw signal?.aborted ? signal.reason : e;
113
+ await sleep(delay);
114
+ delay *= 2;
115
+ continue;
116
+ }
117
+ if ((res.status === 429 || res.status >= 500) && attempt < retries) {
118
+ const ra = Number(res.headers.get("retry-after"));
119
+ await res.body?.cancel().catch(() => { });
120
+ await sleep(Number.isFinite(ra) && ra > 0 ? ra * 1000 : delay);
121
+ delay *= 2;
122
+ continue;
123
+ }
124
+ return res;
125
+ }
126
+ }
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
- /** `{ userId, token, provider, platform?, deviceName?, updatedAt }` with `by_user` and `by_token` indexes. */
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
- /** `{ userId, token, provider, platform?, deviceName?, updatedAt }` with `by_user` and `by_token` indexes. */
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 ?? "expo",
84
+ provider: input.provider ?? inferPushProvider(input.token, input.platform),
44
85
  updatedAt: ctx.now,
45
86
  };
46
- if (input.platform !== undefined)
47
- fields.platform = input.platform;
48
- if (input.deviceName !== undefined)
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
- /** Distinct tokens of these users, optionally of one provider. */
76
- export function pushTokensForUsers(db, userIds, opts = {}) {
77
- const out = new Set();
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.add(d.token);
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
  }
@@ -0,0 +1,75 @@
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, PushPayload, 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
+ /**
41
+ * An app with its own envelope: called for every direct (APNs/FCM) device, it returns the payload to send as given
42
+ * (`PushPayload`), or `null` for Ember's usual handling (its envelope, encryption mode and fallback). A throw is that
43
+ * device's `RequestFailed` ticket. Expo devices never reach it.
44
+ */
45
+ seal?: (device: PushTarget, message: PushMessage, provider: "apns" | "fcm") => PushPayload | null | Promise<PushPayload | null>;
46
+ now?: () => number;
47
+ }
48
+ export interface DeviceSendResult {
49
+ /** One per target (VoIP sends: one per target with a VoIP token), in order. */
50
+ tickets: PushTicket[];
51
+ /** Push tokens to delete (`DeviceNotRegistered` or a malformed token). */
52
+ unregisteredTokens: string[];
53
+ /** VoIP tokens APNs no longer accepts: clear them (`removeVoipTokens`), the device keeps its push token. */
54
+ unregisteredVoipTokens: Array<{
55
+ userId: string;
56
+ voipToken: string;
57
+ }>;
58
+ /** APNs environments discovered for devices registered without one: record them (`setPushDeviceEnvironment`). */
59
+ environments: Array<{
60
+ token: string;
61
+ environment: ApnsEnvironment;
62
+ }>;
63
+ /** Expo `ok` tickets, for `checkPushReceipts` later (APNs/FCM answers are final). */
64
+ expoTickets: PushTicket[];
65
+ }
66
+ /**
67
+ * Sends `message` to every target (duplicate tokens once). Never throws for per-device failures: they come back as
68
+ * error tickets (a provider other than `expo`/`apns`/`fcm` is `ProviderNotConfigured`, like one with no credentials). Encryption, per device:
69
+ * - `off`: clear;
70
+ * - `auto`: sealed when the device is `apns`/`fcm` and registered a public key, else clear;
71
+ * - `required`: sealed, or the fallback text only (no data) for an Expo device or one without a key.
72
+ * Only pushes with something to show (title/subtitle/body) are sealed; background (`contentAvailable` only) and VoIP
73
+ * pushes go as they are under `auto`, and lose their `data` under `required`.
74
+ */
75
+ export declare function sendToDevices(targets: Iterable<PushTarget>, message: PushMessage, t?: PushTransports): Promise<DeviceSendResult>;
package/dist/direct.js ADDED
@@ -0,0 +1,150 @@
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
+ if (direct && t.seal) {
77
+ let own;
78
+ try {
79
+ own = await t.seal(target, message, provider);
80
+ }
81
+ catch (e) {
82
+ tickets[i] = { token: to, status: "error", error: "RequestFailed", message: `seal hook: ${e.message}` };
83
+ return;
84
+ }
85
+ if (own) {
86
+ groups[provider].push({ i, m: { ...m, payload: own } });
87
+ return;
88
+ }
89
+ }
90
+ const sealable = direct && !message.voip && hasVisibleContent(message);
91
+ if (mode !== "off" && sealable && target.publicKey) {
92
+ try {
93
+ const content = {};
94
+ for (const f of ["title", "subtitle", "body", "data"])
95
+ if (message[f] !== undefined)
96
+ content[f] = message[f];
97
+ m.sealed = await sealPushContent(target.publicKey, content, { now: now() });
98
+ }
99
+ catch (e) {
100
+ const msg = e.message;
101
+ tickets[i] = { token: to, status: "error", error: /over \d+/.test(msg) ? "MessageTooBig" : "RequestFailed", message: `cannot seal: ${msg}` };
102
+ return;
103
+ }
104
+ }
105
+ else if (mode === "required") {
106
+ const { data, ...withoutData } = message;
107
+ void data;
108
+ const base = hasVisibleContent(message) ? fallbackOnly(message, fallback) : withoutData;
109
+ m = { ...base, to, ...(m.environment ? { environment: m.environment } : {}) };
110
+ }
111
+ groups[provider].push({ i, m });
112
+ }));
113
+ const run = async (provider, send) => {
114
+ const g = groups[provider];
115
+ if (!g.length)
116
+ return;
117
+ if (!send) {
118
+ for (const { i, m } of g)
119
+ tickets[i] = { token: m.to, status: "error", error: "ProviderNotConfigured", message: `no ${provider} credentials configured` };
120
+ return;
121
+ }
122
+ const out = await send(g.map((x) => x.m));
123
+ g.forEach(({ i }, k) => (tickets[i] = out[k]));
124
+ };
125
+ await Promise.all([
126
+ run("expo", t.expo === false ? null : (ms) => sendExpoMessages(ms, t.expo || {})),
127
+ run("apns", t.apns ? (ms) => sendApns(ms, { ...t.apns, fallback: t.apns.fallback ?? fallback }) : null),
128
+ run("fcm", t.fcm ? (ms) => sendFcm(ms, { ...t.fcm, fallback: t.fcm.fallback ?? fallback }) : null),
129
+ ]);
130
+ const result = { tickets, unregisteredTokens: [], unregisteredVoipTokens: [], environments: [], expoTickets: [] };
131
+ const dead = new Set();
132
+ tickets.forEach((ticket, i) => {
133
+ const { target, provider } = list[i];
134
+ if (ticket.status === "ok") {
135
+ if (provider === "expo")
136
+ result.expoTickets.push(ticket);
137
+ if (ticket.environment && !message.voip)
138
+ result.environments.push({ token: target.token, environment: ticket.environment });
139
+ return;
140
+ }
141
+ if (ticket.error !== "DeviceNotRegistered" && ticket.error !== "InvalidToken")
142
+ return;
143
+ if (message.voip)
144
+ result.unregisteredVoipTokens.push({ userId: target.userId ?? "", voipToken: ticket.token });
145
+ else
146
+ dead.add(target.token);
147
+ });
148
+ result.unregisteredTokens = [...dead];
149
+ return result;
150
+ }
@@ -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;