@checkstack/integration-webhook-backend 0.0.35 → 0.1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,157 @@
1
1
  # @checkstack/integration-webhook-backend
2
2
 
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [270ef29]
8
+ - Updated dependencies [b995afb]
9
+ - Updated dependencies [b995afb]
10
+ - Updated dependencies [b995afb]
11
+ - Updated dependencies [270ef29]
12
+ - Updated dependencies [270ef29]
13
+ - Updated dependencies [270ef29]
14
+ - Updated dependencies [270ef29]
15
+ - Updated dependencies [270ef29]
16
+ - Updated dependencies [270ef29]
17
+ - Updated dependencies [270ef29]
18
+ - Updated dependencies [270ef29]
19
+ - Updated dependencies [b995afb]
20
+ - Updated dependencies [b995afb]
21
+ - Updated dependencies [270ef29]
22
+ - Updated dependencies [b995afb]
23
+ - Updated dependencies [270ef29]
24
+ - Updated dependencies [b995afb]
25
+ - Updated dependencies [b995afb]
26
+ - Updated dependencies [270ef29]
27
+ - Updated dependencies [b995afb]
28
+ - Updated dependencies [b995afb]
29
+ - Updated dependencies [b995afb]
30
+ - Updated dependencies [b995afb]
31
+ - Updated dependencies [b995afb]
32
+ - Updated dependencies [b995afb]
33
+ - Updated dependencies [270ef29]
34
+ - Updated dependencies [270ef29]
35
+ - Updated dependencies [270ef29]
36
+ - Updated dependencies [270ef29]
37
+ - Updated dependencies [270ef29]
38
+ - Updated dependencies [270ef29]
39
+ - Updated dependencies [270ef29]
40
+ - Updated dependencies [270ef29]
41
+ - Updated dependencies [b995afb]
42
+ - Updated dependencies [b995afb]
43
+ - @checkstack/backend-api@0.19.0
44
+ - @checkstack/automation-backend@0.3.0
45
+
46
+ ## 0.1.0
47
+
48
+ ### Minor Changes
49
+
50
+ - 41c77f4: feat(automation): one-time migration of webhook subscriptions + remove legacy integration backend
51
+
52
+ **BREAKING CHANGES** (platform is in BETA — no major bump):
53
+
54
+ - `IntegrationProvider` no longer carries `config` (subscription
55
+ config) or `deliver`. The interface now models a connection provider
56
+ only: connection schema + `getConnectionOptions` + `testConnection`.
57
+ - The legacy subscription / delivery-log / event endpoints
58
+ (`listSubscriptions`, `createSubscription`, `getDeliveryLogs`,
59
+ `listEventTypes`, …) are removed from `integrationContract`.
60
+ - `delivery-coordinator`, `hook-subscriber`, `event-registry`, and the
61
+ `integrationEventExtensionPoint` are deleted. Plugins that
62
+ previously called `integrationEvents.registerEvent(...)` now
63
+ register their hooks as automation triggers via
64
+ `automationTriggerExtensionPoint.registerTrigger(...)`.
65
+ - Frontend pages `IntegrationsPage` and `DeliveryLogsPage` are gone;
66
+ the integration plugin's only remaining UI is connection
67
+ management. Subscription management lives under `/automation/...`.
68
+ - `webhook_subscriptions` and `delivery_logs` tables stay in the
69
+ database for one release as a safety net (no code reads or writes
70
+ them), and will be dropped in a follow-up migration.
71
+
72
+ **New**:
73
+
74
+ - `jira.create_issue`, `teams.post_message`, `webex.post_message`,
75
+ `webhook.send`, `integration-script.run_shell`, and
76
+ `integration-script.run_script` actions registered against the
77
+ Automation Platform with matching `*.message`, `*.delivery`,
78
+ `shell.result`, and `script.result` artifact types. The script
79
+ plugin exposes **two** actions — `run_shell` runs bash via the
80
+ shared `ShellScriptRunner` (Monaco `shell` editor), `run_script`
81
+ runs an ESM module in a Bun subprocess via `EsmScriptRunner`
82
+ (Monaco `typescript` editor + `defineIntegration` helper) — to
83
+ preserve the legacy provider split. `jira.create_issue` keeps the
84
+ dynamic field-mapping dropdown (driven by
85
+ `JIRA_RESOLVERS.FIELD_OPTIONS`).
86
+ - One-time data migration runs on boot in
87
+ `automation-backend.afterPluginsReady`. It reads
88
+ `webhook_subscriptions` via a new service RPC
89
+ `IntegrationApi.listLegacySubscriptions`, translates each row into
90
+ a single-trigger / single-action automation (marked with
91
+ `managed_by = "migrated-subscription:<id>"`), and is idempotent
92
+ across restarts.
93
+ - Failed translations are recorded in a new
94
+ `automation_migration_failures` table and surfaced via
95
+ `AutomationApi.listMigrationFailures` /
96
+ `acknowledgeMigrationFailure` so admins can review and re-create
97
+ failed entries by hand.
98
+
99
+ - 41c77f4: fix(automation): qualify action `produces` / `consumes` with the owning plugin id
100
+
101
+ `context.artifacts` showed up untyped (no fields) in the script editor
102
+ because action `produces` / `consumes` were hand-written full strings
103
+ (`"jira.issue"`) that did not match the artifact-type registry's
104
+ qualified id. The registry derives `${pluginId}.${id}`, and the plugin's
105
+ id is the package name `integration-jira`, so the artifact type actually
106
+ registers as `integration-jira.issue` — the editor's schema lookup
107
+ (`produces` vs registered `qualifiedId`) missed, leaving the artifact's
108
+ fields unknown. (Runtime store/consume happened to agree with each other
109
+ on the short string, so it "worked" but typed nothing.)
110
+
111
+ The action registry now qualifies `produces` with the owning plugin id,
112
+ exactly as it already qualifies the action's own `id` and as the
113
+ artifact-type registry qualifies the artifact type id — so the three can
114
+ never drift. Actions declare the **local** artifact id:
115
+
116
+ - `produces: "issue"` → registered as `integration-jira.issue`,
117
+ - `consumes: ["issue"]` → resolved against the owning plugin's namespace
118
+ at run time; `consumedArtifacts` is keyed by the local id, so an
119
+ action's `execute` reads `consumedArtifacts["issue"]`.
120
+
121
+ All five artifact-producing integration plugins (jira / teams / webex /
122
+ webhook / script) now declare local ids. With `produces` matching the
123
+ registered artifact type, the editor types `context.artifacts[...]` with
124
+ the real schema (e.g. `issueKey`, `projectKey`, `issueUrl`).
125
+
126
+ **BREAKING (beta):** the fully-qualified artifact type ids change from
127
+ the short form to the plugin-prefixed form, e.g. `jira.issue` →
128
+ `integration-jira.issue`. This affects how artifacts are referenced in
129
+ templates (`{{ artifact.integration-jira.issue.issueKey }}`), the TS
130
+ script `context.artifacts["integration-jira.issue"]`, and shell env names
131
+ (`$CHECKSTACK_ARTIFACT_INTEGRATION_JIRA_ISSUE_ISSUEKEY`). Artifacts are
132
+ per-run and ephemeral, so no stored-data migration is needed.
133
+
134
+ Note: this keeps the same-plugin produce→consume handoff (the current
135
+ pattern). Cross-plugin artifact consumption would need a follow-up to
136
+ allow a fully-qualified `consumes` ref.
137
+
138
+ ### Patch Changes
139
+
140
+ - Updated dependencies [e2d6f25]
141
+ - Updated dependencies [41c77f4]
142
+ - Updated dependencies [e1a2077]
143
+ - Updated dependencies [41c77f4]
144
+ - Updated dependencies [41c77f4]
145
+ - Updated dependencies [41c77f4]
146
+ - Updated dependencies [41c77f4]
147
+ - Updated dependencies [41c77f4]
148
+ - Updated dependencies [6d52276]
149
+ - Updated dependencies [6d52276]
150
+ - Updated dependencies [35bc682]
151
+ - @checkstack/automation-backend@0.2.0
152
+ - @checkstack/common@0.12.0
153
+ - @checkstack/backend-api@0.18.0
154
+
3
155
  ## 0.0.35
4
156
 
5
157
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/integration-webhook-backend",
3
- "version": "0.0.35",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -11,20 +11,21 @@
11
11
  "typecheck": "tsgo -b",
12
12
  "lint": "bun run lint:code",
13
13
  "lint:code": "eslint . --max-warnings 0",
14
+ "test": "bun test",
14
15
  "pack": "bunx @checkstack/scripts plugin-pack"
15
16
  },
16
17
  "dependencies": {
17
- "@checkstack/backend-api": "0.17.0",
18
- "@checkstack/integration-backend": "0.1.29",
19
- "@checkstack/integration-common": "0.5.0",
20
- "@checkstack/common": "0.11.0",
18
+ "@checkstack/backend-api": "0.18.0",
19
+ "@checkstack/automation-backend": "0.2.0",
20
+ "@checkstack/common": "0.12.0",
21
21
  "zod": "^4.2.1"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/bun": "^1.0.0",
25
25
  "typescript": "^5.0.0",
26
26
  "@checkstack/tsconfig": "0.0.7",
27
- "@checkstack/scripts": "0.3.3"
27
+ "@checkstack/scripts": "0.3.4",
28
+ "@checkstack/test-utils-backend": "0.1.31"
28
29
  },
29
30
  "description": "Checkstack integration-webhook-backend plugin",
30
31
  "author": {
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Behaviour tests for the webhook automation action.
3
+ *
4
+ * fetch is stubbed in-process so we can exercise the full
5
+ * `webhookSendAction.execute` flow including the retry/error mapping.
6
+ * Template expansion happens at the dispatch-engine level — by the
7
+ * time this action runs, `config.body` is already a rendered string,
8
+ * so we don't need to set up a template runtime here.
9
+ */
10
+ import { describe, it, expect, beforeEach } from "bun:test";
11
+ import type { Logger } from "@checkstack/backend-api";
12
+ import { createMockLogger } from "@checkstack/test-utils-backend";
13
+
14
+ import { webhookSendAction, webhookDeliveryArtifactType } from "./automations";
15
+
16
+ const logger = createMockLogger() as Logger;
17
+
18
+ interface CapturedFetchCall {
19
+ url: string;
20
+ init: RequestInit;
21
+ }
22
+
23
+ interface StagedResponse {
24
+ body?: unknown;
25
+ status?: number;
26
+ headers?: Record<string, string>;
27
+ raw?: string;
28
+ /** Throw this error instead of returning a response. */
29
+ throws?: Error;
30
+ }
31
+
32
+ function setupFetchSequence(responses: StagedResponse[]) {
33
+ const calls: CapturedFetchCall[] = [];
34
+ const originalFetch = globalThis.fetch;
35
+ let i = 0;
36
+ globalThis.fetch = (async (
37
+ input: string | URL | Request,
38
+ init?: RequestInit,
39
+ ) => {
40
+ calls.push({ url: String(input), init: init ?? {} });
41
+ const next = responses[i++] ?? { body: {}, status: 200 };
42
+ if (next.throws) throw next.throws;
43
+ const headers = new Headers({ "Content-Type": "application/json", ...next.headers });
44
+ const payload =
45
+ next.raw !== undefined ? next.raw : JSON.stringify(next.body ?? {});
46
+ return new Response(payload, {
47
+ status: next.status ?? 200,
48
+ headers,
49
+ });
50
+ }) as typeof fetch;
51
+ return {
52
+ calls,
53
+ restore: () => {
54
+ globalThis.fetch = originalFetch;
55
+ },
56
+ };
57
+ }
58
+
59
+ let fetchFixture: ReturnType<typeof setupFetchSequence> | undefined;
60
+
61
+ beforeEach(() => {
62
+ fetchFixture?.restore();
63
+ fetchFixture = undefined;
64
+ });
65
+
66
+ const ctxBase = {
67
+ runId: "run-1",
68
+ automationId: "auto-1",
69
+ contextKey: null,
70
+ logger,
71
+ getService: async <T,>(): Promise<T> => {
72
+ throw new Error("not used");
73
+ },
74
+ };
75
+
76
+ const baseConfig = {
77
+ url: "https://example.com/hook",
78
+ method: "POST" as const,
79
+ contentType: "application/json" as const,
80
+ authType: "none" as const,
81
+ timeout: 10_000,
82
+ };
83
+
84
+ describe("webhookDeliveryArtifactType", () => {
85
+ it("validates the canonical artifact shape", () => {
86
+ const ok = webhookDeliveryArtifactType.schema.safeParse({
87
+ url: "https://example.com/hook",
88
+ status: 200,
89
+ responseBody: "{}",
90
+ });
91
+ expect(ok.success).toBe(true);
92
+ });
93
+
94
+ it("rejects when status is missing", () => {
95
+ const bad = webhookDeliveryArtifactType.schema.safeParse({
96
+ url: "https://example.com/hook",
97
+ responseBody: "{}",
98
+ });
99
+ expect(bad.success).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe("webhook.send", () => {
104
+ it("delivers a payload and emits a webhook.delivery artifact", async () => {
105
+ fetchFixture = setupFetchSequence([
106
+ { body: { id: "msg-42" }, status: 201 },
107
+ ]);
108
+ const result = await webhookSendAction.execute({
109
+ ...ctxBase,
110
+ consumedArtifacts: {},
111
+ config: { ...baseConfig, body: '{"x":1}' },
112
+ });
113
+ expect(result.success).toBe(true);
114
+ if (!result.success) return;
115
+ const artifact = result.artifact as {
116
+ url: string;
117
+ status: number;
118
+ externalId?: string;
119
+ responseBody: string;
120
+ };
121
+ expect(artifact.url).toBe("https://example.com/hook");
122
+ expect(artifact.status).toBe(201);
123
+ expect(artifact.externalId).toBe("msg-42");
124
+ expect(result.externalId).toBe("msg-42");
125
+ const call = fetchFixture.calls[0]!;
126
+ expect(call.init.method).toBe("POST");
127
+ expect(call.init.body).toBe('{"x":1}');
128
+ const headers = call.init.headers as Record<string, string>;
129
+ expect(headers["Content-Type"]).toBe("application/json");
130
+ });
131
+
132
+ it("attaches a bearer token to the Authorization header", async () => {
133
+ fetchFixture = setupFetchSequence([{ body: {} }]);
134
+ await webhookSendAction.execute({
135
+ ...ctxBase,
136
+ consumedArtifacts: {},
137
+ config: {
138
+ ...baseConfig,
139
+ authType: "bearer",
140
+ bearerToken: "secret-token",
141
+ },
142
+ });
143
+ const headers = fetchFixture.calls[0]!.init.headers as Record<string, string>;
144
+ expect(headers.Authorization).toBe("Bearer secret-token");
145
+ });
146
+
147
+ it("attaches Basic auth when username and password are set", async () => {
148
+ fetchFixture = setupFetchSequence([{ body: {} }]);
149
+ await webhookSendAction.execute({
150
+ ...ctxBase,
151
+ consumedArtifacts: {},
152
+ config: {
153
+ ...baseConfig,
154
+ authType: "basic",
155
+ basicUsername: "alice",
156
+ basicPassword: "wonderland",
157
+ },
158
+ });
159
+ const headers = fetchFixture.calls[0]!.init.headers as Record<string, string>;
160
+ const expected = `Basic ${Buffer.from("alice:wonderland").toString("base64")}`;
161
+ expect(headers.Authorization).toBe(expected);
162
+ });
163
+
164
+ it("attaches a custom header when authType is header", async () => {
165
+ fetchFixture = setupFetchSequence([{ body: {} }]);
166
+ await webhookSendAction.execute({
167
+ ...ctxBase,
168
+ consumedArtifacts: {},
169
+ config: {
170
+ ...baseConfig,
171
+ authType: "header",
172
+ authHeaderName: "X-API-Key",
173
+ authHeaderValue: "k123",
174
+ },
175
+ });
176
+ const headers = fetchFixture.calls[0]!.init.headers as Record<string, string>;
177
+ expect(headers["X-API-Key"]).toBe("k123");
178
+ });
179
+
180
+ it("appends extra custom headers", async () => {
181
+ fetchFixture = setupFetchSequence([{ body: {} }]);
182
+ await webhookSendAction.execute({
183
+ ...ctxBase,
184
+ consumedArtifacts: {},
185
+ config: {
186
+ ...baseConfig,
187
+ customHeaders: [
188
+ { name: "X-Source", value: "checkstack" },
189
+ { name: "X-Trace", value: "abc" },
190
+ ],
191
+ },
192
+ });
193
+ const headers = fetchFixture.calls[0]!.init.headers as Record<string, string>;
194
+ expect(headers["X-Source"]).toBe("checkstack");
195
+ expect(headers["X-Trace"]).toBe("abc");
196
+ });
197
+
198
+ it("returns a retryable failure when the status matches retryOnStatus", async () => {
199
+ fetchFixture = setupFetchSequence([
200
+ { status: 429, raw: "rate limited", headers: { "Retry-After": "5" } },
201
+ ]);
202
+ const result = await webhookSendAction.execute({
203
+ ...ctxBase,
204
+ consumedArtifacts: {},
205
+ config: { ...baseConfig, retryOnStatus: [429, 503] },
206
+ });
207
+ expect(result.success).toBe(false);
208
+ if (result.success) return;
209
+ expect(result.retryAfterMs).toBe(5_000);
210
+ expect(result.error).toMatch(/429/);
211
+ });
212
+
213
+ it("defaults retryAfterMs to 30s when Retry-After is missing", async () => {
214
+ fetchFixture = setupFetchSequence([
215
+ { status: 503, raw: "unavailable" },
216
+ ]);
217
+ const result = await webhookSendAction.execute({
218
+ ...ctxBase,
219
+ consumedArtifacts: {},
220
+ config: { ...baseConfig, retryOnStatus: [503] },
221
+ });
222
+ expect(result.success).toBe(false);
223
+ if (result.success) return;
224
+ expect(result.retryAfterMs).toBe(30_000);
225
+ });
226
+
227
+ it("returns a non-retryable failure for an unmatched 4xx/5xx", async () => {
228
+ fetchFixture = setupFetchSequence([
229
+ { status: 500, raw: "boom" },
230
+ ]);
231
+ const result = await webhookSendAction.execute({
232
+ ...ctxBase,
233
+ consumedArtifacts: {},
234
+ config: { ...baseConfig },
235
+ });
236
+ expect(result.success).toBe(false);
237
+ if (result.success) return;
238
+ expect(result.error).toMatch(/HTTP 500/);
239
+ expect(result.retryAfterMs).toBeUndefined();
240
+ });
241
+
242
+ it("classifies network errors as retryable", async () => {
243
+ fetchFixture = setupFetchSequence([
244
+ { throws: new Error("ECONNREFUSED 127.0.0.1") },
245
+ ]);
246
+ const result = await webhookSendAction.execute({
247
+ ...ctxBase,
248
+ consumedArtifacts: {},
249
+ config: { ...baseConfig },
250
+ });
251
+ expect(result.success).toBe(false);
252
+ if (result.success) return;
253
+ expect(result.retryAfterMs).toBe(30_000);
254
+ });
255
+
256
+ it("does not retry generic non-network errors", async () => {
257
+ fetchFixture = setupFetchSequence([
258
+ { throws: new Error("something else broke") },
259
+ ]);
260
+ const result = await webhookSendAction.execute({
261
+ ...ctxBase,
262
+ consumedArtifacts: {},
263
+ config: { ...baseConfig },
264
+ });
265
+ expect(result.success).toBe(false);
266
+ if (result.success) return;
267
+ expect(result.retryAfterMs).toBeUndefined();
268
+ });
269
+
270
+ it("returns success without externalId when the response body is not JSON", async () => {
271
+ fetchFixture = setupFetchSequence([
272
+ { status: 200, raw: "ok" },
273
+ ]);
274
+ const result = await webhookSendAction.execute({
275
+ ...ctxBase,
276
+ consumedArtifacts: {},
277
+ config: { ...baseConfig },
278
+ });
279
+ expect(result.success).toBe(true);
280
+ if (!result.success) return;
281
+ expect(result.externalId).toBeUndefined();
282
+ const artifact = result.artifact as { responseBody: string };
283
+ expect(artifact.responseBody).toBe("ok");
284
+ });
285
+
286
+ it("truncates the captured response body to 1000 chars", async () => {
287
+ const big = "x".repeat(5_000);
288
+ fetchFixture = setupFetchSequence([{ status: 200, raw: big }]);
289
+ const result = await webhookSendAction.execute({
290
+ ...ctxBase,
291
+ consumedArtifacts: {},
292
+ config: { ...baseConfig },
293
+ });
294
+ expect(result.success).toBe(true);
295
+ if (!result.success) return;
296
+ const artifact = result.artifact as { responseBody: string };
297
+ expect(artifact.responseBody.length).toBe(1_000);
298
+ });
299
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Webhook action — generic HTTP POST/PUT/PATCH to an external endpoint.
3
+ *
4
+ * Replaces the legacy `webhookProvider.deliver` path. All template
5
+ * expansion now happens in the dispatch engine before `execute` is
6
+ * called, so this action just builds the request and fires it.
7
+ */
8
+ import { z } from "zod";
9
+ import {
10
+ configNumber,
11
+ configString,
12
+ requestTimeoutMs,
13
+ Versioned,
14
+ } from "@checkstack/backend-api";
15
+ import type { ActionDefinition } from "@checkstack/automation-backend";
16
+ import { extractErrorMessage } from "@checkstack/common";
17
+
18
+ const webhookHeaderSchema = z.object({
19
+ name: z.string().min(1).describe("Header name"),
20
+ value: z.string().describe("Header value"),
21
+ });
22
+
23
+ export const webhookSendConfigSchema = z.object({
24
+ url: configString({}).url().describe("Endpoint URL"),
25
+ method: z
26
+ .enum(["POST", "PUT", "PATCH"])
27
+ .default("POST")
28
+ .describe("HTTP method"),
29
+ contentType: z
30
+ .enum(["application/json", "application/x-www-form-urlencoded"])
31
+ .default("application/json"),
32
+ authType: z
33
+ .enum(["none", "bearer", "basic", "header"])
34
+ .default("none")
35
+ .describe("Authentication method"),
36
+ bearerToken: configString({ "x-secret": true })
37
+ .optional()
38
+ .describe("Bearer token"),
39
+ basicUsername: configString({}).optional(),
40
+ basicPassword: configString({ "x-secret": true }).optional(),
41
+ authHeaderName: configString({}).optional(),
42
+ authHeaderValue: configString({ "x-secret": true }).optional(),
43
+ customHeaders: z.array(webhookHeaderSchema).optional(),
44
+ body: configString({
45
+ "x-editor-types": ["raw", "json", "yaml", "xml", "formdata"],
46
+ })
47
+ .optional()
48
+ .describe("Request body — already rendered when this action runs"),
49
+ timeout: requestTimeoutMs().describe("Request timeout in milliseconds"),
50
+ retryOnStatus: z
51
+ .array(configNumber({}))
52
+ .optional()
53
+ .describe("Retryable HTTP status codes (e.g., 429, 503)"),
54
+ });
55
+
56
+ export type WebhookSendConfig = z.infer<typeof webhookSendConfigSchema>;
57
+
58
+ interface WebhookDeliveryArtifact {
59
+ url: string;
60
+ status: number;
61
+ externalId?: string;
62
+ responseBody: string;
63
+ }
64
+
65
+ export const webhookSendAction: ActionDefinition<
66
+ WebhookSendConfig,
67
+ WebhookDeliveryArtifact
68
+ > = {
69
+ id: "send",
70
+ displayName: "Send Webhook",
71
+ description: "Deliver a payload to an HTTP endpoint",
72
+ category: "Webhook",
73
+ icon: "Webhook",
74
+ config: new Versioned({
75
+ version: 1,
76
+ schema: webhookSendConfigSchema,
77
+ }),
78
+ produces: "delivery",
79
+ execute: async ({ config, logger }) => {
80
+ const headers: Record<string, string> = {
81
+ "Content-Type": config.contentType,
82
+ "User-Agent": "Checkstack-Automation/1.0",
83
+ };
84
+
85
+ switch (config.authType) {
86
+ case "bearer": {
87
+ if (config.bearerToken) {
88
+ headers.Authorization = `Bearer ${config.bearerToken}`;
89
+ }
90
+ break;
91
+ }
92
+ case "basic": {
93
+ if (config.basicUsername && config.basicPassword) {
94
+ const credentials = Buffer.from(
95
+ `${config.basicUsername}:${config.basicPassword}`,
96
+ ).toString("base64");
97
+ headers.Authorization = `Basic ${credentials}`;
98
+ }
99
+ break;
100
+ }
101
+ case "header": {
102
+ if (config.authHeaderName && config.authHeaderValue) {
103
+ headers[config.authHeaderName] = config.authHeaderValue;
104
+ }
105
+ break;
106
+ }
107
+ }
108
+
109
+ if (config.customHeaders) {
110
+ for (const header of config.customHeaders) {
111
+ headers[header.name] = header.value;
112
+ }
113
+ }
114
+
115
+ const body = config.body ?? "";
116
+
117
+ try {
118
+ const response = await fetch(config.url, {
119
+ method: config.method,
120
+ headers,
121
+ body,
122
+ signal: AbortSignal.timeout(config.timeout),
123
+ });
124
+
125
+ const responseText = await response.text();
126
+
127
+ if (config.retryOnStatus?.includes(response.status)) {
128
+ const retryAfter = response.headers.get("Retry-After");
129
+ const retryAfterMs = retryAfter
130
+ ? Number.parseInt(retryAfter, 10) * 1000
131
+ : 30_000;
132
+ return {
133
+ success: false,
134
+ error: `Received status ${response.status} (retryable)`,
135
+ retryAfterMs,
136
+ };
137
+ }
138
+
139
+ if (!response.ok) {
140
+ return {
141
+ success: false,
142
+ error: `HTTP ${response.status}: ${responseText.slice(0, 200)}`,
143
+ };
144
+ }
145
+
146
+ let externalId: string | undefined;
147
+ try {
148
+ const json = JSON.parse(responseText) as Record<string, unknown>;
149
+ const candidate = json.id ?? json.externalId ?? json.messageId;
150
+ if (typeof candidate === "string") externalId = candidate;
151
+ } catch {
152
+ // Not JSON — that's fine, no externalId.
153
+ }
154
+
155
+ logger.debug(
156
+ `Webhook delivered to ${config.url}: ${response.status}`,
157
+ );
158
+ return {
159
+ success: true,
160
+ externalId,
161
+ artifact: {
162
+ url: config.url,
163
+ status: response.status,
164
+ externalId,
165
+ responseBody: responseText.slice(0, 1000),
166
+ },
167
+ };
168
+ } catch (error) {
169
+ const message = extractErrorMessage(error);
170
+ logger.error(`Webhook delivery failed: ${message}`);
171
+ if (
172
+ message.includes("timeout") ||
173
+ message.includes("ECONNREFUSED") ||
174
+ message.includes("ENOTFOUND")
175
+ ) {
176
+ return { success: false, error: message, retryAfterMs: 30_000 };
177
+ }
178
+ return { success: false, error: message };
179
+ }
180
+ },
181
+ };
182
+
183
+ export const webhookDeliveryArtifactType = {
184
+ id: "delivery",
185
+ displayName: "Webhook Delivery",
186
+ description: "Result of an HTTP delivery attempt",
187
+ schema: z.object({
188
+ url: z.string(),
189
+ status: z.number(),
190
+ externalId: z.string().optional(),
191
+ responseBody: z.string(),
192
+ }),
193
+ } as const;