@checkstack/notification-backend 1.0.4 → 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,80 @@
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
+
43
+ ## 1.0.5
44
+
45
+ ### Patch Changes
46
+
47
+ - b33fb4d: Refresh `bun.lock` to clear MEDIUM-severity Trivy advisories on transitive
48
+ runtime dependencies. No public API change — bumping every workspace
49
+ package that lists `@orpc/server` as a direct dep so consumers re-resolve
50
+ the optional `ws` peer to the patched release on their next install.
51
+
52
+ - `ws` `8.20.0` → `8.20.1` (CVE-2026-45736). Pulled into the install tree
53
+ as `@orpc/server`'s optional WebSocket peer; Bun auto-installs it into
54
+ every backend package that depends on `@orpc/server`, so a stale 8.20.0
55
+ ships in the consumer's `node_modules` until the parent package
56
+ re-resolves.
57
+ - `brace-expansion` `5.0.5` → `5.0.6` (CVE-2026-45149). Pulled in only
58
+ through dev tooling (`minimatch@10` via `@typescript-eslint` and
59
+ `storybook`'s `glob@13`), so it does not ship to consumers and no
60
+ workspace `package.json` lists it; the lockfile bump alone clears the
61
+ finding for the Docker image and the local dev tree. No version bump
62
+ is attributed to this advisory.
63
+
64
+ The fix lives entirely in `bun.lock` — no `package.json`, `overrides`, or
65
+ `resolutions` change is needed because both parent ranges (`minimatch@10
66
+ → brace-expansion@^5.0.5`, `@orpc/server / storybook / happy-dom →
67
+ ws@>=8.18.x`) already accept the patched releases, and `bun install`
68
+ keeps the resolved versions sticky after the initial `bun update`.
69
+
70
+ - Updated dependencies [1909a61]
71
+ - Updated dependencies [b33fb4d]
72
+ - @checkstack/backend-api@0.15.3
73
+ - @checkstack/auth-backend@0.4.27
74
+ - @checkstack/cache-api@0.3.2
75
+ - @checkstack/queue-api@0.3.2
76
+ - @checkstack/cache-utils@0.2.7
77
+
3
78
  ## 1.0.4
4
79
 
5
80
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/notification-backend",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -15,24 +15,24 @@
15
15
  "test": "bun test"
16
16
  },
17
17
  "dependencies": {
18
- "@checkstack/notification-common": "1.0.2",
19
- "@checkstack/backend-api": "0.15.1",
20
- "@checkstack/cache-api": "0.3.0",
21
- "@checkstack/cache-utils": "0.2.5",
22
- "@checkstack/signal-common": "0.2.2",
23
- "@checkstack/queue-api": "0.3.0",
24
- "@checkstack/auth-backend": "0.4.25",
25
- "@checkstack/auth-common": "0.6.6",
18
+ "@checkstack/notification-common": "1.1.0",
19
+ "@checkstack/backend-api": "0.15.3",
20
+ "@checkstack/cache-api": "0.3.2",
21
+ "@checkstack/cache-utils": "0.2.7",
22
+ "@checkstack/signal-common": "0.2.3",
23
+ "@checkstack/queue-api": "0.3.2",
24
+ "@checkstack/auth-backend": "0.4.27",
25
+ "@checkstack/auth-common": "0.7.0",
26
26
  "drizzle-orm": "^0.45.0",
27
27
  "zod": "^4.2.1",
28
- "@checkstack/common": "0.9.0",
28
+ "@checkstack/common": "0.10.0",
29
29
  "@orpc/server": "^1.13.2"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@checkstack/drizzle-helper": "0.0.5",
33
- "@checkstack/scripts": "0.3.1",
33
+ "@checkstack/scripts": "0.3.2",
34
34
  "@checkstack/tsconfig": "0.0.7",
35
- "@checkstack/test-utils-backend": "0.1.25",
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
+ };