@checkstack/notification-backstage-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,25 @@
1
+ # @checkstack/notification-backstage-backend
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 97c5a6b: Add Backstage notification provider plugin
8
+
9
+ This new plugin enables forwarding Checkstack notifications to external Backstage instances via the Backstage Notifications REST API.
10
+
11
+ **Features:**
12
+
13
+ - Admin configuration for Backstage instance URL and API token
14
+ - User configuration for custom entity reference (e.g., `user:default/john.doe`)
15
+ - Automatic entity reference generation from user email when not specified
16
+ - Severity mapping from Checkstack importance levels to Backstage severity
17
+ - Full admin and user setup instructions
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies [97c5a6b]
22
+ - Updated dependencies [8e43507]
23
+ - @checkstack/backend-api@0.2.0
24
+ - @checkstack/common@0.1.0
25
+ - @checkstack/notification-backend@0.0.4
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@checkstack/notification-backstage-backend",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "scripts": {
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@checkstack/backend-api": "workspace:*",
14
+ "@checkstack/notification-backend": "workspace:*",
15
+ "@checkstack/common": "workspace:*",
16
+ "zod": "^4.2.1"
17
+ },
18
+ "devDependencies": {
19
+ "@checkstack/tsconfig": "workspace:*",
20
+ "typescript": "^5.9.3"
21
+ }
22
+ }
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ backstageConfigSchemaV1,
4
+ userConfigSchemaV1,
5
+ mapImportanceToSeverity,
6
+ } from "./index";
7
+
8
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
9
+ // Config Schema Tests
10
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
11
+
12
+ describe("backstageConfigSchemaV1", () => {
13
+ it("should accept valid config with all fields", () => {
14
+ const config = {
15
+ baseUrl: "https://backstage.example.com",
16
+ token: "my-secret-token",
17
+ defaultEntityPrefix: "user:development/",
18
+ };
19
+
20
+ const result = backstageConfigSchemaV1.safeParse(config);
21
+ expect(result.success).toBe(true);
22
+ if (result.success) {
23
+ expect(result.data.baseUrl).toBe("https://backstage.example.com");
24
+ expect(result.data.token).toBe("my-secret-token");
25
+ expect(result.data.defaultEntityPrefix).toBe("user:development/");
26
+ }
27
+ });
28
+
29
+ it("should accept config with only required fields (all optional)", () => {
30
+ const config = {};
31
+
32
+ const result = backstageConfigSchemaV1.safeParse(config);
33
+ expect(result.success).toBe(true);
34
+ if (result.success) {
35
+ expect(result.data.defaultEntityPrefix).toBe("user:default/");
36
+ }
37
+ });
38
+
39
+ it("should reject invalid URL", () => {
40
+ const config = {
41
+ baseUrl: "not-a-valid-url",
42
+ token: "my-token",
43
+ };
44
+
45
+ const result = backstageConfigSchemaV1.safeParse(config);
46
+ expect(result.success).toBe(false);
47
+ });
48
+
49
+ it("should use default entity prefix when not provided", () => {
50
+ const config = {
51
+ baseUrl: "https://backstage.example.com",
52
+ token: "my-token",
53
+ };
54
+
55
+ const result = backstageConfigSchemaV1.safeParse(config);
56
+ expect(result.success).toBe(true);
57
+ if (result.success) {
58
+ expect(result.data.defaultEntityPrefix).toBe("user:default/");
59
+ }
60
+ });
61
+ });
62
+
63
+ describe("userConfigSchemaV1", () => {
64
+ it("should accept valid user config with entity reference", () => {
65
+ const config = {
66
+ entityRef: "user:default/john.doe",
67
+ };
68
+
69
+ const result = userConfigSchemaV1.safeParse(config);
70
+ expect(result.success).toBe(true);
71
+ if (result.success) {
72
+ expect(result.data.entityRef).toBe("user:default/john.doe");
73
+ }
74
+ });
75
+
76
+ it("should accept empty user config", () => {
77
+ const config = {};
78
+
79
+ const result = userConfigSchemaV1.safeParse(config);
80
+ expect(result.success).toBe(true);
81
+ });
82
+
83
+ it("should accept group entity references", () => {
84
+ const config = {
85
+ entityRef: "group:default/team-platform",
86
+ };
87
+
88
+ const result = userConfigSchemaV1.safeParse(config);
89
+ expect(result.success).toBe(true);
90
+ if (result.success) {
91
+ expect(result.data.entityRef).toBe("group:default/team-platform");
92
+ }
93
+ });
94
+ });
95
+
96
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
97
+ // Severity Mapping Tests
98
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
99
+
100
+ describe("mapImportanceToSeverity", () => {
101
+ it("should map 'info' to 'normal'", () => {
102
+ expect(mapImportanceToSeverity("info")).toBe("normal");
103
+ });
104
+
105
+ it("should map 'warning' to 'high'", () => {
106
+ expect(mapImportanceToSeverity("warning")).toBe("high");
107
+ });
108
+
109
+ it("should map 'critical' to 'critical'", () => {
110
+ expect(mapImportanceToSeverity("critical")).toBe("critical");
111
+ });
112
+ });
113
+
114
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
115
+ // Strategy Send Function Tests
116
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
117
+
118
+ describe("backstageStrategy.send", () => {
119
+ // Note: Full integration tests would require mocking the plugin registration
120
+ // For now, we test the exported utility functions and schema validation
121
+ // The send function is tested indirectly through proper config validation
122
+
123
+ it("should validate that baseUrl and token are required for sending", () => {
124
+ // This test verifies the config validation logic
125
+ // The send function checks for baseUrl and token presence
126
+ const emptyConfig = backstageConfigSchemaV1.parse({});
127
+ expect(emptyConfig.baseUrl).toBeUndefined();
128
+ expect(emptyConfig.token).toBeUndefined();
129
+ // Strategy will return error when these are missing
130
+ });
131
+
132
+ it("should build correct entity ref from email when user config is empty", () => {
133
+ // Test the entity ref construction logic
134
+ const prefix = "user:default/";
135
+ const email = "john.doe@example.com";
136
+ const emailPart = email.split("@")[0]?.toLowerCase() ?? "";
137
+ const expectedRef = `${prefix}${emailPart}`;
138
+
139
+ expect(expectedRef).toBe("user:default/john.doe");
140
+ });
141
+
142
+ it("should strip trailing slash from baseUrl", () => {
143
+ const baseUrl = "https://backstage.example.com/";
144
+ const normalizedUrl = baseUrl.replace(/\/$/, "");
145
+ expect(normalizedUrl).toBe("https://backstage.example.com");
146
+ });
147
+ });
package/src/index.ts ADDED
@@ -0,0 +1,292 @@
1
+ import {
2
+ createBackendPlugin,
3
+ type NotificationStrategy,
4
+ Versioned,
5
+ configString,
6
+ markdownToPlainText,
7
+ } from "@checkstack/backend-api";
8
+ import { notificationStrategyExtensionPoint } from "@checkstack/notification-backend";
9
+ import { z } from "zod";
10
+ import { pluginMetadata } from "./plugin-metadata";
11
+
12
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13
+ // Admin Configuration Schema
14
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
15
+
16
+ /**
17
+ * Backstage configuration schema with versioning support.
18
+ * Admins configure the Backstage instance URL and access token.
19
+ */
20
+ const backstageConfigSchemaV1 = z.object({
21
+ baseUrl: configString({})
22
+ .url()
23
+ .optional()
24
+ .describe(
25
+ "Backstage instance base URL (e.g., https://backstage.example.com)"
26
+ ),
27
+ token: configString({ "x-secret": true })
28
+ .optional()
29
+ .describe("Backstage API access token for external service authentication"),
30
+ defaultEntityPrefix: configString({})
31
+ .optional()
32
+ .default("user:default/")
33
+ .describe("Default entity prefix for user mapping (e.g., user:default/)"),
34
+ });
35
+
36
+ type BackstageConfig = z.infer<typeof backstageConfigSchemaV1>;
37
+
38
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
39
+ // User Configuration Schema
40
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41
+
42
+ /**
43
+ * Per-user configuration for Backstage notifications.
44
+ * Users can specify their entity reference in Backstage.
45
+ */
46
+ const userConfigSchemaV1 = z.object({
47
+ entityRef: configString({})
48
+ .optional()
49
+ .describe("Your Backstage entity reference (e.g., user:default/john.doe)"),
50
+ });
51
+
52
+ type BackstageUserConfig = z.infer<typeof userConfigSchemaV1>;
53
+
54
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
55
+ // Instructions
56
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
57
+
58
+ const adminInstructions = `
59
+ ## Backstage Configuration
60
+
61
+ Connect Checkstack to your Backstage instance to forward notifications.
62
+
63
+ ### Prerequisites
64
+
65
+ 1. Your Backstage instance must have the **notifications plugin** installed
66
+ 2. You need to enable **external access** for the notifications API
67
+
68
+ ### Setup Steps
69
+
70
+ 1. Enable external service access in your Backstage instance
71
+ 2. Generate a static access token (see [Backstage docs](https://backstage.io/docs/auth/service-to-service-auth))
72
+ 3. Enter your Backstage instance URL (e.g., \`https://backstage.example.com\`)
73
+ 4. Paste the access token in the **Token** field
74
+
75
+ > **Note**: The default entity prefix is used when users don't specify their own entity reference.
76
+ `.trim();
77
+
78
+ const userInstructions = `
79
+ ## Connect to Backstage
80
+
81
+ Receive Checkstack notifications in your Backstage notification inbox.
82
+
83
+ ### Find Your Entity Reference
84
+
85
+ Your entity reference is how Backstage identifies you. It typically follows the format:
86
+ - \`user:default/your.username\`
87
+ - \`user:default/your-email\`
88
+
89
+ You can find this in your Backstage profile or catalog.
90
+
91
+ > **Tip**: If you leave this blank, the system will try to use your email with the default prefix.
92
+ `.trim();
93
+
94
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
95
+ // Severity Mapping
96
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
97
+
98
+ /**
99
+ * Maps Checkstack importance levels to Backstage severity levels.
100
+ */
101
+ function mapImportanceToSeverity(
102
+ importance: "info" | "warning" | "critical"
103
+ ): "low" | "normal" | "high" | "critical" {
104
+ switch (importance) {
105
+ case "info": {
106
+ return "normal";
107
+ }
108
+ case "warning": {
109
+ return "high";
110
+ }
111
+ case "critical": {
112
+ return "critical";
113
+ }
114
+ default: {
115
+ return "normal";
116
+ }
117
+ }
118
+ }
119
+
120
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
121
+ // Backstage Strategy Implementation
122
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
123
+
124
+ const backstageStrategy: NotificationStrategy<
125
+ BackstageConfig,
126
+ BackstageUserConfig,
127
+ undefined
128
+ > = {
129
+ id: "backstage",
130
+ displayName: "Backstage",
131
+ description: "Send notifications to your Backstage developer portal",
132
+ icon: "LayoutDashboard",
133
+
134
+ config: new Versioned({
135
+ version: 1,
136
+ schema: backstageConfigSchemaV1,
137
+ }),
138
+
139
+ userConfig: new Versioned({
140
+ version: 1,
141
+ schema: userConfigSchemaV1,
142
+ }),
143
+
144
+ contactResolution: { type: "user-config", field: "entityRef" },
145
+
146
+ adminInstructions,
147
+ userInstructions,
148
+
149
+ async send({
150
+ contact,
151
+ notification,
152
+ strategyConfig,
153
+ userConfig,
154
+ user,
155
+ logger,
156
+ }) {
157
+ // Validate required admin config
158
+ if (!strategyConfig.baseUrl || !strategyConfig.token) {
159
+ return {
160
+ success: false,
161
+ error:
162
+ "Backstage is not configured. Please configure baseUrl and token.",
163
+ };
164
+ }
165
+
166
+ // Determine the entity reference
167
+ let entityRef = userConfig?.entityRef;
168
+
169
+ // Fallback: construct from email using default prefix
170
+ if (!entityRef && user.email) {
171
+ const prefix = strategyConfig.defaultEntityPrefix ?? "user:default/";
172
+ // Convert email to entity-safe format (replace @ and . with common patterns)
173
+ const emailPart = user.email.split("@")[0]?.toLowerCase() ?? "";
174
+ entityRef = `${prefix}${emailPart}`;
175
+ }
176
+
177
+ // Still no entity ref? Use the contact as-is (might be set by system)
178
+ if (!entityRef) {
179
+ entityRef = contact;
180
+ }
181
+
182
+ if (!entityRef) {
183
+ return {
184
+ success: false,
185
+ error: "No Backstage entity reference configured for this user.",
186
+ };
187
+ }
188
+
189
+ // Build the notification payload
190
+ const description = notification.body
191
+ ? markdownToPlainText(notification.body)
192
+ : undefined;
193
+
194
+ const payload = {
195
+ recipients: {
196
+ type: "entity" as const,
197
+ entityRef,
198
+ },
199
+ payload: {
200
+ title: notification.title,
201
+ ...(description && { description }),
202
+ ...(notification.action?.url && { link: notification.action.url }),
203
+ severity: mapImportanceToSeverity(notification.importance),
204
+ ...(notification.type && { topic: notification.type }),
205
+ },
206
+ };
207
+
208
+ // Send to Backstage
209
+ const url = `${strategyConfig.baseUrl.replace(
210
+ /\/$/,
211
+ ""
212
+ )}/api/notifications/notifications`;
213
+
214
+ try {
215
+ logger?.debug?.("Sending notification to Backstage", {
216
+ url,
217
+ entityRef,
218
+ title: notification.title,
219
+ });
220
+
221
+ const response = await fetch(url, {
222
+ method: "POST",
223
+ headers: {
224
+ "Content-Type": "application/json",
225
+ Authorization: `Bearer ${strategyConfig.token}`,
226
+ },
227
+ body: JSON.stringify(payload),
228
+ });
229
+
230
+ if (!response.ok) {
231
+ const errorText = await response.text();
232
+ logger?.error?.("Backstage API error", {
233
+ status: response.status,
234
+ error: errorText,
235
+ });
236
+ return {
237
+ success: false,
238
+ error: `Backstage API error: ${response.status} - ${errorText}`,
239
+ };
240
+ }
241
+
242
+ // Try to extract notification ID from response
243
+ let externalId: string | undefined;
244
+ try {
245
+ const result = (await response.json()) as { id?: string };
246
+ externalId = result.id;
247
+ } catch {
248
+ // Response might not be JSON, that's ok
249
+ }
250
+
251
+ logger?.info?.("Notification sent to Backstage", {
252
+ entityRef,
253
+ externalId,
254
+ });
255
+
256
+ return {
257
+ success: true,
258
+ ...(externalId && { externalId }),
259
+ };
260
+ } catch (error) {
261
+ logger?.error?.("Failed to send notification to Backstage", {
262
+ error: error instanceof Error ? error.message : String(error),
263
+ });
264
+ return {
265
+ success: false,
266
+ error: error instanceof Error ? error.message : String(error),
267
+ };
268
+ }
269
+ },
270
+ };
271
+
272
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
273
+ // Plugin Definition
274
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
275
+
276
+ export default createBackendPlugin({
277
+ metadata: pluginMetadata,
278
+
279
+ register(env) {
280
+ // Get the notification strategy extension point
281
+ const extensionPoint = env.getExtensionPoint(
282
+ notificationStrategyExtensionPoint
283
+ );
284
+
285
+ // Register the Backstage strategy with our plugin metadata
286
+ extensionPoint.addStrategy(backstageStrategy, pluginMetadata);
287
+ },
288
+ });
289
+
290
+ // Export for testing
291
+ export { backstageConfigSchemaV1, userConfigSchemaV1, mapImportanceToSeverity };
292
+ export type { BackstageConfig, BackstageUserConfig };
@@ -0,0 +1,9 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ /**
4
+ * Plugin metadata for the Backstage Notification backend.
5
+ * This is the single source of truth for the plugin ID.
6
+ */
7
+ export const pluginMetadata = definePluginMetadata({
8
+ pluginId: "notification-backstage",
9
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json"
3
+ }