@checkstack/notification-gotify-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-gotify-backend
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cf5f245: Added Gotify notification provider for self-hosted push notifications. Features include priority mapping (info→5, warning→7, critical→10), action URL extras, and configurable server URL.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@checkstack/notification-gotify-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,181 @@
1
+ import { describe, it, expect, spyOn } from "bun:test";
2
+ import {
3
+ gotifyConfigSchemaV1,
4
+ gotifyUserConfigSchema,
5
+ mapImportanceToPriority,
6
+ } from "./index";
7
+
8
+ /**
9
+ * Unit tests for the Gotify Notification Strategy.
10
+ *
11
+ * Tests cover:
12
+ * - Config schema validation
13
+ * - Priority mapping
14
+ * - REST API interaction
15
+ */
16
+
17
+ describe("Gotify Notification Strategy", () => {
18
+ // ─────────────────────────────────────────────────────────────────────────
19
+ // Config Schema Validation
20
+ // ─────────────────────────────────────────────────────────────────────────
21
+
22
+ describe("config schema", () => {
23
+ it("validates admin config - requires serverUrl", () => {
24
+ expect(() => {
25
+ gotifyConfigSchemaV1.parse({});
26
+ }).toThrow();
27
+ });
28
+
29
+ it("validates admin config - requires valid URL", () => {
30
+ expect(() => {
31
+ gotifyConfigSchemaV1.parse({ serverUrl: "not-a-url" });
32
+ }).toThrow();
33
+ });
34
+
35
+ it("accepts valid admin config", () => {
36
+ const result = gotifyConfigSchemaV1.parse({
37
+ serverUrl: "https://gotify.example.com",
38
+ });
39
+ expect(result.serverUrl).toBe("https://gotify.example.com");
40
+ });
41
+
42
+ it("validates user config - requires appToken", () => {
43
+ expect(() => {
44
+ gotifyUserConfigSchema.parse({});
45
+ }).toThrow();
46
+ });
47
+
48
+ it("accepts valid user config", () => {
49
+ const result = gotifyUserConfigSchema.parse({
50
+ appToken: "A-secret-token-123",
51
+ });
52
+ expect(result.appToken).toBe("A-secret-token-123");
53
+ });
54
+ });
55
+
56
+ // ─────────────────────────────────────────────────────────────────────────
57
+ // Priority Mapping
58
+ // ─────────────────────────────────────────────────────────────────────────
59
+
60
+ describe("priority mapping", () => {
61
+ it("maps info to normal priority (5)", () => {
62
+ expect(mapImportanceToPriority("info")).toBe(5);
63
+ });
64
+
65
+ it("maps warning to high-normal priority (7)", () => {
66
+ expect(mapImportanceToPriority("warning")).toBe(7);
67
+ });
68
+
69
+ it("maps critical to highest priority (10)", () => {
70
+ expect(mapImportanceToPriority("critical")).toBe(10);
71
+ });
72
+ });
73
+
74
+ // ─────────────────────────────────────────────────────────────────────────
75
+ // REST API Interaction
76
+ // ─────────────────────────────────────────────────────────────────────────
77
+
78
+ describe("REST API interaction", () => {
79
+ it("sends message to Gotify server with token", async () => {
80
+ let capturedBody: string | undefined;
81
+ let capturedUrl: string | undefined;
82
+
83
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
84
+ url: RequestInfo | URL,
85
+ options?: RequestInit,
86
+ ) => {
87
+ capturedUrl = url.toString();
88
+ capturedBody = options?.body as string;
89
+ return new Response(JSON.stringify({ id: 42 }), { status: 200 });
90
+ }) as unknown as typeof fetch);
91
+
92
+ try {
93
+ const serverUrl = "https://gotify.example.com";
94
+ const appToken = "test-token";
95
+
96
+ await fetch(`${serverUrl}/message?token=${appToken}`, {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify({
100
+ title: "Test Alert",
101
+ message: "Test message body",
102
+ priority: 5,
103
+ }),
104
+ });
105
+
106
+ expect(capturedUrl).toContain("gotify.example.com/message");
107
+ expect(capturedUrl).toContain("token=test-token");
108
+
109
+ const parsedBody = JSON.parse(capturedBody!);
110
+ expect(parsedBody.title).toBe("Test Alert");
111
+ expect(parsedBody.message).toBe("Test message body");
112
+ expect(parsedBody.priority).toBe(5);
113
+ } finally {
114
+ mockFetch.mockRestore();
115
+ }
116
+ });
117
+
118
+ it("includes extras for action URL", async () => {
119
+ let capturedBody: string | undefined;
120
+
121
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
122
+ _url: RequestInfo | URL,
123
+ options?: RequestInit,
124
+ ) => {
125
+ capturedBody = options?.body as string;
126
+ return new Response(JSON.stringify({ id: 43 }), { status: 200 });
127
+ }) as unknown as typeof fetch);
128
+
129
+ try {
130
+ await fetch("https://gotify.example.com/message?token=test", {
131
+ method: "POST",
132
+ headers: { "Content-Type": "application/json" },
133
+ body: JSON.stringify({
134
+ title: "Test",
135
+ message: "Body",
136
+ priority: 10,
137
+ extras: {
138
+ "client::notification": {
139
+ click: { url: "https://example.com/action" },
140
+ },
141
+ },
142
+ }),
143
+ });
144
+
145
+ const parsedBody = JSON.parse(capturedBody!);
146
+ expect(parsedBody.extras).toBeDefined();
147
+ expect(parsedBody.extras["client::notification"].click.url).toBe(
148
+ "https://example.com/action",
149
+ );
150
+ } finally {
151
+ mockFetch.mockRestore();
152
+ }
153
+ });
154
+
155
+ it("handles API errors gracefully", async () => {
156
+ const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
157
+ (async () => {
158
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
159
+ status: 401,
160
+ });
161
+ }) as unknown as typeof fetch,
162
+ );
163
+
164
+ try {
165
+ const response = await fetch(
166
+ "https://gotify.example.com/message?token=invalid",
167
+ {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({ title: "Test", message: "Body" }),
171
+ },
172
+ );
173
+
174
+ expect(response.ok).toBe(false);
175
+ expect(response.status).toBe(401);
176
+ } finally {
177
+ mockFetch.mockRestore();
178
+ }
179
+ });
180
+ });
181
+ });
package/src/index.ts ADDED
@@ -0,0 +1,221 @@
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 Gotify strategy.
20
+ * Admins configure the Gotify server URL.
21
+ */
22
+ const gotifyConfigSchemaV1 = z.object({
23
+ serverUrl: configString({})
24
+ .url()
25
+ .describe("Gotify server URL (e.g., https://gotify.example.com)"),
26
+ });
27
+
28
+ type GotifyConfig = z.infer<typeof gotifyConfigSchemaV1>;
29
+
30
+ /**
31
+ * User configuration for Gotify - users provide their app token.
32
+ */
33
+ const gotifyUserConfigSchema = z.object({
34
+ appToken: configString({ "x-secret": true }).describe(
35
+ "Gotify Application Token",
36
+ ),
37
+ });
38
+
39
+ type GotifyUserConfig = z.infer<typeof gotifyUserConfigSchema>;
40
+
41
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42
+ // Instructions
43
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
44
+
45
+ const adminInstructions = `
46
+ ## Configure Gotify Server
47
+
48
+ 1. Deploy a [Gotify server](https://gotify.net/) or use an existing instance
49
+ 2. Enter the server URL below (e.g., \`https://gotify.example.com\`)
50
+ 3. Users will create their own application tokens in the Gotify web UI
51
+
52
+ > **Note**: Ensure the server URL is accessible from this Checkstack instance.
53
+ `.trim();
54
+
55
+ const userInstructions = `
56
+ ## Get Your Gotify App Token
57
+
58
+ 1. Log into your organization's Gotify server
59
+ 2. Go to the **Apps** tab in the web interface
60
+ 3. Click **Create Application**
61
+ 4. Give it a name (e.g., "Checkstack Notifications")
62
+ 5. Copy the generated **Token** and paste it in the field above
63
+
64
+ > **Tip**: You can customize the app icon in Gotify for easy identification.
65
+ `.trim();
66
+
67
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
68
+ // Priority Mapping
69
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
70
+
71
+ /**
72
+ * Maps notification importance to Gotify priority.
73
+ * Gotify priorities: 0=min, 1-3=low, 4-7=normal, 8-10=high
74
+ */
75
+ function mapImportanceToPriority(
76
+ importance: "info" | "warning" | "critical",
77
+ ): number {
78
+ const priorityMap: Record<string, number> = {
79
+ info: 5, // Normal
80
+ warning: 7, // High-normal
81
+ critical: 10, // Highest
82
+ };
83
+ return priorityMap[importance];
84
+ }
85
+
86
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
87
+ // Gotify Strategy Implementation
88
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
89
+
90
+ /**
91
+ * Gotify notification strategy using REST API.
92
+ */
93
+ const gotifyStrategy: NotificationStrategy<GotifyConfig, GotifyUserConfig> = {
94
+ id: "gotify",
95
+ displayName: "Gotify",
96
+ description: "Send notifications via Gotify self-hosted server",
97
+ icon: "Bell",
98
+
99
+ config: new Versioned({
100
+ version: 1,
101
+ schema: gotifyConfigSchemaV1,
102
+ }),
103
+
104
+ // User-config resolution - users enter their app token
105
+ contactResolution: { type: "user-config", field: "appToken" },
106
+
107
+ userConfig: new Versioned({
108
+ version: 1,
109
+ schema: gotifyUserConfigSchema,
110
+ }),
111
+
112
+ adminInstructions,
113
+ userInstructions,
114
+
115
+ async send(
116
+ context: NotificationSendContext<GotifyConfig, GotifyUserConfig>,
117
+ ): Promise<NotificationDeliveryResult> {
118
+ const { userConfig, notification, strategyConfig, logger } = context;
119
+
120
+ if (!strategyConfig.serverUrl) {
121
+ return {
122
+ success: false,
123
+ error: "Gotify server URL not configured",
124
+ };
125
+ }
126
+
127
+ if (!userConfig?.appToken) {
128
+ return {
129
+ success: false,
130
+ error: "User has not configured their Gotify app token",
131
+ };
132
+ }
133
+
134
+ try {
135
+ // Build message body
136
+ const message = notification.body
137
+ ? markdownToPlainText(notification.body)
138
+ : notification.title;
139
+
140
+ // Add action URL to extras if present
141
+ const extras: Record<string, unknown> = {};
142
+ if (notification.action?.url) {
143
+ extras["client::notification"] = {
144
+ click: { url: notification.action.url },
145
+ };
146
+ }
147
+
148
+ // Build request URL with token
149
+ const serverUrl = strategyConfig.serverUrl.replace(/\/$/, "");
150
+ const url = `${serverUrl}/message?token=${encodeURIComponent(userConfig.appToken)}`;
151
+
152
+ // Send to Gotify
153
+ const response = await fetch(url, {
154
+ method: "POST",
155
+ headers: {
156
+ "Content-Type": "application/json",
157
+ },
158
+ body: JSON.stringify({
159
+ title: notification.title,
160
+ message,
161
+ priority: mapImportanceToPriority(notification.importance),
162
+ extras: Object.keys(extras).length > 0 ? extras : undefined,
163
+ }),
164
+ signal: AbortSignal.timeout(10_000),
165
+ });
166
+
167
+ if (!response.ok) {
168
+ const errorText = await response.text();
169
+ logger.error("Failed to send Gotify message", {
170
+ status: response.status,
171
+ error: errorText.slice(0, 500),
172
+ });
173
+ return {
174
+ success: false,
175
+ error: `Failed to send Gotify message: ${response.status}`,
176
+ };
177
+ }
178
+
179
+ const result = (await response.json()) as { id?: number };
180
+
181
+ return {
182
+ success: true,
183
+ externalId: result.id ? String(result.id) : undefined,
184
+ };
185
+ } catch (error) {
186
+ const message =
187
+ error instanceof Error ? error.message : "Unknown Gotify API error";
188
+ logger.error("Gotify notification error", { error: message });
189
+ return {
190
+ success: false,
191
+ error: `Failed to send Gotify notification: ${message}`,
192
+ };
193
+ }
194
+ },
195
+ };
196
+
197
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
198
+ // Plugin Definition
199
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
200
+
201
+ export default createBackendPlugin({
202
+ metadata: pluginMetadata,
203
+
204
+ register(env) {
205
+ // Get the notification strategy extension point
206
+ const extensionPoint = env.getExtensionPoint(
207
+ notificationStrategyExtensionPoint,
208
+ );
209
+
210
+ // Register the Gotify strategy with our plugin metadata
211
+ extensionPoint.addStrategy(gotifyStrategy, pluginMetadata);
212
+ },
213
+ });
214
+
215
+ // Export for testing
216
+ export {
217
+ gotifyConfigSchemaV1,
218
+ gotifyUserConfigSchema,
219
+ mapImportanceToPriority,
220
+ };
221
+ export type { GotifyConfig, GotifyUserConfig };
@@ -0,0 +1,5 @@
1
+ import type { PluginMetadata } from "@checkstack/common";
2
+
3
+ export const pluginMetadata: PluginMetadata = {
4
+ pluginId: "notification-gotify",
5
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json"
3
+ }