1import * as alerting from "@distilled.cloud/cloudflare/alerting";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Predicate from "effect/Predicate";
5import * as Redacted from "effect/Redacted";
6
7import * as Schedule from "effect/Schedule";
8import * as Stream from "effect/Stream";
9
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
14import type { Providers } from "../Providers.ts";
15
16const TypeId = "Cloudflare.Alerting.Webhook" as const;
17type TypeId = typeof TypeId;
18
19/**
20 * Webhook destination endpoint type, inferred by Cloudflare from the URL.
21 */
22export type NotificationWebhookType =
23 | "datadog"
24 | "discord"
25 | "feishu"
26 | "gchat"
27 | "generic"
28 | "opsgenie"
29 | "slack"
30 | "splunk"
31 // Keep the union open so new Cloudflare destination types aren't blocked
32 // by stale types.
33 | (string & {});
34
35export interface NotificationWebhookProps {
36 /**
37 * Name of the webhook destination. Included in the request body when a
38 * notification is dispatched. If omitted, a unique name is generated.
39 * Mutable — renames are applied in place.
40 * @default ${app}-${id}-${stage}-${suffix}
41 */
42 name?: string;
43 /**
44 * The POST endpoint Cloudflare calls when dispatching a notification.
45 * Cloudflare sends a test request on create/update; the endpoint must
46 * respond with a 2xx or the operation fails with `WebhookTestFailed`.
47 * Mutable — updated in place.
48 */
49 url: string;
50 /**
51 * Optional secret sent in the `cf-webhook-auth` header on every
52 * notification dispatch (for generic webhooks). Write-only: Cloudflare
53 * never returns it, so drift cannot be observed — the secret is re-sent
54 * whenever the prop value changes.
55 */
56 secret?: Redacted.Redacted<string>;
57}
58
59export interface NotificationWebhookAttributes {
60 /** Cloudflare-assigned webhook destination UUID. */
61 webhookId: string;
62 /** Account that owns this webhook destination. */
63 accountId: string;
64 /** Name of the webhook destination. */
65 name: string;
66 /** The POST endpoint called when dispatching a notification. */
67 url: string;
68 /** Endpoint type inferred by Cloudflare from the URL (e.g. `generic`, `slack`). */
69 type: NotificationWebhookType | undefined;
70 /** ISO8601 creation timestamp. */
71 createdAt: string | undefined;
72}
73
74export type NotificationWebhook = Resource<
75 TypeId,
76 NotificationWebhookProps,
77 NotificationWebhookAttributes,
78 never,
79 Providers
80>;
81
82/**
83 * A Cloudflare Notifications webhook destination.
84 *
85 * Webhook destinations receive alert notifications dispatched by
86 * {@link NotificationPolicy | notification policies}. Cloudflare sends a
87 * test POST to the URL when the webhook is created or updated, so the
88 * endpoint must be live and respond with a 2xx.
89 * ### Creating a Webhook destination
90 * **Example:** Generic webhook with a generated name
91 * ```typescript
92 * const webhook = yield* Cloudflare.Alerting.NotificationWebhook("AlertsHook", {
93 * url: "https://alerts.example.com/cf",
94 * });
95 * ```
96 *
97 * **Example:** Webhook with an auth secret
98 * The secret is sent in the `cf-webhook-auth` header on every dispatch.
99 * ```typescript
100 * const webhook = yield* Cloudflare.Alerting.NotificationWebhook("AlertsHook", {
101 * name: "production-alerts",
102 * url: "https://alerts.example.com/cf",
103 * secret: yield* Config.Redacted("WEBHOOK_SECRET"),
104 * });
105 * ```
106 *
107 * ### Using with a Notification policy
108 * **Example:** Dispatch policy notifications to the webhook
109 * ```typescript
110 * yield* Cloudflare.Alerting.NotificationPolicy("SslAlerts", {
111 * alertType: "universal_ssl_event_type",
112 * mechanisms: { webhooks: [{ id: webhook.webhookId }] },
113 * });
114 * ```
115 *
116 * @see https://developers.cloudflare.com/notifications/get-started/configure-webhooks/
117 *
118 * @resource
119 * @product Alerting
120 * @category Observability & Analytics
121 */
122export const NotificationWebhook = Resource<NotificationWebhook>(TypeId);
123
124/**
125 * Returns true if the given value is a NotificationWebhook resource.
126 */
127export const isNotificationWebhook = (
128 value: unknown,
129): value is NotificationWebhook =>
130 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
131
132export const NotificationWebhookProvider = () =>
133 Provider.succeed(NotificationWebhook, {
134 stables: ["webhookId", "accountId"],
135
136 // Account collection (pattern b): enumerate every webhook destination in
137 // the account and hydrate each into the same Attributes shape `read`
138 // returns. The secret is write-only (never returned by Cloudflare), so it
139 // is absent from Attributes and there is nothing extra to fetch per item.
140 list: Effect.fn(function* () {
141 const { accountId } = yield* yield* CloudflareEnvironment;
142 return yield* alerting.listDestinationWebhooks.pages({ accountId }).pipe(
143 Stream.runCollect,
144 Effect.map((chunk) =>
145 Array.from(chunk).flatMap((page) =>
146 (page.result ?? [])
147 .map(narrowWebhook)
148 .filter((w): w is ObservedWebhook => w !== undefined)
149 .map((w) => toWebhookAttributes(w, accountId)),
150 ),
151 ),
152 );
153 }),
154
155 diff: Effect.fn(function* ({ output }) {
156 const { accountId } = yield* yield* CloudflareEnvironment;
157 if ((output?.accountId ?? accountId) !== accountId) {
158 return { action: "replace" } as const;
159 }
160 }),
161
162 read: Effect.fn(function* ({ id, output, olds }) {
163 const { accountId } = yield* yield* CloudflareEnvironment;
164 const acct = output?.accountId ?? accountId;
165
166 // Owned path: refresh by the persisted webhook id.
167 if (output?.webhookId) {
168 const observed = yield* observeWebhook(acct, output.webhookId);
169 if (observed) return toWebhookAttributes(observed, acct);
170 }
171
172 // Cold read: no persisted id (state-persistence failure) — find the
173 // deterministic physical name in the account's webhook list.
174 const name = yield* createWebhookName(id, olds?.name);
175 const match = yield* findWebhookByName(acct, name);
176 if (match) return toWebhookAttributes(match, acct);
177 return undefined;
178 }),
179
180 reconcile: Effect.fn(function* ({ id, news, output, olds }) {
181 const { accountId } = yield* yield* CloudflareEnvironment;
182 const name = yield* createWebhookName(id, news.name);
183 // Inputs are resolved to concrete values by the engine before
184 // reconcile runs.
185 const url = news.url as string;
186 const secret =
187 news.secret === undefined ? undefined : Redacted.value(news.secret);
188
189 // 1. Observe — by cached id first, then by deterministic name.
190 let observed: ObservedWebhook | undefined;
191 if (output?.webhookId) {
192 observed = yield* observeWebhook(accountId, output.webhookId);
193 }
194 if (!observed) {
195 observed = yield* findWebhookByName(accountId, name);
196 }
197
198 // 2. Ensure — create when missing. Cloudflare fires a test POST at
199 // the URL from an arbitrary PoP; when the destination is a
200 // just-deployed Worker that PoP may not have the fresh
201 // workers.dev subdomain yet and the test POST 404s even though
202 // the URL serves elsewhere. A bounded retry (~2 min, capped
203 // backoff) rides out edge propagation — fresh workers.dev URLs
204 // have been observed to 404 for well over a minute under heavy
205 // account-wide deploy load; a genuinely broken endpoint still
206 // fails after the budget is exhausted.
207 if (!observed) {
208 const created = yield* alerting
209 .createDestinationWebhook({
210 accountId,
211 name,
212 url,
213 secret,
214 })
215 .pipe(
216 Effect.retry({
217 while: (e) => e._tag === "WebhookTestFailed",
218 schedule: Schedule.max([
219 Schedule.min([
220 Schedule.exponential("1 second"),
221 Schedule.spaced("5 seconds"),
222 ]),
223 Schedule.recurs(24),
224 ]),
225 }),
226 );
227 if (!created.id) {
228 return yield* Effect.fail(
229 new Error(
230 "Cloudflare did not return an id for the created webhook destination",
231 ),
232 );
233 }
234 const fresh = yield* observeWebhook(accountId, created.id);
235 return toWebhookAttributes(fresh ?? { id: created.id }, accountId);
236 }
237
238 // 3. Sync — PUT the full desired body when any observable field
239 // drifts, or when the (write-only, unobservable) secret prop
240 // changed between olds and news.
241 const oldSecret =
242 olds?.secret === undefined ? undefined : Redacted.value(olds.secret);
243 const secretChanged = secret !== oldSecret;
244 if (observed.name !== name || observed.url !== url || secretChanged) {
245 // The update PUT fires the same test POST as create — ride out edge
246 // propagation of a just-deployed destination URL the same way.
247 yield* alerting
248 .updateDestinationWebhook({
249 accountId,
250 webhookId: observed.id,
251 name,
252 url,
253 secret,
254 })
255 .pipe(
256 Effect.retry({
257 while: (e) => e._tag === "WebhookTestFailed",
258 schedule: Schedule.max([
259 Schedule.exponential("1 second"),
260 Schedule.recurs(5),
261 ]),
262 }),
263 );
264 const fresh = yield* observeWebhook(accountId, observed.id);
265 return toWebhookAttributes(fresh ?? observed, accountId);
266 }
267
268 // 4. Return.
269 return toWebhookAttributes(observed, accountId);
270 }),
271
272 delete: Effect.fn(function* ({ output }) {
273 // Cloudflare answers `deleteDestinationWebhook` for a webhook that no
274 // longer exists with a generic `InternalServerError` (code 15000)
275 // rather than a not-found error. Since that envelope is
276 // indistinguishable from a genuine server fault, catch the typed tag
277 // and verify the webhook is actually gone: `WebhookNotFound` on the
278 // follow-up read confirms idempotent success; anything still present
279 // re-fails with the original error.
280 yield* alerting
281 .deleteDestinationWebhook({
282 accountId: output.accountId,
283 webhookId: output.webhookId,
284 })
285 .pipe(
286 Effect.catchTag("InternalServerError", (e) =>
287 observeWebhook(output.accountId, output.webhookId).pipe(
288 Effect.flatMap((observed) =>
289 observed === undefined ? Effect.void : Effect.fail(e),
290 ),
291 ),
292 ),
293 );
294 }),
295 });
296
297interface ObservedWebhook {
298 readonly id: string;
299 readonly name?: string;
300 readonly url?: string;
301 readonly type?: NotificationWebhookType;
302 readonly createdAt?: string;
303}
304
305const undef = <T>(v: T | null | undefined): T | undefined =>
306 v == null ? undefined : v;
307
308const narrowWebhook = (raw: {
309 id?: string | null;
310 name?: string | null;
311 url?: string | null;
312 type?: string | null;
313 createdAt?: string | null;
314}): ObservedWebhook | undefined =>
315 raw.id == null
316 ? undefined
317 : {
318 id: raw.id,
319 name: undef(raw.name),
320 url: undef(raw.url),
321 type: undef(raw.type) as NotificationWebhookType | undefined,
322 createdAt: undef(raw.createdAt),
323 };
324
325const observeWebhook = (accountId: string, webhookId: string) =>
326 alerting.getDestinationWebhook({ accountId, webhookId }).pipe(
327 Effect.map((w) => narrowWebhook({ ...w, id: w.id ?? webhookId })),
328 Effect.catchTag("WebhookNotFound", () => Effect.succeed(undefined)),
329 );
330
331const findWebhookByName = (accountId: string, name: string) =>
332 alerting.listDestinationWebhooks.items({ accountId }).pipe(
333 Stream.filter((w) => w.name === name && w.id != null),
334 Stream.runHead,
335 Effect.map(Option.getOrUndefined),
336 Effect.map((w) => (w === undefined ? undefined : narrowWebhook(w))),
337 );
338
339const toWebhookAttributes = (
340 observed: ObservedWebhook,
341 accountId: string,
342): NotificationWebhookAttributes => ({
343 webhookId: observed.id,
344 accountId,
345 name: observed.name ?? "",
346 url: observed.url ?? "",
347 type: observed.type,
348 createdAt: observed.createdAt,
349});
350
351const createWebhookName = (id: string, name: string | undefined) =>
352 Effect.gen(function* () {
353 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
354 });