@checkstack/notification-backend 1.0.5 → 1.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 CHANGED
@@ -1,5 +1,45 @@
1
1
  # @checkstack/notification-backend
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a06b899: Dead-code audit cleanup and a small platform of shared notification helpers.
8
+
9
+ **Removed (dead code)**
10
+
11
+ - `core/backend/src/plugin-manager/deregistration-guard.ts` deleted. The exported `assertCanDeregister()` was never called and was a less-complete version of the dependents+isUninstallable checks already done inline by `previewUninstallOriginator` / `uninstallOriginator` in `plugin-manager-orchestrator.ts`.
12
+ - `createMockQueueFactory` deprecated alias removed from `@checkstack/test-utils-backend`. Use `createMockQueueManager` directly.
13
+
14
+ **New shared helpers**
15
+
16
+ - `@checkstack/backend-api` now exports `requestTimeoutMs()` — a Zod field builder for outbound HTTP request timeouts (1s..60s, default 10s). Replaces hand-rolled `configNumber({}).min(1000).max(60_000).default(10_000)` in `integration-webhook-backend`, `integration-script-backend`, and `healthcheck-script-backend`'s inline collector.
17
+ - `@checkstack/notification-common` now exports `SubjectStatusSchema` / `SubjectStatus`, mirroring the existing `ImportanceSchema`.
18
+ - `@checkstack/notification-backend` now exports:
19
+ - `SUBJECT_STATUS_EMOJI` / `IMPORTANCE_EMOJI` — the shared status / importance emoji maps that Discord, Slack, Teams, Webex and Telegram previously each redefined inline.
20
+ - `postJson(opts)` — a timeout-bounded `fetch` wrapper that handles non-2xx logging and error mapping for webhook-style POSTs. Returns `{ ok: true, response } | { ok: false, error }`.
21
+
22
+ **Migrated to shared helpers**
23
+
24
+ - Discord, Slack, Gotify, Pushover notification backends now use `postJson`. Outer try/catch + per-plugin error mapping deleted (~140 LOC).
25
+ - Discord, Slack, Teams, Telegram, Webex notification backends now use `IMPORTANCE_EMOJI`. Discord, Slack, Teams use `SUBJECT_STATUS_EMOJI`.
26
+ - Teams, Webex, Backstage, Telegram kept their inline fetch/Bot logic: their error strings surface server response bodies to operators, or the transport isn't raw `fetch` (Telegram uses `grammy`'s `Bot`).
27
+
28
+ **API surface tightening**
29
+
30
+ - Per-plugin test-only re-exports in 6 notification backends (Pushover, Gotify, Backstage, Slack, Discord, Teams) and the `CertificateInfo` interface in `healthcheck-tls-backend/strategy.ts` are now JSDoc-tagged `@internal`. No behaviour change; signals that downstream consumers must not depend on them.
31
+
32
+ ### Patch Changes
33
+
34
+ - Updated dependencies [a06b899]
35
+ - Updated dependencies [a06b899]
36
+ - @checkstack/backend-api@0.16.0
37
+ - @checkstack/notification-common@1.1.1
38
+ - @checkstack/auth-backend@0.4.28
39
+ - @checkstack/cache-api@0.3.3
40
+ - @checkstack/queue-api@0.3.3
41
+ - @checkstack/cache-utils@0.2.8
42
+
3
43
  ## 1.0.5
4
44
 
5
45
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/notification-backend",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -16,12 +16,12 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@checkstack/notification-common": "1.1.0",
19
- "@checkstack/backend-api": "0.15.2",
20
- "@checkstack/cache-api": "0.3.1",
21
- "@checkstack/cache-utils": "0.2.6",
19
+ "@checkstack/backend-api": "0.15.3",
20
+ "@checkstack/cache-api": "0.3.2",
21
+ "@checkstack/cache-utils": "0.2.7",
22
22
  "@checkstack/signal-common": "0.2.3",
23
- "@checkstack/queue-api": "0.3.1",
24
- "@checkstack/auth-backend": "0.4.26",
23
+ "@checkstack/queue-api": "0.3.2",
24
+ "@checkstack/auth-backend": "0.4.27",
25
25
  "@checkstack/auth-common": "0.7.0",
26
26
  "drizzle-orm": "^0.45.0",
27
27
  "zod": "^4.2.1",
@@ -32,7 +32,7 @@
32
32
  "@checkstack/drizzle-helper": "0.0.5",
33
33
  "@checkstack/scripts": "0.3.2",
34
34
  "@checkstack/tsconfig": "0.0.7",
35
- "@checkstack/test-utils-backend": "0.1.26",
35
+ "@checkstack/test-utils-backend": "0.1.27",
36
36
  "@types/node": "^20.0.0",
37
37
  "drizzle-kit": "^0.31.10",
38
38
  "typescript": "^5.0.0"
package/src/index.ts CHANGED
@@ -46,6 +46,14 @@ export const notificationStrategyExtensionPoint =
46
46
  "notification.strategyExtensionPoint"
47
47
  );
48
48
 
49
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
50
+ // Shared render helpers for notification strategies
51
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
52
+
53
+ export { SUBJECT_STATUS_EMOJI, IMPORTANCE_EMOJI } from "./render";
54
+ export { postJson } from "./post-json";
55
+ export type { PostJsonOptions, PostJsonResult } from "./post-json";
56
+
49
57
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
50
58
  // Registry Implementation
51
59
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -0,0 +1,133 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import type { Logger } from "@checkstack/backend-api";
3
+ import { postJson } from "./post-json";
4
+
5
+ function makeLogger(): Logger {
6
+ return {
7
+ info: mock(() => {}),
8
+ error: mock(() => {}),
9
+ warn: mock(() => {}),
10
+ debug: mock(() => {}),
11
+ };
12
+ }
13
+
14
+ describe("postJson", () => {
15
+ it("returns ok:true with the response on 2xx", async () => {
16
+ const responses: string[] = [];
17
+ const originalFetch = globalThis.fetch;
18
+ globalThis.fetch = mock(async (url: RequestInfo | URL) => {
19
+ responses.push(String(url));
20
+ return new Response(null, { status: 204 });
21
+ }) as unknown as typeof fetch;
22
+ try {
23
+ const logger = makeLogger();
24
+ const result = await postJson({
25
+ url: "https://example.test/hook",
26
+ body: { hello: "world" },
27
+ serviceName: "Test",
28
+ logger,
29
+ });
30
+ expect(result.ok).toBe(true);
31
+ if (result.ok) {
32
+ expect(result.response.status).toBe(204);
33
+ }
34
+ expect(responses).toEqual(["https://example.test/hook"]);
35
+ expect(logger.error).not.toHaveBeenCalled();
36
+ } finally {
37
+ globalThis.fetch = originalFetch;
38
+ }
39
+ });
40
+
41
+ it("returns ok:false with a service-tagged error on non-2xx", async () => {
42
+ const originalFetch = globalThis.fetch;
43
+ globalThis.fetch = mock(async () =>
44
+ new Response("Bad request body", { status: 400 }),
45
+ ) as unknown as typeof fetch;
46
+ try {
47
+ const logger = makeLogger();
48
+ const result = await postJson({
49
+ url: "https://example.test/hook",
50
+ body: { hello: "world" },
51
+ serviceName: "Discord",
52
+ logger,
53
+ });
54
+ expect(result.ok).toBe(false);
55
+ if (!result.ok) {
56
+ expect(result.error).toBe("Failed to send Discord message: 400");
57
+ }
58
+ expect(logger.error).toHaveBeenCalledTimes(1);
59
+ } finally {
60
+ globalThis.fetch = originalFetch;
61
+ }
62
+ });
63
+
64
+ it("returns ok:false when fetch rejects (network / timeout)", async () => {
65
+ const originalFetch = globalThis.fetch;
66
+ globalThis.fetch = mock(async () => {
67
+ throw new Error("ECONNREFUSED");
68
+ }) as unknown as typeof fetch;
69
+ try {
70
+ const logger = makeLogger();
71
+ const result = await postJson({
72
+ url: "https://example.test/hook",
73
+ body: {},
74
+ serviceName: "Slack",
75
+ logger,
76
+ });
77
+ expect(result.ok).toBe(false);
78
+ if (!result.ok) {
79
+ expect(result.error).toBe(
80
+ "Failed to send Slack notification: ECONNREFUSED",
81
+ );
82
+ }
83
+ expect(logger.error).toHaveBeenCalledTimes(1);
84
+ } finally {
85
+ globalThis.fetch = originalFetch;
86
+ }
87
+ });
88
+
89
+ it("merges caller headers on top of the default Content-Type", async () => {
90
+ let observedHeaders: Headers | undefined;
91
+ const originalFetch = globalThis.fetch;
92
+ globalThis.fetch = mock(async (_url: RequestInfo | URL, init?: RequestInit) => {
93
+ observedHeaders = new Headers(init?.headers);
94
+ return new Response(null, { status: 200 });
95
+ }) as unknown as typeof fetch;
96
+ try {
97
+ await postJson({
98
+ url: "https://example.test/hook",
99
+ body: {},
100
+ headers: { Authorization: "Bearer token" },
101
+ serviceName: "Webex",
102
+ logger: makeLogger(),
103
+ });
104
+ expect(observedHeaders?.get("content-type")).toBe("application/json");
105
+ expect(observedHeaders?.get("authorization")).toBe("Bearer token");
106
+ } finally {
107
+ globalThis.fetch = originalFetch;
108
+ }
109
+ });
110
+
111
+ it("truncates long error bodies in the log payload", async () => {
112
+ const longBody = "x".repeat(2_000);
113
+ const originalFetch = globalThis.fetch;
114
+ globalThis.fetch = mock(async () =>
115
+ new Response(longBody, { status: 500 }),
116
+ ) as unknown as typeof fetch;
117
+ try {
118
+ const logger = makeLogger();
119
+ await postJson({
120
+ url: "https://example.test/hook",
121
+ body: {},
122
+ serviceName: "Gotify",
123
+ logger,
124
+ });
125
+ const errorCall = (logger.error as ReturnType<typeof mock>).mock
126
+ .calls[0];
127
+ const meta = errorCall?.[1] as { error: string } | undefined;
128
+ expect(meta?.error.length).toBe(500);
129
+ } finally {
130
+ globalThis.fetch = originalFetch;
131
+ }
132
+ });
133
+ });
@@ -0,0 +1,86 @@
1
+ import type { Logger } from "@checkstack/backend-api";
2
+ import { extractErrorMessage } from "@checkstack/common";
3
+
4
+ /**
5
+ * Outcome of a [[postJson]] call. Discriminated on `ok`:
6
+ * - `ok: true` carries the raw `Response` so callers can read service-specific
7
+ * success payloads (e.g. Pushover's `receipt`).
8
+ * - `ok: false` carries a human-readable error string already suitable for
9
+ * surfacing back to operators via `NotificationDeliveryResult.error`.
10
+ */
11
+ export type PostJsonResult =
12
+ | { ok: true; response: Response }
13
+ | { ok: false; error: string };
14
+
15
+ export interface PostJsonOptions {
16
+ /** Absolute URL to POST to. */
17
+ url: string;
18
+ /** Body, JSON-stringified for you. */
19
+ body: unknown;
20
+ /**
21
+ * Headers merged on top of `Content-Type: application/json`. Provide auth
22
+ * tokens here (e.g. `Bearer ...`).
23
+ */
24
+ headers?: Record<string, string>;
25
+ /** Request timeout. Defaults to 10s, matching the platform-wide default. */
26
+ timeoutMs?: number;
27
+ /**
28
+ * Short, human-readable service label used to build log messages and the
29
+ * returned error string. Example: `"Discord"`, `"Slack webhook"`.
30
+ */
31
+ serviceName: string;
32
+ logger: Logger;
33
+ }
34
+
35
+ /**
36
+ * Shared POST helper for notification strategies. Centralises the
37
+ * timeout-bounded fetch, non-2xx logging, and error mapping that every
38
+ * webhook-style notification plugin (Discord, Slack, Teams, Webex, Telegram,
39
+ * Gotify, Pushover, ...) was previously re-implementing inline.
40
+ *
41
+ * Plugins remain responsible for building the request body and interpreting
42
+ * a successful `Response` (some services return JSON receipts; most return
43
+ * 204 No Content).
44
+ */
45
+ export async function postJson(
46
+ options: PostJsonOptions,
47
+ ): Promise<PostJsonResult> {
48
+ const {
49
+ url,
50
+ body,
51
+ headers = {},
52
+ timeoutMs = 10_000,
53
+ serviceName,
54
+ logger,
55
+ } = options;
56
+
57
+ try {
58
+ const response = await fetch(url, {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json", ...headers },
61
+ body: JSON.stringify(body),
62
+ signal: AbortSignal.timeout(timeoutMs),
63
+ });
64
+
65
+ if (!response.ok) {
66
+ const errorText = await response.text();
67
+ logger.error(`Failed to send ${serviceName} message`, {
68
+ status: response.status,
69
+ error: errorText.slice(0, 500),
70
+ });
71
+ return {
72
+ ok: false,
73
+ error: `Failed to send ${serviceName} message: ${response.status}`,
74
+ };
75
+ }
76
+
77
+ return { ok: true, response };
78
+ } catch (error) {
79
+ const message = extractErrorMessage(error, `Unknown ${serviceName} error`);
80
+ logger.error(`${serviceName} request error`, { error: message });
81
+ return {
82
+ ok: false,
83
+ error: `Failed to send ${serviceName} notification: ${message}`,
84
+ };
85
+ }
86
+ }
package/src/render.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type {
2
+ Importance,
3
+ SubjectStatus,
4
+ } from "@checkstack/notification-common";
5
+
6
+ /**
7
+ * Emoji used to prefix an affected subject in a rendered notification, keyed
8
+ * by the subject's health status. Plugins that render notifications in
9
+ * plain-text channels (Slack, Discord, Teams, Telegram, Webex, etc.) should
10
+ * use this map instead of redefining their own so operators see a consistent
11
+ * visual language across destinations.
12
+ */
13
+ export const SUBJECT_STATUS_EMOJI: Record<SubjectStatus, string> = {
14
+ healthy: "🟢",
15
+ degraded: "🟡",
16
+ unhealthy: "🔴",
17
+ unknown: "⚪",
18
+ };
19
+
20
+ /**
21
+ * Emoji used to prefix a notification's title in a rendered message, keyed
22
+ * by the notification's importance level. Plugins should use this map rather
23
+ * than redefining their own.
24
+ */
25
+ export const IMPORTANCE_EMOJI: Record<Importance, string> = {
26
+ info: "ℹ️",
27
+ warning: "⚠️",
28
+ critical: "🚨",
29
+ };