@voltro/plugin-notifications 0.85.0 → 0.87.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,97 @@
1
+ import { PushTokenRejected as e } from "./errors.js";
2
+ import { sign as t } from "node:crypto";
3
+ //#region src/apnsPush.ts
4
+ var n = {
5
+ production: "https://api.push.apple.com",
6
+ sandbox: "https://api.sandbox.push.apple.com"
7
+ }, r = 3e6, i = /* @__PURE__ */ new Set([
8
+ "BadDeviceToken",
9
+ "Unregistered",
10
+ "DeviceTokenNotForTopic",
11
+ "ExpiredToken"
12
+ ]), a = (e) => Buffer.from(e).toString("base64url"), o = (e, n) => {
13
+ let r = a(JSON.stringify({
14
+ alg: "ES256",
15
+ kid: e.keyId
16
+ })), i = a(JSON.stringify({
17
+ iss: e.teamId,
18
+ iat: Math.floor(n / 1e3)
19
+ }));
20
+ return `${r}.${i}.${a(t("sha256", Buffer.from(`${r}.${i}`), {
21
+ key: e.privateKey,
22
+ dsaEncoding: "ieee-p1363"
23
+ }))}`;
24
+ }, s = (e) => ({
25
+ aps: {
26
+ alert: {
27
+ title: e.title,
28
+ body: e.body
29
+ },
30
+ ...e.badge === void 0 ? {} : { badge: e.badge },
31
+ sound: "default",
32
+ ...e.image === void 0 ? {} : { "mutable-content": 1 },
33
+ ...e.actions !== void 0 && typeof e.data.notification == "string" ? { category: e.data.notification } : {}
34
+ },
35
+ ...e.data
36
+ }), c = async ({ url: e, headers: t, body: n }) => {
37
+ let r = await import("node:http2"), i = new URL(e), a = r.connect(i.origin);
38
+ try {
39
+ return await new Promise((e, r) => {
40
+ a.once("error", r);
41
+ let o = a.request({
42
+ ":method": "POST",
43
+ ":path": i.pathname,
44
+ ...t
45
+ }), s = 0, c = [];
46
+ o.on("response", (e) => {
47
+ s = Number(e[":status"] ?? 0);
48
+ }), o.on("data", (e) => c.push(e)), o.on("end", () => e({
49
+ status: s,
50
+ body: Buffer.concat(c).toString("utf8")
51
+ })), o.on("error", r), o.end(n);
52
+ });
53
+ } finally {
54
+ a.close();
55
+ }
56
+ }, l = (t) => {
57
+ let a = [
58
+ "keyId",
59
+ "teamId",
60
+ "privateKey",
61
+ "bundleId"
62
+ ].filter((e) => !t[e]);
63
+ if (a.length > 0) throw Error(`apnsTransport: ${a.join(", ")} required — supply APNS_KEY_ID, APNS_TEAM_ID, APNS_PRIVATE_KEY (the .p8) and APNS_BUNDLE_ID from the environment`);
64
+ let l = t.request ?? c, u = t.now ?? Date.now, d = n[t.environment ?? "production"], f = null, p = () => {
65
+ let e = u();
66
+ return (f === null || e - f.at > r) && (f = {
67
+ token: o(t, e),
68
+ at: e
69
+ }), f.token;
70
+ };
71
+ return async (n) => {
72
+ let r = await l({
73
+ url: `${d}/3/device/${encodeURIComponent(n.token)}`,
74
+ headers: {
75
+ authorization: `bearer ${p()}`,
76
+ "apns-topic": t.bundleId,
77
+ "apns-push-type": "alert",
78
+ "apns-priority": "10",
79
+ "content-type": "application/json"
80
+ },
81
+ body: JSON.stringify(s(n))
82
+ });
83
+ if (r.status === 200) return;
84
+ let a = "";
85
+ try {
86
+ a = String(JSON.parse(r.body).reason ?? "");
87
+ } catch {
88
+ a = "";
89
+ }
90
+ throw r.status === 410 || i.has(a) ? new e({
91
+ token: n.token,
92
+ reason: a || "Unregistered"
93
+ }) : Error(`apns: HTTP ${r.status}${a ? ` — ${a}` : ""}`);
94
+ };
95
+ };
96
+ //#endregion
97
+ export { n as APNS_HOSTS, i as APNS_TOKEN_REJECTIONS, s as apnsBody, o as apnsProviderToken, l as apnsTransport };
@@ -0,0 +1,92 @@
1
+ import { PushTokenRejected as e } from "./errors.js";
2
+ //#region src/channels.ts
3
+ var t = () => ({
4
+ id: "console",
5
+ deliver: async (e) => {
6
+ process.stdout.write(`[notify:${e.category}] → ${e.to}: ${e.title} — ${e.body}\n`);
7
+ }
8
+ }), n = (e) => ({
9
+ id: e.id ?? "slack",
10
+ deliver: async (t) => {
11
+ let n = e.format ? e.format(t) : { text: `*${t.title}*\n${t.body}` }, r = await fetch(e.url, {
12
+ method: "POST",
13
+ headers: {
14
+ "content-type": "application/json",
15
+ ...e.headers ?? {}
16
+ },
17
+ body: JSON.stringify(n)
18
+ });
19
+ if (!r.ok) throw Error(`webhook ${e.id ?? "slack"} POST ${r.status}`);
20
+ }
21
+ }), r = (e) => ({
22
+ id: "email",
23
+ deliver: (t) => e({
24
+ to: t.to,
25
+ subject: t.title,
26
+ html: `<p>${t.body}</p>`,
27
+ text: t.body
28
+ })
29
+ }), i = (e) => ({
30
+ id: "sms",
31
+ deliver: (t) => e(t.to, `${t.title}: ${t.body}`)
32
+ }), a = (e, t) => ({
33
+ id: e,
34
+ deliver: t
35
+ }), o = (e) => {
36
+ let t = typeof e?.image == "string" ? e.image : void 0, n = e?.actions, r = Array.isArray(n) ? n.filter((e) => typeof e == "object" && !!e && typeof e.id == "string" && typeof e.title == "string").map((e) => ({
37
+ id: e.id,
38
+ title: e.title
39
+ })) : void 0;
40
+ return {
41
+ ...t === void 0 ? {} : { image: t },
42
+ ...r !== void 0 && r.length > 0 ? { actions: r } : {}
43
+ };
44
+ }, s = (t) => {
45
+ let n = async (n, r, i) => {
46
+ let a = {
47
+ token: r,
48
+ title: n.title,
49
+ body: n.body,
50
+ ...i === void 0 ? {} : { badge: i },
51
+ data: n.data ?? {},
52
+ ...o(n.data)
53
+ };
54
+ try {
55
+ return await t.transport(a), {
56
+ endpoint: r,
57
+ status: "sent"
58
+ };
59
+ } catch (n) {
60
+ let i = n instanceof e, a = i ? n.reason : n instanceof Error ? n.message : String(n);
61
+ if (i && t.onTokenRejected !== void 0) try {
62
+ await t.onTokenRejected(r, a);
63
+ } catch {}
64
+ return {
65
+ endpoint: r,
66
+ status: "failed",
67
+ error: a,
68
+ ...i ? { pruned: t.onTokenRejected !== void 0 } : {}
69
+ };
70
+ }
71
+ }, r = async (e) => {
72
+ let r = await t.tokensFor(e.to), i = e.data?.badge, a = typeof i == "number" ? i : void 0, o = [];
73
+ for (let t of r) o.push(await n(e, t, a));
74
+ return o;
75
+ };
76
+ return {
77
+ id: t.id ?? "push",
78
+ deliver: async (t) => {
79
+ let n = await r(t), i = n.filter((e) => e.status === "failed");
80
+ if (n.length > 0 && i.length === n.length) throw new e({
81
+ token: n[0].endpoint,
82
+ reason: i[0].error ?? "all tokens rejected"
83
+ });
84
+ },
85
+ deliverDetailed: r
86
+ };
87
+ }, c = (e, t, n, r, i) => {
88
+ let a = n ? e.filter((e) => n.includes(e)) : [...e], o = new Set(r.filter((e) => e.subjectId === i && e.category === t && e.enabled === !1).map((e) => e.channel));
89
+ return a.filter((e) => !o.has(e));
90
+ };
91
+ //#endregion
92
+ export { t as consoleChannel, a as customChannel, r as emailChannel, s as pushChannel, c as resolveChannels, o as richPushOf, i as smsChannel, n as webhookChannel };
@@ -0,0 +1,42 @@
1
+ import { defaultRecipients as e, sendInputFor as t } from "./sendNotification.js";
2
+ import { Schema as n } from "effect";
3
+ //#region src/durable.ts
4
+ var r = "voltro.notification.send", i = async (t, i, a) => {
5
+ let o = n.decodeUnknownSync(i.audience)(a.audience), s = n.is(i.payload)(a.payload) ? n.encodeUnknownSync(i.payload)(a.payload) : (n.decodeUnknownSync(i.payload)(a.payload), a.payload), c = a.recipients ? await a.recipients(o) : e(o), l = [];
6
+ for (let e of c) {
7
+ let n = (a.localeFor ? await a.localeFor(e) : null) ?? a.defaultLocale ?? "en", o = {
8
+ notification: i.name,
9
+ recipientId: e,
10
+ locale: n,
11
+ payload: s,
12
+ tenantId: a.tenantId ?? null
13
+ }, c = await t.enqueue(r, { ...o }, {
14
+ ...a.idempotencyKey === void 0 ? {} : { idempotencyKey: `${i.name}:${e}:${a.idempotencyKey}` },
15
+ ...a.maxAttempts === void 0 ? {} : { maxAttempts: a.maxAttempts },
16
+ ...a.delayMs === void 0 ? {} : { delayMs: a.delayMs }
17
+ });
18
+ l.push({
19
+ recipientId: e,
20
+ locale: n,
21
+ outboxId: c
22
+ });
23
+ }
24
+ return l;
25
+ }, a = (e) => typeof e.notification == "string" && typeof e.recipientId == "string" && typeof e.locale == "string", o = (e) => {
26
+ let i = new Map(e.notifications.map((e) => [e.name, e]));
27
+ return {
28
+ effect: r,
29
+ maxAttempts: e.maxAttempts ?? 5,
30
+ handler: async ({ payload: o }) => {
31
+ if (!a(o)) throw Error(`${r}: row payload needs notification, recipientId and locale — got keys ${Object.keys(o).join(", ") || "(none)"}`);
32
+ let s = i.get(o.notification);
33
+ if (s === void 0) throw Error(`${r}: no descriptor named ${JSON.stringify(o.notification)} in this handler — list it in notificationOutboxHandler({ notifications })`);
34
+ let c = e.service();
35
+ if (c === void 0) throw Error(`${r}: the notification service is not available in this process`);
36
+ let l = n.decodeUnknownSync(s.payload)(o.payload);
37
+ return c.send(t(s, l, o.recipientId, o.locale, o.tenantId));
38
+ }
39
+ };
40
+ };
41
+ //#endregion
42
+ export { r as NOTIFICATION_SEND_EFFECT, i as enqueueNotification, o as notificationOutboxHandler };
package/dist/errors.js ADDED
@@ -0,0 +1,8 @@
1
+ import { Schema as e } from "effect";
2
+ //#region src/errors.ts
3
+ var t = class extends e.TaggedError()("PushTokenRejected", {
4
+ token: e.String,
5
+ reason: e.String
6
+ }) {};
7
+ //#endregion
8
+ export { t as PushTokenRejected };
@@ -0,0 +1,35 @@
1
+ import { PushTokenRejected as e } from "./errors.js";
2
+ //#region src/expoPush.ts
3
+ var t = "https://exp.host/--/api/v2/push/send", n = (e) => /^Expo(nent)?PushToken\[[^\]]+\]$/.test(e), r = (t = {}) => async (r) => {
4
+ if (!n(r.token)) throw new e({
5
+ token: r.token,
6
+ reason: "not an Expo push token"
7
+ });
8
+ let i = await (t.fetch ?? fetch)(t.endpoint ?? "https://exp.host/--/api/v2/push/send", {
9
+ method: "POST",
10
+ headers: {
11
+ "content-type": "application/json",
12
+ accept: "application/json",
13
+ ...t.accessToken ? { authorization: `Bearer ${t.accessToken}` } : {}
14
+ },
15
+ body: JSON.stringify({
16
+ to: r.token,
17
+ title: r.title,
18
+ body: r.body,
19
+ data: r.data,
20
+ ...r.badge === void 0 ? {} : { badge: r.badge },
21
+ ...r.image === void 0 ? {} : { richContent: { image: r.image } },
22
+ ...r.actions !== void 0 && typeof r.data.notification == "string" ? { categoryId: r.data.notification } : {}
23
+ })
24
+ }), a = await i.json().catch(() => ({}));
25
+ if (!i.ok) throw Error(`expo push: HTTP ${i.status}${a.errors?.[0]?.message ? ` — ${a.errors[0].message}` : ""}`);
26
+ let o = Array.isArray(a.data) ? a.data[0] : a.data;
27
+ if (!o || o.status === "ok") return;
28
+ let s = o.details?.error;
29
+ throw s === "DeviceNotRegistered" || s === "InvalidCredentials" ? new e({
30
+ token: r.token,
31
+ reason: s
32
+ }) : Error(`expo push: ${s ?? "error"}${o.message ? ` — ${o.message}` : ""}`);
33
+ };
34
+ //#endregion
35
+ export { t as EXPO_PUSH_ENDPOINT, r as expoPushTransport, n as isExpoPushToken };
@@ -0,0 +1,82 @@
1
+ import { PushTokenRejected as e } from "./errors.js";
2
+ import { sign as t } from "node:crypto";
3
+ //#region src/fcmPush.ts
4
+ var n = "https://www.googleapis.com/auth/firebase.messaging", r = "https://oauth2.googleapis.com/token", i = (e) => `https://fcm.googleapis.com/v1/projects/${encodeURIComponent(e)}/messages:send`, a = /* @__PURE__ */ new Set(["UNREGISTERED", "NOT_FOUND"]), o = (e) => Buffer.from(e).toString("base64url"), s = (e) => {
5
+ let t;
6
+ if (typeof e == "string") try {
7
+ t = JSON.parse(e);
8
+ } catch {
9
+ throw Error("fcmTransport: FCM_SERVICE_ACCOUNT is not the JSON Google issued");
10
+ }
11
+ else t = e;
12
+ let n = [
13
+ "project_id",
14
+ "client_email",
15
+ "private_key"
16
+ ].filter((e) => typeof t[e] != "string" || t[e].length === 0);
17
+ if (n.length > 0) throw Error(`fcmTransport: service account is missing ${n.join(", ")} — supply FCM_SERVICE_ACCOUNT from the environment`);
18
+ return t;
19
+ }, c = (e, r) => {
20
+ let i = Math.floor(r / 1e3), a = o(JSON.stringify({
21
+ alg: "RS256",
22
+ typ: "JWT"
23
+ })), s = o(JSON.stringify({
24
+ iss: e.client_email,
25
+ scope: n,
26
+ aud: e.token_uri ?? "https://oauth2.googleapis.com/token",
27
+ iat: i,
28
+ exp: i + 3600
29
+ }));
30
+ return `${a}.${s}.${o(t("sha256", Buffer.from(`${a}.${s}`), e.private_key))}`;
31
+ }, l = (e) => ({ message: {
32
+ token: e.token,
33
+ notification: {
34
+ title: e.title,
35
+ body: e.body,
36
+ ...e.image === void 0 ? {} : { image: e.image }
37
+ },
38
+ data: Object.fromEntries(Object.entries(e.data).map(([e, t]) => [e, typeof t == "string" ? t : JSON.stringify(t)])),
39
+ ...e.badge !== void 0 || e.image !== void 0 ? { apns: {
40
+ payload: { aps: {
41
+ ...e.badge === void 0 ? {} : { badge: e.badge },
42
+ ...e.image === void 0 ? {} : { "mutable-content": 1 }
43
+ } },
44
+ ...e.image === void 0 ? {} : { fcm_options: { image: e.image } }
45
+ } } : {}
46
+ } }), u = (t) => {
47
+ let n = s(t.serviceAccount), r = t.fetch ?? fetch, o = t.now ?? Date.now, u = t.endpoint ?? i(n.project_id), d = null, f = async () => {
48
+ let e = o();
49
+ if (d !== null && d.expiresAt - 6e4 > e) return d.token;
50
+ let t = await r(n.token_uri ?? "https://oauth2.googleapis.com/token", {
51
+ method: "POST",
52
+ headers: { "content-type": "application/x-www-form-urlencoded" },
53
+ body: new URLSearchParams({
54
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
55
+ assertion: c(n, e)
56
+ }).toString()
57
+ }), i = await t.json().catch(() => ({}));
58
+ if (!t.ok || typeof i.access_token != "string") throw Error(`fcm: token exchange HTTP ${t.status}${i.error ? ` — ${i.error}` : ""}`);
59
+ return d = {
60
+ token: i.access_token,
61
+ expiresAt: e + (i.expires_in ?? 3600) * 1e3
62
+ }, d.token;
63
+ };
64
+ return async (t) => {
65
+ let n = await r(u, {
66
+ method: "POST",
67
+ headers: {
68
+ authorization: `Bearer ${await f()}`,
69
+ "content-type": "application/json"
70
+ },
71
+ body: JSON.stringify(l(t))
72
+ });
73
+ if (n.ok) return;
74
+ let i = await n.json().catch(() => ({})), o = i.error?.details?.find((e) => typeof e.errorCode == "string")?.errorCode ?? i.error?.status ?? "";
75
+ throw n.status === 404 || a.has(o) ? new e({
76
+ token: t.token,
77
+ reason: o || "UNREGISTERED"
78
+ }) : Error(`fcm: HTTP ${n.status}${o ? ` — ${o}` : ""}${i.error?.message ? ` — ${i.error.message}` : ""}`);
79
+ };
80
+ };
81
+ //#endregion
82
+ export { n as FCM_SCOPE, a as FCM_TOKEN_REJECTIONS, r as GOOGLE_TOKEN_URI, c as fcmAssertion, l as fcmMessage, i as fcmSendEndpoint, u as fcmTransport, s as parseServiceAccount };
package/dist/index.d.ts CHANGED
@@ -65,6 +65,7 @@ export declare const buildNotificationService: (deps: {
65
65
  * a specific `channels:` list coalesce into one digest per subject per window.
66
66
  * 0 (default) disables digesting — every send goes out immediately. */
67
67
  readonly digestWindowMs?: number;
68
+ readonly deliveryGuards?: Readonly<Record<string, NotificationDeliveryGuard>>;
68
69
  }) => NotificationServiceShape;
69
70
 
70
71
  /** A delivery channel. `id` is the channel key prefs target (e.g. 'email',
@@ -410,6 +411,20 @@ export declare const minuteOfDayInZone: (at: Date, tz: string) => number;
410
411
 
411
412
  export declare const NOTIFICATION_SEND_EFFECT = "voltro.notification.send";
412
413
 
414
+ export declare type NotificationDeliveryGuard = (input: NotificationDeliveryGuardInput) => boolean | Promise<boolean>;
415
+
416
+ export declare interface NotificationDeliveryGuardInput {
417
+ readonly recipientId: string;
418
+ readonly tenantId: string | null;
419
+ readonly payload: Readonly<Record<string, unknown>>;
420
+ }
421
+
422
+ /** Persisted authorization reference; never forwarded to delivery channels. */
423
+ export declare interface NotificationDeliveryGuardRef {
424
+ readonly id: string;
425
+ readonly payload: Readonly<Record<string, unknown>>;
426
+ }
427
+
413
428
  export declare interface NotificationDescriptor<Audience = unknown, Payload = unknown> {
414
429
  readonly _tag: 'VoltroNotification';
415
430
  readonly name: string;
@@ -533,6 +548,8 @@ export declare interface NotificationsPluginOptions {
533
548
  * boundary by the scheduled flush). A send that forces its own `channels:`
534
549
  * bypasses the digest. 0 (default) = every send delivers immediately. */
535
550
  readonly digestWindowMs?: number;
551
+ /** Re-evaluated before actual delivery, including persisted quiet/digest holds. Unknown guards refuse. */
552
+ readonly deliveryGuards?: Readonly<Record<string, NotificationDeliveryGuard>>;
536
553
  /** How often the scheduled flush drains due digest windows + quiet-hours
537
554
  * deferrals. Default 30s. Only runs once the store is bound at boot. */
538
555
  readonly flushIntervalMs?: number;
@@ -839,6 +856,7 @@ export declare const richPushOf: (data: Readonly<Record<string, unknown>> | unde
839
856
 
840
857
  /** What a caller asks to send. `channels` overrides the default routing. */
841
858
  export declare interface SendInput {
859
+ readonly deliveryGuard?: NotificationDeliveryGuardRef;
842
860
  readonly to: string;
843
861
  readonly category: string;
844
862
  readonly title: string;