@terreno/api 0.0.1

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.
Files changed (119) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +170 -0
  3. package/biome.jsonc +22 -0
  4. package/bunfig.toml +4 -0
  5. package/dist/api.d.ts +227 -0
  6. package/dist/api.js +1024 -0
  7. package/dist/api.test.d.ts +1 -0
  8. package/dist/api.test.js +2143 -0
  9. package/dist/auth.d.ts +50 -0
  10. package/dist/auth.js +512 -0
  11. package/dist/auth.test.d.ts +1 -0
  12. package/dist/auth.test.js +778 -0
  13. package/dist/errors.d.ts +75 -0
  14. package/dist/errors.js +216 -0
  15. package/dist/example.d.ts +1 -0
  16. package/dist/example.js +118 -0
  17. package/dist/expressServer.d.ts +35 -0
  18. package/dist/expressServer.js +436 -0
  19. package/dist/index.d.ts +14 -0
  20. package/dist/index.js +30 -0
  21. package/dist/logger.d.ts +23 -0
  22. package/dist/logger.js +249 -0
  23. package/dist/middleware.d.ts +10 -0
  24. package/dist/middleware.js +52 -0
  25. package/dist/notifiers/googleChatNotifier.d.ts +5 -0
  26. package/dist/notifiers/googleChatNotifier.js +130 -0
  27. package/dist/notifiers/googleChatNotifier.test.d.ts +1 -0
  28. package/dist/notifiers/googleChatNotifier.test.js +260 -0
  29. package/dist/notifiers/index.d.ts +3 -0
  30. package/dist/notifiers/index.js +19 -0
  31. package/dist/notifiers/slackNotifier.d.ts +5 -0
  32. package/dist/notifiers/slackNotifier.js +130 -0
  33. package/dist/notifiers/slackNotifier.test.d.ts +1 -0
  34. package/dist/notifiers/slackNotifier.test.js +259 -0
  35. package/dist/notifiers/zoomNotifier.d.ts +34 -0
  36. package/dist/notifiers/zoomNotifier.js +181 -0
  37. package/dist/notifiers/zoomNotifier.test.d.ts +1 -0
  38. package/dist/notifiers/zoomNotifier.test.js +370 -0
  39. package/dist/openApi.d.ts +60 -0
  40. package/dist/openApi.js +441 -0
  41. package/dist/openApi.test.d.ts +1 -0
  42. package/dist/openApi.test.js +445 -0
  43. package/dist/openApiBuilder.d.ts +419 -0
  44. package/dist/openApiBuilder.js +424 -0
  45. package/dist/openApiBuilder.test.d.ts +1 -0
  46. package/dist/openApiBuilder.test.js +509 -0
  47. package/dist/openApiEtag.d.ts +7 -0
  48. package/dist/openApiEtag.js +38 -0
  49. package/dist/permissions.d.ts +26 -0
  50. package/dist/permissions.js +331 -0
  51. package/dist/permissions.test.d.ts +1 -0
  52. package/dist/permissions.test.js +413 -0
  53. package/dist/plugins.d.ts +67 -0
  54. package/dist/plugins.js +315 -0
  55. package/dist/plugins.test.d.ts +1 -0
  56. package/dist/plugins.test.js +639 -0
  57. package/dist/populate.d.ts +14 -0
  58. package/dist/populate.js +315 -0
  59. package/dist/populate.test.d.ts +1 -0
  60. package/dist/populate.test.js +133 -0
  61. package/dist/response.d.ts +0 -0
  62. package/dist/response.js +1 -0
  63. package/dist/tests/bunSetup.d.ts +1 -0
  64. package/dist/tests/bunSetup.js +297 -0
  65. package/dist/tests/index.d.ts +1 -0
  66. package/dist/tests/index.js +17 -0
  67. package/dist/tests.d.ts +99 -0
  68. package/dist/tests.js +273 -0
  69. package/dist/transformers.d.ts +25 -0
  70. package/dist/transformers.js +217 -0
  71. package/dist/transformers.test.d.ts +1 -0
  72. package/dist/transformers.test.js +370 -0
  73. package/dist/utils.d.ts +11 -0
  74. package/dist/utils.js +143 -0
  75. package/dist/utils.test.d.ts +1 -0
  76. package/dist/utils.test.js +14 -0
  77. package/index.ts +1 -0
  78. package/package.json +88 -0
  79. package/src/__snapshots__/openApi.test.ts.snap +4814 -0
  80. package/src/__snapshots__/openApiBuilder.test.ts.snap +1485 -0
  81. package/src/api.test.ts +1661 -0
  82. package/src/api.ts +1036 -0
  83. package/src/auth.test.ts +550 -0
  84. package/src/auth.ts +408 -0
  85. package/src/errors.ts +225 -0
  86. package/src/example.ts +99 -0
  87. package/src/express.d.ts +5 -0
  88. package/src/expressServer.ts +387 -0
  89. package/src/index.ts +14 -0
  90. package/src/logger.ts +190 -0
  91. package/src/middleware.ts +18 -0
  92. package/src/notifiers/googleChatNotifier.test.ts +114 -0
  93. package/src/notifiers/googleChatNotifier.ts +47 -0
  94. package/src/notifiers/index.ts +3 -0
  95. package/src/notifiers/slackNotifier.test.ts +113 -0
  96. package/src/notifiers/slackNotifier.ts +55 -0
  97. package/src/notifiers/zoomNotifier.test.ts +207 -0
  98. package/src/notifiers/zoomNotifier.ts +111 -0
  99. package/src/openApi.test.ts +331 -0
  100. package/src/openApi.ts +494 -0
  101. package/src/openApiBuilder.test.ts +442 -0
  102. package/src/openApiBuilder.ts +636 -0
  103. package/src/openApiEtag.ts +40 -0
  104. package/src/permissions.test.ts +219 -0
  105. package/src/permissions.ts +228 -0
  106. package/src/plugins.test.ts +390 -0
  107. package/src/plugins.ts +289 -0
  108. package/src/populate.test.ts +65 -0
  109. package/src/populate.ts +258 -0
  110. package/src/response.ts +0 -0
  111. package/src/tests/bunSetup.ts +234 -0
  112. package/src/tests/index.ts +1 -0
  113. package/src/tests.ts +218 -0
  114. package/src/transformers.test.ts +202 -0
  115. package/src/transformers.ts +170 -0
  116. package/src/utils.test.ts +14 -0
  117. package/src/utils.ts +47 -0
  118. package/tsconfig.json +60 -0
  119. package/types.d.ts +17 -0
@@ -0,0 +1,18 @@
1
+ import * as Sentry from "@sentry/node";
2
+ import type {NextFunction, Request, Response} from "express";
3
+
4
+ /**
5
+ * Express middleware that captures the app version from the request header
6
+ * and adds it as a tag to the current Sentry scope.
7
+ *
8
+ * This allows filtering Sentry errors by app version.
9
+ *
10
+ * Expected header: `App-Version`
11
+ */
12
+ export function sentryAppVersionMiddleware(req: Request, _res: Response, next: NextFunction): void {
13
+ const appVersion = req.get("App-Version");
14
+ if (appVersion) {
15
+ Sentry.getCurrentScope().setTag("app_version", appVersion);
16
+ }
17
+ next();
18
+ }
@@ -0,0 +1,114 @@
1
+ import {afterAll, afterEach, beforeEach, describe, expect, it, type Mock, spyOn} from "bun:test";
2
+ import * as Sentry from "@sentry/node";
3
+ import axios from "axios";
4
+
5
+ import {sendToGoogleChat} from "./googleChatNotifier";
6
+
7
+ describe("sendToGoogleChat", () => {
8
+ let mockAxiosPost: Mock<typeof axios.post>;
9
+
10
+ const ORIGINAL_ENV = process.env;
11
+
12
+ beforeEach(() => {
13
+ mockAxiosPost = spyOn(axios, "post").mockResolvedValue({status: 200} as any);
14
+ process.env = {...ORIGINAL_ENV};
15
+ process.env.GOOGLE_CHAT_WEBHOOKS = undefined;
16
+ (Sentry.captureException as Mock<typeof Sentry.captureException>).mockClear();
17
+ (Sentry.captureMessage as Mock<typeof Sentry.captureMessage>).mockClear();
18
+ });
19
+
20
+ afterEach(() => {
21
+ mockAxiosPost.mockRestore();
22
+ });
23
+
24
+ afterAll(() => {
25
+ process.env = ORIGINAL_ENV;
26
+ });
27
+
28
+ it("returns early when GOOGLE_CHAT_WEBHOOKS is missing", async () => {
29
+ await sendToGoogleChat("hello");
30
+ expect(mockAxiosPost.mock.calls.length).toBe(0);
31
+ });
32
+
33
+ it("posts to default webhook with plain text", async () => {
34
+ process.env.GOOGLE_CHAT_WEBHOOKS = JSON.stringify({
35
+ default: "https://chat.example/webhook",
36
+ });
37
+ mockAxiosPost.mockResolvedValue({status: 200});
38
+
39
+ await sendToGoogleChat("hello world");
40
+ const callArgs = mockAxiosPost.mock.calls[0];
41
+ expect(Array.isArray(callArgs)).toBe(true);
42
+ const [url, payload] = callArgs;
43
+ expect(url).toBe("https://chat.example/webhook");
44
+ expect(payload).toEqual({text: "hello world"});
45
+ });
46
+
47
+ it("posts to a specific channel when provided", async () => {
48
+ process.env.GOOGLE_CHAT_WEBHOOKS = JSON.stringify({
49
+ default: "https://chat.example/default",
50
+ ops: "https://chat.example/ops",
51
+ });
52
+ mockAxiosPost.mockResolvedValue({status: 200});
53
+
54
+ await sendToGoogleChat("ops msg", {channel: "ops"});
55
+ const callArgs = mockAxiosPost.mock.calls[0];
56
+ expect(Array.isArray(callArgs)).toBe(true);
57
+ const [url, payload] = callArgs;
58
+ expect(url).toBe("https://chat.example/ops");
59
+ expect(payload).toEqual({text: "ops msg"});
60
+ });
61
+
62
+ it("falls back to default when channel not found", async () => {
63
+ process.env.GOOGLE_CHAT_WEBHOOKS = JSON.stringify({
64
+ default: "https://chat.example/default",
65
+ });
66
+ mockAxiosPost.mockResolvedValue({status: 200});
67
+
68
+ await sendToGoogleChat("missing channel", {channel: "unknown"});
69
+ const callArgs = mockAxiosPost.mock.calls[0];
70
+ expect(Array.isArray(callArgs)).toBe(true);
71
+ const [url, payload] = callArgs;
72
+ expect(url).toBe("https://chat.example/default");
73
+ expect(payload).toEqual({text: "missing channel"});
74
+ });
75
+
76
+ it("prefixes message with [ENV] when env provided", async () => {
77
+ process.env.GOOGLE_CHAT_WEBHOOKS = JSON.stringify({
78
+ default: "https://chat.example/default",
79
+ });
80
+ mockAxiosPost.mockResolvedValue({status: 200});
81
+
82
+ await sendToGoogleChat("status ok", {env: "prod"});
83
+ const callArgs = mockAxiosPost.mock.calls[0];
84
+ expect(Array.isArray(callArgs)).toBe(true);
85
+ const [, payload] = callArgs;
86
+ expect(payload).toEqual({text: "[PROD] status ok"});
87
+ });
88
+
89
+ it("captures error and throws APIError when shouldThrow=true", async () => {
90
+ process.env.GOOGLE_CHAT_WEBHOOKS = JSON.stringify({
91
+ default: "https://chat.example/default",
92
+ });
93
+ mockAxiosPost.mockRejectedValue(new Error("chat down"));
94
+
95
+ try {
96
+ await sendToGoogleChat("err", {shouldThrow: true});
97
+ throw new Error("Expected sendToGoogleChat to throw APIError");
98
+ } catch (error) {
99
+ expect((error as any).name).toBe("APIError");
100
+ expect((error as any).title).toMatch(/Error posting to Google Chat/i);
101
+ }
102
+ expect(mockAxiosPost.mock.calls.length).toBe(1);
103
+ });
104
+
105
+ it("captures error and does not throw when shouldThrow=false", async () => {
106
+ process.env.GOOGLE_CHAT_WEBHOOKS = JSON.stringify({
107
+ default: "https://chat.example/default",
108
+ });
109
+ mockAxiosPost.mockRejectedValue(new Error("chat intermittent"));
110
+
111
+ await sendToGoogleChat("err", {shouldThrow: false});
112
+ expect(mockAxiosPost.mock.calls.length).toBe(1);
113
+ });
114
+ });
@@ -0,0 +1,47 @@
1
+ import * as Sentry from "@sentry/node";
2
+ import axios from "axios";
3
+
4
+ import {APIError} from "../errors";
5
+ import {logger} from "../logger";
6
+
7
+ export async function sendToGoogleChat(
8
+ messageText: string,
9
+ {channel, shouldThrow = false, env}: {channel?: string; shouldThrow?: boolean; env?: string} = {}
10
+ ) {
11
+ const chatWebhooksString = process.env.GOOGLE_CHAT_WEBHOOKS;
12
+ if (!chatWebhooksString) {
13
+ const msg = "GOOGLE_CHAT_WEBHOOKS not set. Google Chat message not sent";
14
+ Sentry.captureException(new Error(msg));
15
+ logger.error(msg);
16
+ return;
17
+ }
18
+ const chatWebhooks = JSON.parse(chatWebhooksString ?? "{}");
19
+
20
+ const chatChannel = channel ?? "default";
21
+ const chatWebhookUrl = chatWebhooks[chatChannel] ?? chatWebhooks.default;
22
+
23
+ if (!chatWebhookUrl) {
24
+ const msg = `No webhook url set in env for ${chatChannel}. Google Chat message not sent`;
25
+ Sentry.captureException(new Error(msg));
26
+ logger.error(msg);
27
+ return;
28
+ }
29
+
30
+ let formattedMessageText = messageText;
31
+ if (env) {
32
+ formattedMessageText = `[${env.toUpperCase()}] ${messageText}`;
33
+ }
34
+
35
+ try {
36
+ await axios.post(chatWebhookUrl, {text: formattedMessageText});
37
+ } catch (error: any) {
38
+ logger.error(`Error posting to Google Chat: ${error.text ?? error.message}`);
39
+ Sentry.captureException(error);
40
+ if (shouldThrow) {
41
+ throw new APIError({
42
+ status: 500,
43
+ title: `Error posting to Google Chat: ${error.text ?? error.message}`,
44
+ });
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./googleChatNotifier";
2
+ export * from "./slackNotifier";
3
+ export * from "./zoomNotifier";
@@ -0,0 +1,113 @@
1
+ import {afterAll, afterEach, beforeEach, describe, expect, it, type Mock, spyOn} from "bun:test";
2
+ import * as Sentry from "@sentry/node";
3
+ import axios from "axios";
4
+
5
+ import {sendToSlack} from "./slackNotifier";
6
+
7
+ describe("sendToSlack", () => {
8
+ let mockAxiosPost: Mock<typeof axios.post>;
9
+
10
+ const ORIGINAL_ENV = process.env;
11
+
12
+ beforeEach(() => {
13
+ mockAxiosPost = spyOn(axios, "post").mockResolvedValue({status: 200} as any);
14
+ process.env = {...ORIGINAL_ENV};
15
+ process.env.SLACK_WEBHOOKS = undefined;
16
+ (Sentry.captureException as Mock<typeof Sentry.captureException>).mockClear();
17
+ (Sentry.captureMessage as Mock<typeof Sentry.captureMessage>).mockClear();
18
+ });
19
+
20
+ afterEach(() => {
21
+ mockAxiosPost.mockRestore();
22
+ });
23
+
24
+ afterAll(() => {
25
+ process.env = ORIGINAL_ENV;
26
+ });
27
+
28
+ it("returns early when SLACK_WEBHOOKS is missing", async () => {
29
+ await sendToSlack("hello");
30
+ expect(mockAxiosPost.mock.calls.length).toBe(0);
31
+ });
32
+
33
+ it("posts to default webhook with plain text", async () => {
34
+ process.env.SLACK_WEBHOOKS = JSON.stringify({default: "https://slack.example/webhook"});
35
+ mockAxiosPost.mockResolvedValue({status: 200});
36
+
37
+ await sendToSlack("hello world");
38
+ expect(mockAxiosPost.mock.calls.length).toBe(1);
39
+ const callArgs = mockAxiosPost.mock.calls[0];
40
+ expect(Array.isArray(callArgs)).toBe(true);
41
+ const [url, payload] = callArgs;
42
+ expect(url).toBe("https://slack.example/webhook");
43
+ expect(payload).toEqual({text: "hello world"});
44
+ });
45
+
46
+ it("posts to a specific channel when provided", async () => {
47
+ process.env.SLACK_WEBHOOKS = JSON.stringify({
48
+ default: "https://slack.example/default",
49
+ ops: "https://slack.example/ops",
50
+ });
51
+ mockAxiosPost.mockResolvedValue({status: 200});
52
+
53
+ await sendToSlack("ops msg", {slackChannel: "ops"});
54
+ const callArgs = mockAxiosPost.mock.calls[0];
55
+ expect(Array.isArray(callArgs)).toBe(true);
56
+ const [url, payload] = callArgs;
57
+ expect(url).toBe("https://slack.example/ops");
58
+ expect(payload).toEqual({text: "ops msg"});
59
+ });
60
+
61
+ it("falls back to default when channel not found", async () => {
62
+ process.env.SLACK_WEBHOOKS = JSON.stringify({
63
+ default: "https://slack.example/default",
64
+ });
65
+ mockAxiosPost.mockResolvedValue({status: 200});
66
+
67
+ await sendToSlack("missing channel", {slackChannel: "unknown"});
68
+ const callArgs = mockAxiosPost.mock.calls[0];
69
+ expect(Array.isArray(callArgs)).toBe(true);
70
+ const [url, payload] = callArgs;
71
+ expect(url).toBe("https://slack.example/default");
72
+ expect(payload).toEqual({text: "missing channel"});
73
+ });
74
+
75
+ it("prefixes message with [ENV] when env provided", async () => {
76
+ process.env.SLACK_WEBHOOKS = JSON.stringify({
77
+ default: "https://slack.example/default",
78
+ });
79
+ mockAxiosPost.mockResolvedValue({status: 200});
80
+
81
+ await sendToSlack("status ok", {env: "stg"});
82
+ const callArgs = mockAxiosPost.mock.calls[0];
83
+ expect(Array.isArray(callArgs)).toBe(true);
84
+ const [, payload] = callArgs;
85
+ expect(payload).toEqual({text: "[STG] status ok"});
86
+ });
87
+
88
+ it("captures error and throws APIError when shouldThrow=true", async () => {
89
+ process.env.SLACK_WEBHOOKS = JSON.stringify({
90
+ default: "https://slack.example/default",
91
+ });
92
+ mockAxiosPost.mockRejectedValue(new Error("slack down"));
93
+
94
+ try {
95
+ await sendToSlack("err", {shouldThrow: true});
96
+ throw new Error("Expected sendToSlack to throw APIError");
97
+ } catch (error) {
98
+ expect((error as any).name).toBe("APIError");
99
+ expect((error as any).title).toMatch(/Error posting to slack/i);
100
+ }
101
+ expect(mockAxiosPost.mock.calls.length).toBe(1);
102
+ });
103
+
104
+ it("captures error and does not throw when shouldThrow=false", async () => {
105
+ process.env.SLACK_WEBHOOKS = JSON.stringify({
106
+ default: "https://slack.example/default",
107
+ });
108
+ mockAxiosPost.mockRejectedValue(new Error("slack intermittent"));
109
+
110
+ await sendToSlack("err", {shouldThrow: false});
111
+ expect(mockAxiosPost.mock.calls.length).toBe(1);
112
+ });
113
+ });
@@ -0,0 +1,55 @@
1
+ import * as Sentry from "@sentry/node";
2
+ import axios from "axios";
3
+
4
+ import {APIError} from "../errors";
5
+ import {logger} from "../logger";
6
+ // Convenience method to send data to a Slack webhook.
7
+ export async function sendToSlack(
8
+ text: string,
9
+ {
10
+ slackChannel,
11
+ shouldThrow = false,
12
+ env,
13
+ }: {slackChannel?: string; shouldThrow?: boolean; env?: string} = {}
14
+ ) {
15
+ // since Slack now requires a webhook for each channel, we need to store them in the environment
16
+ // as an object, so we can look them up by channel name.
17
+ const slackWebhooksString = process.env.SLACK_WEBHOOKS;
18
+ if (!slackWebhooksString) {
19
+ logger.debug("You must set SLACK_WEBHOOKS in the environment to use sendToSlack.");
20
+ return;
21
+ }
22
+ const slackWebhooks = JSON.parse(slackWebhooksString ?? "{}");
23
+
24
+ const channel = slackChannel ?? "default";
25
+
26
+ const slackWebhookUrl = slackWebhooks[channel] ?? slackWebhooks.default;
27
+
28
+ if (!slackWebhookUrl) {
29
+ Sentry.captureException(
30
+ new Error(`No webhook url set in env for ${channel}. Slack message not sent`)
31
+ );
32
+ logger.debug(`No webhook url set in env for ${channel}.`);
33
+ return;
34
+ }
35
+
36
+ let formattedText = text;
37
+ if (env) {
38
+ formattedText = `[${env.toUpperCase()}] ${text}`;
39
+ }
40
+
41
+ try {
42
+ await axios.post(slackWebhookUrl, {
43
+ text: formattedText,
44
+ });
45
+ } catch (error: any) {
46
+ logger.error(`Error posting to slack: ${error.text ?? error.message}`);
47
+ Sentry.captureException(error);
48
+ if (shouldThrow) {
49
+ throw new APIError({
50
+ status: 500,
51
+ title: `Error posting to slack: ${error.text ?? error.message}`,
52
+ });
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,207 @@
1
+ import {afterAll, afterEach, beforeEach, describe, expect, it, type Mock, spyOn} from "bun:test";
2
+ import * as Sentry from "@sentry/node";
3
+ import axios from "axios";
4
+
5
+ import {sendToZoom} from "./zoomNotifier";
6
+
7
+ describe("sendToZoom", () => {
8
+ let mockAxiosPost: Mock<typeof axios.post>;
9
+
10
+ const ORIGINAL_ENV = process.env;
11
+
12
+ beforeEach(() => {
13
+ mockAxiosPost = spyOn(axios, "post").mockResolvedValue({status: 200} as any);
14
+ process.env = {...ORIGINAL_ENV};
15
+ process.env.ZOOM_CHAT_WEBHOOKS = undefined;
16
+ (Sentry.captureException as Mock<typeof Sentry.captureException>).mockClear();
17
+ (Sentry.captureMessage as Mock<typeof Sentry.captureMessage>).mockClear();
18
+ });
19
+
20
+ afterEach(() => {
21
+ mockAxiosPost.mockRestore();
22
+ });
23
+
24
+ afterAll(() => {
25
+ process.env = ORIGINAL_ENV;
26
+ });
27
+
28
+ it("returns early when ZOOM_CHAT_WEBHOOKS is missing", async () => {
29
+ await sendToZoom({body: "world", header: "hello"}, {channel: "default"});
30
+ expect(mockAxiosPost.mock.calls.length).toBe(0);
31
+ });
32
+
33
+ it("posts to default webhook with rich message format and authorization header", async () => {
34
+ process.env.ZOOM_CHAT_WEBHOOKS = JSON.stringify({
35
+ default: {
36
+ channel: "https://zoom.example/webhook",
37
+ verificationToken: "test-token-123",
38
+ },
39
+ });
40
+ mockAxiosPost.mockResolvedValue({status: 200});
41
+
42
+ await sendToZoom({body: "world", header: "hello"}, {channel: "default"});
43
+ expect(mockAxiosPost.mock.calls.length).toBe(1);
44
+ const callArgs = mockAxiosPost.mock.calls[0];
45
+ expect(Array.isArray(callArgs)).toBe(true);
46
+ const [url, payload, options] = callArgs;
47
+ expect(url).toBe("https://zoom.example/webhook?format=full");
48
+ expect(payload).toEqual({
49
+ content: {
50
+ body: [{text: "world", type: "message"}],
51
+ head: {text: "hello"},
52
+ },
53
+ });
54
+ expect(options?.headers).toEqual({
55
+ Authorization: "test-token-123",
56
+ "Content-Type": "application/json",
57
+ });
58
+ });
59
+
60
+ it("posts to a specific channel when provided", async () => {
61
+ process.env.ZOOM_CHAT_WEBHOOKS = JSON.stringify({
62
+ default: {
63
+ channel: "https://zoom.example/default",
64
+ verificationToken: "default-token",
65
+ },
66
+ ops: {
67
+ channel: "https://zoom.example/ops",
68
+ verificationToken: "ops-token",
69
+ },
70
+ });
71
+ mockAxiosPost.mockResolvedValue({status: 200});
72
+
73
+ await sendToZoom({body: "ops msg", header: "ops msg"}, {channel: "ops"});
74
+ const callArgs = mockAxiosPost.mock.calls[0];
75
+ expect(Array.isArray(callArgs)).toBe(true);
76
+ const [url, payload, options] = callArgs;
77
+ expect(url).toBe("https://zoom.example/ops?format=full");
78
+ expect(payload).toEqual({
79
+ content: {
80
+ body: [{text: "ops msg", type: "message"}],
81
+ head: {text: "ops msg"},
82
+ },
83
+ });
84
+ expect(options?.headers?.Authorization).toBe("ops-token");
85
+ });
86
+
87
+ it("falls back to default when channel not found", async () => {
88
+ process.env.ZOOM_CHAT_WEBHOOKS = JSON.stringify({
89
+ default: {
90
+ channel: "https://zoom.example/default",
91
+ verificationToken: "default-token",
92
+ },
93
+ });
94
+ mockAxiosPost.mockResolvedValue({status: 200});
95
+
96
+ await sendToZoom({body: "missing channel", header: "missing channel"}, {channel: "unknown"});
97
+ const callArgs = mockAxiosPost.mock.calls[0];
98
+ expect(Array.isArray(callArgs)).toBe(true);
99
+ const [url, payload] = callArgs;
100
+ expect(url).toBe("https://zoom.example/default?format=full");
101
+ expect(payload).toEqual({
102
+ content: {
103
+ body: [{text: "missing channel", type: "message"}],
104
+ head: {text: "missing channel"},
105
+ },
106
+ });
107
+ });
108
+
109
+ it("returns early when webhook url is missing for channel", async () => {
110
+ process.env.ZOOM_CHAT_WEBHOOKS = JSON.stringify({
111
+ default: {
112
+ verificationToken: "default-token",
113
+ },
114
+ });
115
+
116
+ await sendToZoom({body: "no url", header: "no url"}, {channel: "default"});
117
+ expect(mockAxiosPost.mock.calls.length).toBe(0);
118
+ });
119
+
120
+ it("returns early when verification token is missing for channel", async () => {
121
+ process.env.ZOOM_CHAT_WEBHOOKS = JSON.stringify({
122
+ default: {
123
+ channel: "https://zoom.example/default",
124
+ },
125
+ });
126
+
127
+ await sendToZoom({body: "no token", header: "no token"}, {channel: "default"});
128
+ expect(mockAxiosPost.mock.calls.length).toBe(0);
129
+ });
130
+
131
+ it("prefixes header with [ENV] when env provided", async () => {
132
+ process.env.ZOOM_CHAT_WEBHOOKS = JSON.stringify({
133
+ default: {
134
+ channel: "https://zoom.example/default",
135
+ verificationToken: "token",
136
+ },
137
+ });
138
+ mockAxiosPost.mockResolvedValue({status: 200});
139
+
140
+ await sendToZoom({body: "status ok", header: "status ok"}, {channel: "default", env: "stg"});
141
+ const callArgs = mockAxiosPost.mock.calls[0];
142
+ expect(Array.isArray(callArgs)).toBe(true);
143
+ const [, payload] = callArgs;
144
+ expect(payload).toEqual({
145
+ content: {
146
+ body: [{text: "status ok", type: "message"}],
147
+ head: {text: "[STG] status ok"},
148
+ },
149
+ });
150
+ });
151
+
152
+ it("includes subheader when provided", async () => {
153
+ process.env.ZOOM_CHAT_WEBHOOKS = JSON.stringify({
154
+ default: {
155
+ channel: "https://zoom.example/default",
156
+ verificationToken: "token",
157
+ },
158
+ });
159
+ mockAxiosPost.mockResolvedValue({status: 200});
160
+
161
+ await sendToZoom(
162
+ {body: "Body text", header: "Main Header", subheader: "Subheader text"},
163
+ {channel: "default"}
164
+ );
165
+ const callArgs = mockAxiosPost.mock.calls[0];
166
+ expect(Array.isArray(callArgs)).toBe(true);
167
+ const [, payload] = callArgs;
168
+ expect(payload).toEqual({
169
+ content: {
170
+ body: [{text: "Body text", type: "message"}],
171
+ head: {sub_head: {text: "Subheader text"}, text: "Main Header"},
172
+ },
173
+ });
174
+ });
175
+
176
+ it("captures error and throws APIError when shouldThrow=true", async () => {
177
+ process.env.ZOOM_CHAT_WEBHOOKS = JSON.stringify({
178
+ default: {
179
+ channel: "https://zoom.example/default",
180
+ verificationToken: "token",
181
+ },
182
+ });
183
+ mockAxiosPost.mockRejectedValue(new Error("zoom down"));
184
+
185
+ try {
186
+ await sendToZoom({body: "err", header: "err"}, {channel: "default", shouldThrow: true});
187
+ throw new Error("Expected sendToZoom to throw APIError");
188
+ } catch (error) {
189
+ expect((error as any).name).toBe("APIError");
190
+ expect((error as any).title).toMatch(/Error posting to Zoom/i);
191
+ }
192
+ expect(mockAxiosPost.mock.calls.length).toBe(1);
193
+ });
194
+
195
+ it("captures error and does not throw when shouldThrow=false", async () => {
196
+ process.env.ZOOM_CHAT_WEBHOOKS = JSON.stringify({
197
+ default: {
198
+ channel: "https://zoom.example/default",
199
+ verificationToken: "token",
200
+ },
201
+ });
202
+ mockAxiosPost.mockRejectedValue(new Error("zoom intermittent"));
203
+
204
+ await sendToZoom({body: "err", header: "err"}, {channel: "default", shouldThrow: false});
205
+ expect(mockAxiosPost.mock.calls.length).toBe(1);
206
+ });
207
+ });
@@ -0,0 +1,111 @@
1
+ import * as Sentry from "@sentry/node";
2
+ import axios from "axios";
3
+
4
+ import {APIError} from "../errors";
5
+ import {logger} from "../logger";
6
+
7
+ /**
8
+ * Sends a rich formatted message to a Zoom chat channel via webhook.
9
+ *
10
+ * @param message - Message content with header, body, and optional subheader
11
+ * @param message.header - Main header text for the message
12
+ * @param message.body - Body text content
13
+ * @param message.subheader - Optional subheader text displayed below the main header
14
+ * @param options - Configuration options
15
+ * @param options.channel - The Zoom channel to post to (defaults to "default")
16
+ * @param options.shouldThrow - If true, throws an APIError on failure; otherwise logs and continues
17
+ * @param options.env - Optional environment prefix (e.g., "stg", "prod") prepended to header
18
+ *
19
+ * @remarks
20
+ * Requires ZOOM_CHAT_WEBHOOKS environment variable containing JSON with channel configurations:
21
+ * ```json
22
+ * {
23
+ * "default": {"channel": "webhook_url", "verificationToken": "token"},
24
+ * "ops": {"channel": "webhook_url", "verificationToken": "token"}
25
+ * }
26
+ * ```
27
+ *
28
+ * Falls back to "default" channel if specified channel not found.
29
+ * Logs errors to Sentry and logger when webhook is missing or request fails.
30
+ * Uses Zoom's rich message format (format=full) with structured header and body.
31
+ */
32
+ export async function sendToZoom(
33
+ {header, body, subheader}: {header: string; body: string; subheader?: string},
34
+ {channel, shouldThrow = false, env}: {channel: string; shouldThrow?: boolean; env?: string}
35
+ ) {
36
+ const zoomWebhooksString = process.env.ZOOM_CHAT_WEBHOOKS;
37
+ if (!zoomWebhooksString) {
38
+ const msg = "ZOOM_CHAT_WEBHOOKS not set. Zoom message not sent";
39
+ Sentry.captureException(new Error(msg));
40
+ logger.error(msg);
41
+ return;
42
+ }
43
+ const zoomWebhooks: Record<string, {channel: string; verificationToken: string}> = JSON.parse(
44
+ zoomWebhooksString ?? "{}"
45
+ );
46
+
47
+ const zoomChannel = channel ?? "default";
48
+ // Use format full
49
+ const zoomWebhookUrl = zoomWebhooks[zoomChannel]?.channel ?? zoomWebhooks.default?.channel;
50
+
51
+ if (!zoomWebhookUrl) {
52
+ const msg = `No webhook url set in env for ${zoomChannel}. Zoom message not sent`;
53
+ Sentry.captureException(new Error(msg));
54
+ logger.error(msg);
55
+ return;
56
+ }
57
+
58
+ const zoomToken =
59
+ zoomWebhooks[zoomChannel]?.verificationToken ?? zoomWebhooks.default?.verificationToken;
60
+ if (!zoomToken) {
61
+ const msg = `No verification token set in env for ${zoomChannel}. Zoom message not sent`;
62
+ Sentry.captureException(new Error(msg));
63
+ logger.error(msg);
64
+ return;
65
+ }
66
+
67
+ // Build the message structure
68
+ const messageBody: {
69
+ head: {text: string; sub_head?: {text: string}};
70
+ body: Array<{type: string; text: string}>;
71
+ } = {
72
+ body: [
73
+ {
74
+ text: body,
75
+ type: "message",
76
+ },
77
+ ],
78
+ head: {
79
+ text: env ? `[${env.toUpperCase()}] ${header}` : header,
80
+ },
81
+ };
82
+
83
+ // Add subheader if provided
84
+ if (subheader) {
85
+ messageBody.head.sub_head = {
86
+ text: subheader,
87
+ };
88
+ }
89
+
90
+ try {
91
+ await axios.post(
92
+ `${zoomWebhookUrl}?format=full`,
93
+ {content: messageBody},
94
+ {
95
+ headers: {
96
+ Authorization: zoomToken,
97
+ "Content-Type": "application/json",
98
+ },
99
+ }
100
+ );
101
+ } catch (error: any) {
102
+ logger.error(`Error posting to Zoom: ${error.text ?? error.message}`);
103
+ Sentry.captureException(error);
104
+ if (shouldThrow) {
105
+ throw new APIError({
106
+ status: 500,
107
+ title: `Error posting to Zoom: ${error.text ?? error.message}`,
108
+ });
109
+ }
110
+ }
111
+ }