@checkstack/notification-pushover-backend 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @checkstack/notification-pushover-backend
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cf5f245: Added Pushover notification provider for mobile push notifications. Features include priority mapping (info→0, warning→1, critical→2 emergency), retry/expire for emergency alerts, and supplementary URL support.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@checkstack/notification-pushover-backend",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./plugin-metadata": "./src/plugin-metadata.ts"
9
+ },
10
+ "scripts": {
11
+ "typecheck": "tsc --noEmit",
12
+ "lint": "eslint src --ext .ts",
13
+ "test": "bun test"
14
+ },
15
+ "dependencies": {
16
+ "@checkstack/backend-api": "workspace:*",
17
+ "@checkstack/common": "workspace:*",
18
+ "@checkstack/notification-backend": "workspace:*",
19
+ "zod": "^4.2.1"
20
+ },
21
+ "devDependencies": {
22
+ "@checkstack/tsconfig": "workspace:*",
23
+ "typescript": "^5.7.2"
24
+ }
25
+ }
@@ -0,0 +1,233 @@
1
+ import { describe, it, expect, spyOn } from "bun:test";
2
+ import {
3
+ pushoverConfigSchemaV1,
4
+ pushoverUserConfigSchema,
5
+ mapImportanceToPriority,
6
+ PUSHOVER_API_URL,
7
+ EMERGENCY_RETRY_SECONDS,
8
+ EMERGENCY_EXPIRE_SECONDS,
9
+ } from "./index";
10
+
11
+ /**
12
+ * Unit tests for the Pushover Notification Strategy.
13
+ *
14
+ * Tests cover:
15
+ * - Config schema validation
16
+ * - Priority mapping
17
+ * - REST API interaction
18
+ * - Emergency notification parameters
19
+ */
20
+
21
+ describe("Pushover Notification Strategy", () => {
22
+ // ─────────────────────────────────────────────────────────────────────────
23
+ // Config Schema Validation
24
+ // ─────────────────────────────────────────────────────────────────────────
25
+
26
+ describe("config schema", () => {
27
+ it("validates admin config - requires apiToken", () => {
28
+ expect(() => {
29
+ pushoverConfigSchemaV1.parse({});
30
+ }).toThrow();
31
+ });
32
+
33
+ it("accepts valid admin config", () => {
34
+ const result = pushoverConfigSchemaV1.parse({
35
+ apiToken: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5",
36
+ });
37
+ expect(result.apiToken).toBe("a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5");
38
+ });
39
+
40
+ it("validates user config - requires userKey", () => {
41
+ expect(() => {
42
+ pushoverUserConfigSchema.parse({});
43
+ }).toThrow();
44
+ });
45
+
46
+ it("accepts valid user config", () => {
47
+ const result = pushoverUserConfigSchema.parse({
48
+ userKey: "u1s2e3r4k5e6y7-abcdefg",
49
+ });
50
+ expect(result.userKey).toBe("u1s2e3r4k5e6y7-abcdefg");
51
+ });
52
+ });
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────
55
+ // Priority Mapping
56
+ // ─────────────────────────────────────────────────────────────────────────
57
+
58
+ describe("priority mapping", () => {
59
+ it("maps info to normal priority (0)", () => {
60
+ expect(mapImportanceToPriority("info")).toBe(0);
61
+ });
62
+
63
+ it("maps warning to high priority (1)", () => {
64
+ expect(mapImportanceToPriority("warning")).toBe(1);
65
+ });
66
+
67
+ it("maps critical to emergency priority (2)", () => {
68
+ expect(mapImportanceToPriority("critical")).toBe(2);
69
+ });
70
+ });
71
+
72
+ // ─────────────────────────────────────────────────────────────────────────
73
+ // Emergency Parameters
74
+ // ─────────────────────────────────────────────────────────────────────────
75
+
76
+ describe("emergency parameters", () => {
77
+ it("has correct retry interval", () => {
78
+ expect(EMERGENCY_RETRY_SECONDS).toBe(60);
79
+ });
80
+
81
+ it("has correct expire duration", () => {
82
+ expect(EMERGENCY_EXPIRE_SECONDS).toBe(3600);
83
+ });
84
+ });
85
+
86
+ // ─────────────────────────────────────────────────────────────────────────
87
+ // REST API Interaction
88
+ // ─────────────────────────────────────────────────────────────────────────
89
+
90
+ describe("REST API interaction", () => {
91
+ it("sends message to Pushover API", async () => {
92
+ let capturedBody: string | undefined;
93
+ let capturedUrl: string | undefined;
94
+
95
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
96
+ url: RequestInfo | URL,
97
+ options?: RequestInit,
98
+ ) => {
99
+ capturedUrl = url.toString();
100
+ capturedBody = options?.body as string;
101
+ return new Response(JSON.stringify({ status: 1, request: "abc123" }), {
102
+ status: 200,
103
+ });
104
+ }) as unknown as typeof fetch);
105
+
106
+ try {
107
+ await fetch(PUSHOVER_API_URL, {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({
111
+ token: "api-token",
112
+ user: "user-key",
113
+ title: "Test Alert",
114
+ message: "Test message body",
115
+ priority: 0,
116
+ html: 1,
117
+ }),
118
+ });
119
+
120
+ expect(capturedUrl).toBe(PUSHOVER_API_URL);
121
+
122
+ const parsedBody = JSON.parse(capturedBody!);
123
+ expect(parsedBody.token).toBe("api-token");
124
+ expect(parsedBody.user).toBe("user-key");
125
+ expect(parsedBody.title).toBe("Test Alert");
126
+ expect(parsedBody.priority).toBe(0);
127
+ expect(parsedBody.html).toBe(1);
128
+ } finally {
129
+ mockFetch.mockRestore();
130
+ }
131
+ });
132
+
133
+ it("includes URL parameters for action", async () => {
134
+ let capturedBody: string | undefined;
135
+
136
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
137
+ _url: RequestInfo | URL,
138
+ options?: RequestInit,
139
+ ) => {
140
+ capturedBody = options?.body as string;
141
+ return new Response(JSON.stringify({ status: 1, request: "def456" }), {
142
+ status: 200,
143
+ });
144
+ }) as unknown as typeof fetch);
145
+
146
+ try {
147
+ await fetch(PUSHOVER_API_URL, {
148
+ method: "POST",
149
+ headers: { "Content-Type": "application/json" },
150
+ body: JSON.stringify({
151
+ token: "api-token",
152
+ user: "user-key",
153
+ title: "Incident",
154
+ message: "View incident",
155
+ priority: 1,
156
+ html: 1,
157
+ url: "https://example.com/incident/123",
158
+ url_title: "View Incident",
159
+ }),
160
+ });
161
+
162
+ const parsedBody = JSON.parse(capturedBody!);
163
+ expect(parsedBody.url).toBe("https://example.com/incident/123");
164
+ expect(parsedBody.url_title).toBe("View Incident");
165
+ } finally {
166
+ mockFetch.mockRestore();
167
+ }
168
+ });
169
+
170
+ it("includes retry/expire for emergency priority", async () => {
171
+ let capturedBody: string | undefined;
172
+
173
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
174
+ _url: RequestInfo | URL,
175
+ options?: RequestInit,
176
+ ) => {
177
+ capturedBody = options?.body as string;
178
+ return new Response(
179
+ JSON.stringify({ status: 1, request: "ghi789", receipt: "rcpt123" }),
180
+ { status: 200 },
181
+ );
182
+ }) as unknown as typeof fetch);
183
+
184
+ try {
185
+ await fetch(PUSHOVER_API_URL, {
186
+ method: "POST",
187
+ headers: { "Content-Type": "application/json" },
188
+ body: JSON.stringify({
189
+ token: "api-token",
190
+ user: "user-key",
191
+ title: "Critical Alert",
192
+ message: "Immediate attention required",
193
+ priority: 2,
194
+ html: 1,
195
+ retry: EMERGENCY_RETRY_SECONDS,
196
+ expire: EMERGENCY_EXPIRE_SECONDS,
197
+ }),
198
+ });
199
+
200
+ const parsedBody = JSON.parse(capturedBody!);
201
+ expect(parsedBody.priority).toBe(2);
202
+ expect(parsedBody.retry).toBe(60);
203
+ expect(parsedBody.expire).toBe(3600);
204
+ } finally {
205
+ mockFetch.mockRestore();
206
+ }
207
+ });
208
+
209
+ it("handles API errors gracefully", async () => {
210
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
211
+ (async () => {
212
+ return new Response(
213
+ JSON.stringify({ status: 0, errors: ["invalid token"] }),
214
+ { status: 400 },
215
+ );
216
+ }) as unknown as typeof fetch,
217
+ );
218
+
219
+ try {
220
+ const response = await fetch(PUSHOVER_API_URL, {
221
+ method: "POST",
222
+ headers: { "Content-Type": "application/json" },
223
+ body: JSON.stringify({ token: "invalid", user: "key" }),
224
+ });
225
+
226
+ expect(response.ok).toBe(false);
227
+ expect(response.status).toBe(400);
228
+ } finally {
229
+ mockFetch.mockRestore();
230
+ }
231
+ });
232
+ });
233
+ });
package/src/index.ts ADDED
@@ -0,0 +1,256 @@
1
+ import { z } from "zod";
2
+ import {
3
+ createBackendPlugin,
4
+ configString,
5
+ Versioned,
6
+ type NotificationStrategy,
7
+ type NotificationSendContext,
8
+ type NotificationDeliveryResult,
9
+ markdownToPlainText,
10
+ } from "@checkstack/backend-api";
11
+ import { notificationStrategyExtensionPoint } from "@checkstack/notification-backend";
12
+ import { pluginMetadata } from "./plugin-metadata";
13
+
14
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
15
+ // Configuration Schemas
16
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
17
+
18
+ /**
19
+ * Admin configuration for Pushover strategy.
20
+ * Admins register an app at pushover.net and provide the API token.
21
+ */
22
+ const pushoverConfigSchemaV1 = z.object({
23
+ apiToken: configString({ "x-secret": true }).describe(
24
+ "Pushover Application API Token",
25
+ ),
26
+ });
27
+
28
+ type PushoverConfig = z.infer<typeof pushoverConfigSchemaV1>;
29
+
30
+ /**
31
+ * User configuration for Pushover - users provide their user key.
32
+ */
33
+ const pushoverUserConfigSchema = z.object({
34
+ userKey: configString({ "x-secret": true }).describe("Pushover User Key"),
35
+ });
36
+
37
+ type PushoverUserConfig = z.infer<typeof pushoverUserConfigSchema>;
38
+
39
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40
+ // Instructions
41
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42
+
43
+ const adminInstructions = `
44
+ ## Register a Pushover Application
45
+
46
+ 1. Go to [Pushover](https://pushover.net/) and sign in
47
+ 2. Scroll to the bottom and click **Create an Application/API Token**
48
+ 3. Fill in the application details:
49
+ - **Name**: e.g., "Checkstack Notifications"
50
+ - **Type**: Application
51
+ - **Description**: (optional)
52
+ 4. Click **Create Application**
53
+ 5. Copy the **API Token/Key** and paste it in the field above
54
+
55
+ > **Note**: The API token is shared across all users. Each user provides their own User Key.
56
+ `.trim();
57
+
58
+ const userInstructions = `
59
+ ## Get Your Pushover User Key
60
+
61
+ 1. Go to [Pushover](https://pushover.net/) and sign in (or create an account)
62
+ 2. Your **User Key** is displayed on the main page after login
63
+ 3. Copy the key and paste it in the field above
64
+
65
+ > **Tip**: Make sure you have the Pushover app installed on your device to receive notifications.
66
+ `.trim();
67
+
68
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
69
+ // Priority Mapping
70
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
71
+
72
+ /**
73
+ * Maps notification importance to Pushover priority.
74
+ * Pushover priorities: -2=lowest, -1=low, 0=normal, 1=high, 2=emergency
75
+ */
76
+ function mapImportanceToPriority(
77
+ importance: "info" | "warning" | "critical",
78
+ ): number {
79
+ const priorityMap: Record<string, number> = {
80
+ info: 0, // Normal
81
+ warning: 1, // High (bypasses quiet hours)
82
+ critical: 2, // Emergency (requires acknowledgment)
83
+ };
84
+ return priorityMap[importance];
85
+ }
86
+
87
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
88
+ // Pushover API Constants
89
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
90
+
91
+ const PUSHOVER_API_URL = "https://api.pushover.net/1/messages.json";
92
+
93
+ // Emergency priority parameters
94
+ const EMERGENCY_RETRY_SECONDS = 60; // Retry every 60 seconds
95
+ const EMERGENCY_EXPIRE_SECONDS = 3600; // Expire after 1 hour
96
+
97
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
98
+ // Pushover Strategy Implementation
99
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
100
+
101
+ /**
102
+ * Pushover notification strategy using REST API.
103
+ */
104
+ const pushoverStrategy: NotificationStrategy<
105
+ PushoverConfig,
106
+ PushoverUserConfig
107
+ > = {
108
+ id: "pushover",
109
+ displayName: "Pushover",
110
+ description: "Send notifications via Pushover mobile app",
111
+ icon: "Smartphone",
112
+
113
+ config: new Versioned({
114
+ version: 1,
115
+ schema: pushoverConfigSchemaV1,
116
+ }),
117
+
118
+ // User-config resolution - users enter their user key
119
+ contactResolution: { type: "user-config", field: "userKey" },
120
+
121
+ userConfig: new Versioned({
122
+ version: 1,
123
+ schema: pushoverUserConfigSchema,
124
+ }),
125
+
126
+ adminInstructions,
127
+ userInstructions,
128
+
129
+ async send(
130
+ context: NotificationSendContext<PushoverConfig, PushoverUserConfig>,
131
+ ): Promise<NotificationDeliveryResult> {
132
+ const { userConfig, notification, strategyConfig, logger } = context;
133
+
134
+ if (!strategyConfig.apiToken) {
135
+ return {
136
+ success: false,
137
+ error: "Pushover API token not configured",
138
+ };
139
+ }
140
+
141
+ if (!userConfig?.userKey) {
142
+ return {
143
+ success: false,
144
+ error: "User has not configured their Pushover user key",
145
+ };
146
+ }
147
+
148
+ try {
149
+ const priority = mapImportanceToPriority(notification.importance);
150
+
151
+ // Build message body
152
+ const message = notification.body
153
+ ? markdownToPlainText(notification.body)
154
+ : notification.title;
155
+
156
+ // Build request body
157
+ const body: Record<string, string | number> = {
158
+ token: strategyConfig.apiToken,
159
+ user: userConfig.userKey,
160
+ title: notification.title,
161
+ message,
162
+ priority,
163
+ html: 1, // Enable HTML formatting
164
+ };
165
+
166
+ // Add action URL if present
167
+ if (notification.action?.url) {
168
+ body.url = notification.action.url;
169
+ body.url_title = notification.action.label;
170
+ }
171
+
172
+ // Emergency priority requires retry and expire parameters
173
+ if (priority === 2) {
174
+ body.retry = EMERGENCY_RETRY_SECONDS;
175
+ body.expire = EMERGENCY_EXPIRE_SECONDS;
176
+ }
177
+
178
+ // Send to Pushover
179
+ const response = await fetch(PUSHOVER_API_URL, {
180
+ method: "POST",
181
+ headers: {
182
+ "Content-Type": "application/json",
183
+ },
184
+ body: JSON.stringify(body),
185
+ signal: AbortSignal.timeout(10_000),
186
+ });
187
+
188
+ if (!response.ok) {
189
+ const errorText = await response.text();
190
+ logger.error("Failed to send Pushover message", {
191
+ status: response.status,
192
+ error: errorText.slice(0, 500),
193
+ });
194
+ return {
195
+ success: false,
196
+ error: `Failed to send Pushover message: ${response.status}`,
197
+ };
198
+ }
199
+
200
+ const result = (await response.json()) as {
201
+ status: number;
202
+ request: string;
203
+ receipt?: string;
204
+ };
205
+
206
+ if (result.status !== 1) {
207
+ return {
208
+ success: false,
209
+ error: "Pushover API returned error status",
210
+ };
211
+ }
212
+
213
+ return {
214
+ success: true,
215
+ externalId: result.receipt ?? result.request,
216
+ };
217
+ } catch (error) {
218
+ const message =
219
+ error instanceof Error ? error.message : "Unknown Pushover API error";
220
+ logger.error("Pushover notification error", { error: message });
221
+ return {
222
+ success: false,
223
+ error: `Failed to send Pushover notification: ${message}`,
224
+ };
225
+ }
226
+ },
227
+ };
228
+
229
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
230
+ // Plugin Definition
231
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
232
+
233
+ export default createBackendPlugin({
234
+ metadata: pluginMetadata,
235
+
236
+ register(env) {
237
+ // Get the notification strategy extension point
238
+ const extensionPoint = env.getExtensionPoint(
239
+ notificationStrategyExtensionPoint,
240
+ );
241
+
242
+ // Register the Pushover strategy with our plugin metadata
243
+ extensionPoint.addStrategy(pushoverStrategy, pluginMetadata);
244
+ },
245
+ });
246
+
247
+ // Export for testing
248
+ export {
249
+ pushoverConfigSchemaV1,
250
+ pushoverUserConfigSchema,
251
+ mapImportanceToPriority,
252
+ PUSHOVER_API_URL,
253
+ EMERGENCY_RETRY_SECONDS,
254
+ EMERGENCY_EXPIRE_SECONDS,
255
+ };
256
+ export type { PushoverConfig, PushoverUserConfig };
@@ -0,0 +1,5 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+
3
+ export const pluginMetadata: PluginMetadata = {
4
+ pluginId: "notification-pushover",
5
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json"
3
+ }