1import * as firewall from "@distilled.cloud/cloudflare/firewall";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11import { listAllZones } from "../Zone/lookup.ts";
12
13const UaRuleTypeId = "Cloudflare.Firewall.UaRule" as const;
14type UaRuleTypeId = typeof UaRuleTypeId;
15
16/**
17 * The action a User Agent Blocking rule applies to a matched request.
18 */
19export type UaRuleMode =
20 | "block"
21 | "challenge"
22 | "js_challenge"
23 | "managed_challenge";
24
25export interface UaRuleProps {
26 /**
27 * Zone the rule applies to.
28 *
29 * Stable — moving a rule between zones triggers a replacement.
30 */
31 zoneId: string;
32 /**
33 * The exact User-Agent string to match. The whole UA header must equal
34 * this value — no wildcards or substring matching.
35 *
36 * Mutable — updated in place via PUT. Note that Cloudflare rejects a
37 * second rule for the same User-Agent string in a zone, so the value
38 * acts as the rule's identity for adoption.
39 */
40 userAgent: string;
41 /**
42 * The action to apply to a matched request. Mutable — updated in place.
43 */
44 mode: UaRuleMode;
45 /**
46 * An informative summary of the rule. Sanitized server-side (HTML tags
47 * are removed). Mutable — updated in place.
48 */
49 description?: string;
50 /**
51 * When true, the rule is disabled without being deleted.
52 * Mutable — updated in place.
53 *
54 * @default false
55 */
56 paused?: boolean;
57}
58
59export interface UaRuleAttributes {
60 /** Cloudflare-assigned identifier of the User Agent Blocking rule. */
61 uaRuleId: string;
62 /** Zone the rule belongs to. */
63 zoneId: string;
64 /** The exact User-Agent string the rule matches. */
65 userAgent: string;
66 /** The action applied to matched requests. */
67 mode: UaRuleMode;
68 /** The rule's informative summary, if set. */
69 description: string | undefined;
70 /** Whether the rule is currently paused. */
71 paused: boolean;
72}
73
74export type UaRule = Resource<
75 UaRuleTypeId,
76 UaRuleProps,
77 UaRuleAttributes,
78 never,
79 Providers
80>;
81
82/**
83 * A Cloudflare User Agent Blocking rule — block or challenge every request
84 * to a zone whose `User-Agent` header exactly matches a given string.
85 *
86 * Everything about a UA rule is mutable in place: `userAgent`, `mode`,
87 * `description`, and `paused` are all updated via PUT without replacing the
88 * rule. Only moving the rule to a different zone triggers a replacement.
89 *
90 * Cloudflare rejects a second rule for the same User-Agent string in a zone
91 * with a duplicate error, so the UA string acts as a rule's identity. Plan
92 * quotas: Free 10, Pro 50, Business 250, Enterprise 1000 rules.
93 *
94 * Safety: UA rules carry no ownership markers. When there is no prior
95 * state, `read` scans the zone for an existing rule with the same
96 * User-Agent string and reports it as `Unowned`, so the engine refuses to
97 * take it over unless `--adopt` (or `adopt(true)`) is set.
98 * ### Blocking a User-Agent
99 * **Example:** Block a scraper outright
100 * ```typescript
101 * yield* Cloudflare.Firewall.UaRule("BlockScraper", {
102 * zoneId: zone.zoneId,
103 * userAgent: "BadBot/1.2 (+http://badbot.example)",
104 * mode: "block",
105 * description: "aggressive scraper",
106 * });
107 * ```
108 *
109 * ### Challenging a User-Agent
110 * **Example:** Managed challenge instead of a hard block
111 * ```typescript
112 * yield* Cloudflare.Firewall.UaRule("ChallengeOldClient", {
113 * zoneId: zone.zoneId,
114 * userAgent: "LegacyApp/0.9",
115 * mode: "managed_challenge",
116 * });
117 * ```
118 *
119 * ### Pausing a rule
120 * **Example:** Temporarily disable a rule without deleting it
121 * ```typescript
122 * yield* Cloudflare.Firewall.UaRule("BlockScraper", {
123 * zoneId: zone.zoneId,
124 * userAgent: "BadBot/1.2 (+http://badbot.example)",
125 * mode: "block",
126 * paused: true,
127 * });
128 * ```
129 *
130 * @see https://developers.cloudflare.com/waf/tools/user-agent-blocking/
131 *
132 * @resource
133 * @product Firewall
134 * @category Application Security
135 */
136export const UaRule = Resource<UaRule>(UaRuleTypeId);
137
138/**
139 * Returns true if the given value is a UaRule resource.
140 */
141export const isUaRule = (value: unknown): value is UaRule =>
142 Predicate.hasProperty(value, "Type") && value.Type === UaRuleTypeId;
143
144export const UaRuleProvider = () =>
145 Provider.succeed(UaRule, {
146 stables: ["uaRuleId", "zoneId"],
147
148 list: Effect.fn(function* () {
149 const { accountId } = yield* yield* CloudflareEnvironment;
150 // UA rules live inside a zone (`/zones/{zone_id}/firewall/ua_rules`)
151 // with no account-wide list — enumerate every zone and exhaustively
152 // paginate each one's rules.
153 const zones = yield* listAllZones(accountId);
154 const rows = yield* Effect.forEach(
155 zones,
156 (zone) =>
157 firewall.listUaRules.pages({ zoneId: zone.id }).pipe(
158 Stream.runCollect,
159 Effect.map((chunk) =>
160 Array.from(chunk).flatMap((page) =>
161 (page.result ?? []).map((rule) => toAttributes(rule, zone.id)),
162 ),
163 ),
164 // Plan-gated / partial zones (eventually-consistent scoped
165 // tokens) reject the route; skip them rather than fail the
166 // whole enumeration.
167 Effect.catchTag("Forbidden", () => Effect.succeed([])),
168 ),
169 { concurrency: 10 },
170 );
171 return rows.flat();
172 }),
173
174 diff: Effect.fn(function* ({ olds = {}, news }) {
175 const o = olds as UaRuleProps;
176 const n = news as UaRuleProps;
177 // No prior props to compare against — let the engine decide.
178 if (o.zoneId === undefined) return undefined;
179 // zoneId is Input<string>; compare only once both are concrete.
180 if (
181 typeof o.zoneId === "string" &&
182 typeof n.zoneId === "string" &&
183 o.zoneId !== n.zoneId
184 ) {
185 return { action: "replace" } as const;
186 }
187 return undefined;
188 }),
189
190 read: Effect.fn(function* ({ output, olds }) {
191 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
192 if (zoneId === undefined) return undefined;
193
194 // Owned path: refresh by our persisted rule id.
195 if (output?.uaRuleId) {
196 const observed = yield* getUaRule(zoneId, output.uaRuleId);
197 if (observed) return toAttributes(observed, zoneId);
198 }
199
200 // Adoption path: a rule for this User-Agent string may already exist
201 // (Cloudflare rejects duplicates, so the UA string is the rule's
202 // identity within a zone). UA rules carry no ownership markers, so
203 // brand a match `Unowned` and let the engine gate takeover behind
204 // the adopt policy.
205 const userAgent = output?.userAgent ?? olds?.userAgent;
206 if (userAgent) {
207 const observed = yield* findByUserAgent(zoneId, userAgent);
208 if (observed) return Unowned(toAttributes(observed, zoneId));
209 }
210 return undefined;
211 }),
212
213 reconcile: Effect.fn(function* ({ news, output }) {
214 // Inputs have been resolved to concrete strings by Plan.
215 const zoneId = news.zoneId as string;
216
217 // 1. Observe — the rule id cached on `output` is a hint, not a
218 // guarantee: a missing rule falls through to the User-Agent scan
219 // and then to create.
220 let observed = output?.uaRuleId
221 ? yield* getUaRule(zoneId, output.uaRuleId)
222 : undefined;
223
224 // 2. Fall back to scanning the zone for a User-Agent match.
225 // Ownership has already been verified upstream — `read` reports
226 // existing rules as `Unowned` and the engine gates takeover
227 // behind the adopt policy before reconcile ever runs.
228 if (!observed) {
229 observed = yield* findByUserAgent(zoneId, news.userAgent);
230 }
231
232 // 3. Ensure — create when missing. A concurrent create surfaces as
233 // `DuplicateUaRule` (Cloudflare code 10009): converge by
234 // re-scanning for the rule that won the race.
235 if (!observed) {
236 observed = yield* firewall
237 .createUaRule({
238 zoneId,
239 configuration: { target: "ua", value: news.userAgent },
240 mode: news.mode,
241 description: news.description,
242 paused: news.paused,
243 })
244 .pipe(
245 Effect.catchTag("DuplicateUaRule", (error) =>
246 findByUserAgent(zoneId, news.userAgent).pipe(
247 Effect.flatMap((existing) =>
248 existing ? Effect.succeed(existing) : Effect.fail(error),
249 ),
250 ),
251 ),
252 );
253 }
254
255 // 4. Sync — diff observed cloud state against desired; skip the PUT
256 // entirely on a no-op. Undefined optional props are treated as
257 // "no constraint" so adoption doesn't clobber foreign settings.
258 const dirty =
259 (observed.configuration?.value ?? "") !== news.userAgent ||
260 observed.mode !== news.mode ||
261 (news.description !== undefined &&
262 (observed.description ?? "") !== news.description) ||
263 (news.paused !== undefined &&
264 (observed.paused ?? false) !== news.paused);
265 if (dirty) {
266 observed = yield* firewall.updateUaRule({
267 zoneId,
268 uaRuleId: observed.id ?? "",
269 configuration: { target: "ua", value: news.userAgent },
270 mode: news.mode,
271 description: news.description,
272 paused: news.paused,
273 });
274 }
275
276 return toAttributes(observed, zoneId);
277 }),
278
279 delete: Effect.fn(function* ({ output }) {
280 // Cloudflare's DELETE is naturally idempotent (an already-gone rule
281 // answers HTTP 200 echoing the id) — the typed catch covers the rare
282 // not-found envelope race.
283 yield* firewall
284 .deleteUaRule({ zoneId: output.zoneId, uaRuleId: output.uaRuleId })
285 .pipe(Effect.catchTag("UaRuleNotFound", () => Effect.void));
286 }),
287 });
288
289// ---------------------------------------------------------------------------
290// API helpers
291// ---------------------------------------------------------------------------
292
293type ObservedUaRule = firewall.GetUaRuleResponse;
294
295/**
296 * Read a UA rule by id, mapping "gone" (`UaRuleNotFound`, Cloudflare error
297 * code 10001 `firewalluablock.api.not_found`) to `undefined`.
298 */
299const getUaRule = (zoneId: string, uaRuleId: string) =>
300 firewall.getUaRule({ zoneId, uaRuleId }).pipe(
301 Effect.map((rule): ObservedUaRule | undefined => rule),
302 Effect.catchTag("UaRuleNotFound", () => Effect.succeed(undefined)),
303 );
304
305/**
306 * Find a rule by exact User-Agent string within the zone. The UA string is
307 * a rule's identity — Cloudflare rejects duplicates — so at most one rule
308 * can match.
309 */
310const findByUserAgent = (zoneId: string, userAgent: string) =>
311 firewall.listUaRules.items({ zoneId }).pipe(
312 Stream.runCollect,
313 Effect.map((chunk) =>
314 Array.from(chunk).find(
315 (rule): rule is ObservedUaRule =>
316 rule.configuration?.value === userAgent,
317 ),
318 ),
319 );
320
321const toAttributes = (
322 rule: ObservedUaRule,
323 zoneId: string,
324): UaRuleAttributes => ({
325 // Distilled types every field as optional/nullable — Cloudflare always
326 // echoes them for a persisted rule.
327 uaRuleId: rule.id ?? "",
328 zoneId,
329 userAgent: rule.configuration?.value ?? "",
330 mode: (rule.mode ?? "block") as UaRuleMode,
331 description: rule.description ?? undefined,
332 paused: rule.paused ?? false,
333});