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 Stream from "effect/Stream";
6
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.Alerting.NotificationPolicy" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * The event that triggers a notification dispatch. The full catalog (and
18 * which types your plan can use) is returned by the available-alerts
19 * endpoint. The union is kept open so new Cloudflare alert types aren't
20 * blocked by stale types.
21 */
22export type AlertType = alerting.CreatePolicyRequest["alertType"];
23
24/**
25 * Optional filters restricting which events trigger the policy. Which keys
26 * are valid (or required — Cloudflare returns `FiltersRequired`) depends on
27 * the alert type.
28 */
29export type NotificationPolicyFilters = alerting.CreatePolicyRequest["filters"];
30
31/**
32 * Destinations notified when the policy fires. At least one mechanism is
33 * required (Cloudflare returns `MechanismRequired` otherwise). Email ids
34 * are email addresses; webhook/pagerduty ids reference destination UUIDs.
35 */
36export interface NotificationPolicyMechanisms {
37 /** Email destinations — `id` is the recipient email address. */
38 email?: ReadonlyArray<{ id: string }>;
39 /** Webhook destinations — `id` references a {@link NotificationWebhook}. */
40 webhooks?: ReadonlyArray<{ id: string }>;
41 /** PagerDuty destinations — `id` references a connected PagerDuty service. */
42 pagerduty?: ReadonlyArray<{ id: string }>;
43}
44
45export interface NotificationPolicyProps {
46 /**
47 * Name of the policy. If omitted, a unique name is generated.
48 * Mutable — renames are applied in place.
49 * @default ${app}-${id}-${stage}-${suffix}
50 */
51 name?: string;
52 /**
53 * The event that triggers a notification dispatch (e.g.
54 * `universal_ssl_event_type`, `billing_usage_alert`). Changing the alert
55 * type triggers a replacement: the valid filter set depends on the alert
56 * type, so a policy alerting on a different event is a different policy.
57 */
58 alertType: AlertType;
59 /**
60 * Whether the policy is enabled.
61 * @default true
62 */
63 enabled?: boolean;
64 /**
65 * Optional human-readable description of the policy.
66 */
67 description?: string;
68 /**
69 * How often to re-alert from the same incident (e.g. `"30m"`). Not
70 * supported by all alert types.
71 */
72 alertInterval?: string;
73 /**
74 * Destinations notified when the policy fires. At least one mechanism
75 * is required.
76 */
77 mechanisms: NotificationPolicyMechanisms;
78 /**
79 * Optional filters restricting which events trigger the policy. Some
80 * alert types require specific filters (`FiltersRequired`).
81 */
82 filters?: NotificationPolicyFilters;
83}
84
85export interface NotificationPolicyAttributes {
86 /** Cloudflare-assigned notification policy UUID. */
87 policyId: string;
88 /** Account that owns this policy. */
89 accountId: string;
90 /** Name of the policy. */
91 name: string;
92 /** The alert type the policy fires on. */
93 alertType: AlertType;
94 /** Whether the policy is enabled. */
95 enabled: boolean;
96 /** ISO8601 creation timestamp. */
97 created: string | undefined;
98 /** ISO8601 last-modified timestamp. */
99 modified: string | undefined;
100}
101
102export type NotificationPolicy = Resource<
103 TypeId,
104 NotificationPolicyProps,
105 NotificationPolicyAttributes,
106 never,
107 Providers
108>;
109
110/**
111 * A Cloudflare Notifications policy.
112 *
113 * A notification policy connects an alert type (the event Cloudflare
114 * watches for) to one or more destinations — email addresses, webhook
115 * destinations, or PagerDuty services — optionally narrowed by filters.
116 * ### Creating a policy
117 * **Example:** Email notifications for Universal SSL events
118 * ```typescript
119 * yield* Cloudflare.Alerting.NotificationPolicy("SslAlerts", {
120 * alertType: "universal_ssl_event_type",
121 * mechanisms: { email: [{ id: "ops@example.com" }] },
122 * });
123 * ```
124 *
125 * **Example:** Disabled policy with a description
126 * ```typescript
127 * yield* Cloudflare.Alerting.NotificationPolicy("SslAlerts", {
128 * alertType: "universal_ssl_event_type",
129 * enabled: false,
130 * description: "Paused during migration",
131 * mechanisms: { email: [{ id: "ops@example.com" }] },
132 * });
133 * ```
134 *
135 * ### Webhook destinations
136 * **Example:** Dispatch to a webhook destination
137 * ```typescript
138 * const webhook = yield* Cloudflare.Alerting.NotificationWebhook("AlertsHook", {
139 * url: "https://alerts.example.com/cf",
140 * });
141 *
142 * yield* Cloudflare.Alerting.NotificationPolicy("SslAlerts", {
143 * alertType: "universal_ssl_event_type",
144 * mechanisms: { webhooks: [{ id: webhook.webhookId }] },
145 * });
146 * ```
147 *
148 * ### Filters
149 * **Example:** Health check alerts for specific zones
150 * ```typescript
151 * yield* Cloudflare.Alerting.NotificationPolicy("HealthAlerts", {
152 * alertType: "health_check_status_notification",
153 * mechanisms: { email: [{ id: "ops@example.com" }] },
154 * filters: {
155 * healthCheckId: [healthCheckId],
156 * newHealth: ["Unhealthy"],
157 * },
158 * });
159 * ```
160 *
161 * @see https://developers.cloudflare.com/notifications/
162 *
163 * @resource
164 * @product Alerting
165 * @category Observability & Analytics
166 */
167export const NotificationPolicy = Resource<NotificationPolicy>(TypeId);
168
169/**
170 * Returns true if the given value is a NotificationPolicy resource.
171 */
172export const isNotificationPolicy = (
173 value: unknown,
174): value is NotificationPolicy =>
175 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
176
177export const NotificationPolicyProvider = () =>
178 Provider.succeed(NotificationPolicy, {
179 stables: ["policyId", "accountId", "alertType"],
180
181 // Account-scoped collection: exhaustively paginate the account's
182 // notification policies and hydrate each into the `read` attribute shape.
183 list: Effect.fn(function* () {
184 const { accountId } = yield* yield* CloudflareEnvironment;
185 return yield* alerting.listPolicies.pages({ accountId }).pipe(
186 Stream.runCollect,
187 Effect.map((chunk) =>
188 Array.from(chunk).flatMap((page) =>
189 (page.result ?? [])
190 .map(narrowPolicy)
191 .filter((p): p is ObservedPolicy => p !== undefined)
192 .map((p) => toPolicyAttributes(p, accountId)),
193 ),
194 ),
195 );
196 }),
197
198 diff: Effect.fn(function* ({ olds = {}, news, output }) {
199 const { accountId } = yield* yield* CloudflareEnvironment;
200 if ((output?.accountId ?? accountId) !== accountId) {
201 return { action: "replace" } as const;
202 }
203 // The valid filter/mechanism shape depends on the alert type — a
204 // policy alerting on a different event is a different policy.
205 // `alertType` is a plain string prop, statically knowable here.
206 const o = olds as NotificationPolicyProps;
207 const n = news as NotificationPolicyProps;
208 const oldAlertType = output?.alertType ?? o.alertType;
209 if (oldAlertType !== undefined && oldAlertType !== n.alertType) {
210 return { action: "replace" } as const;
211 }
212 }),
213
214 read: Effect.fn(function* ({ id, output, olds }) {
215 const { accountId } = yield* yield* CloudflareEnvironment;
216 const acct = output?.accountId ?? accountId;
217
218 // Owned path: refresh by the persisted policy id.
219 if (output?.policyId) {
220 const observed = yield* observePolicy(acct, output.policyId);
221 if (observed) return toPolicyAttributes(observed, acct);
222 return undefined;
223 }
224
225 // Cold read: no persisted id (state-persistence failure) — find the
226 // deterministic physical name in the account's policy list.
227 const name = yield* createPolicyName(id, olds?.name);
228 const match = yield* findPolicyByName(acct, name);
229 if (match) return toPolicyAttributes(match, acct);
230 return undefined;
231 }),
232
233 reconcile: Effect.fn(function* ({ id, news, output }) {
234 const { accountId } = yield* yield* CloudflareEnvironment;
235 const name = yield* createPolicyName(id, news.name);
236 const desired = buildPolicyBody(name, news);
237
238 // 1. Observe — by cached id first, then by deterministic name.
239 let observed: ObservedPolicy | undefined;
240 if (output?.policyId) {
241 observed = yield* observePolicy(accountId, output.policyId);
242 }
243 if (!observed) {
244 observed = yield* findPolicyByName(accountId, name);
245 }
246
247 // 2. Ensure — create when missing. `createPolicy` only returns the
248 // new id; re-read for the full attribute set.
249 if (!observed) {
250 const created = yield* alerting.createPolicy({
251 accountId,
252 ...desired,
253 });
254 if (!created.id) {
255 return yield* Effect.fail(
256 new Error(
257 "Cloudflare did not return an id for the created notification policy",
258 ),
259 );
260 }
261 const fresh = yield* observePolicy(accountId, created.id);
262 return toPolicyAttributes(fresh ?? { id: created.id }, accountId);
263 }
264
265 // 3. Sync — PUT the full desired body when any mutable aspect
266 // drifts from the observed cloud state.
267 if (!policyEqualsObserved(desired, observed)) {
268 yield* alerting.updatePolicy({
269 accountId,
270 policyId: observed.id,
271 ...desired,
272 });
273 const fresh = yield* observePolicy(accountId, observed.id);
274 return toPolicyAttributes(fresh ?? observed, accountId);
275 }
276
277 // 4. Return.
278 return toPolicyAttributes(observed, accountId);
279 }),
280
281 delete: Effect.fn(function* ({ output }) {
282 yield* alerting
283 .deletePolicy({
284 accountId: output.accountId,
285 policyId: output.policyId,
286 })
287 .pipe(Effect.catchTag("PolicyNotFound", () => Effect.void));
288 }),
289 });
290
291interface ObservedPolicy {
292 readonly id: string;
293 readonly name?: string;
294 readonly alertType?: AlertType;
295 readonly enabled?: boolean;
296 readonly description?: string;
297 readonly alertInterval?: string;
298 readonly mechanisms?: unknown;
299 readonly filters?: unknown;
300 readonly created?: string;
301 readonly modified?: string;
302}
303
304const undef = <T>(v: T | null | undefined): T | undefined =>
305 v == null ? undefined : v;
306
307const narrowPolicy = (raw: {
308 id?: string | null;
309 name?: string | null;
310 alertType?: string | null;
311 enabled?: boolean | null;
312 description?: string | null;
313 alertInterval?: string | null;
314 mechanisms?: unknown;
315 filters?: unknown;
316 created?: string | null;
317 modified?: string | null;
318}): ObservedPolicy | undefined =>
319 raw.id == null
320 ? undefined
321 : {
322 id: raw.id,
323 name: undef(raw.name),
324 alertType: undef(raw.alertType) as AlertType | undefined,
325 enabled: undef(raw.enabled),
326 description: undef(raw.description),
327 alertInterval: undef(raw.alertInterval),
328 mechanisms: raw.mechanisms ?? undefined,
329 filters: raw.filters ?? undefined,
330 created: undef(raw.created),
331 modified: undef(raw.modified),
332 };
333
334const observePolicy = (accountId: string, policyId: string) =>
335 alerting.getPolicy({ accountId, policyId }).pipe(
336 Effect.map((p) => narrowPolicy({ ...p, id: p.id ?? policyId })),
337 Effect.catchTag("PolicyNotFound", () => Effect.succeed(undefined)),
338 );
339
340const findPolicyByName = (accountId: string, name: string) =>
341 alerting.listPolicies.items({ accountId }).pipe(
342 Stream.filter((p) => p.name === name && p.id != null),
343 Stream.runHead,
344 Effect.map(Option.getOrUndefined),
345 Effect.map((p) => (p === undefined ? undefined : narrowPolicy(p))),
346 );
347
348const toPolicyAttributes = (
349 observed: ObservedPolicy,
350 accountId: string,
351): NotificationPolicyAttributes => ({
352 policyId: observed.id,
353 accountId,
354 name: observed.name ?? "",
355 alertType: (observed.alertType ?? "universal_ssl_event_type") as AlertType,
356 enabled: observed.enabled ?? true,
357 created: observed.created,
358 modified: observed.modified,
359});
360
361const createPolicyName = (id: string, name: string | undefined) =>
362 Effect.gen(function* () {
363 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
364 });
365
366type PolicyBody = Omit<alerting.CreatePolicyRequest, "accountId">;
367
368const buildPolicyBody = (
369 name: string,
370 news: NotificationPolicyProps,
371): PolicyBody => ({
372 name,
373 alertType: news.alertType,
374 enabled: news.enabled ?? true,
375 description: news.description,
376 alertInterval: news.alertInterval,
377 // `Input<string>` ids are resolved to concrete strings by the engine
378 // before reconcile runs.
379 mechanisms: news.mechanisms as PolicyBody["mechanisms"],
380 filters: news.filters,
381});
382
383/**
384 * Compare the desired policy body against observed cloud state.
385 * `mechanisms`/`filters` are compared structurally with `null`/`undefined`
386 * dropped and object keys sorted, since Cloudflare echoes optional fields
387 * as `null`.
388 */
389const policyEqualsObserved = (
390 desired: PolicyBody,
391 observed: ObservedPolicy,
392): boolean =>
393 desired.name === (observed.name ?? "") &&
394 desired.enabled === (observed.enabled ?? true) &&
395 (desired.description ?? "") === (observed.description ?? "") &&
396 (desired.alertInterval ?? undefined) === observed.alertInterval &&
397 normalizedEquals(desired.mechanisms, observed.mechanisms) &&
398 normalizedEquals(desired.filters, observed.filters);
399
400const normalizedEquals = (a: unknown, b: unknown): boolean =>
401 JSON.stringify(normalize(a)) === JSON.stringify(normalize(b));
402
403/** Drop null/undefined members and sort object keys for stable comparison. */
404const normalize = (value: unknown): unknown => {
405 if (value === null || value === undefined) return undefined;
406 if (Array.isArray(value)) return value.map(normalize);
407 if (typeof value === "object") {
408 const out: Record<string, unknown> = {};
409 for (const key of Object.keys(value as Record<string, unknown>).sort()) {
410 const v = normalize((value as Record<string, unknown>)[key]);
411 if (v !== undefined) out[key] = v;
412 }
413 return Object.keys(out).length === 0 ? undefined : out;
414 }
415 return value;
416};