1import * as botManagement from "@distilled.cloud/cloudflare/bot-management";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
8import type { Providers } from "../Providers.ts";
9import { listAllZones } from "../Zone/lookup.ts";
10
11const TypeId = "Cloudflare.BotManagement.BotManagement" as const;
12type TypeId = typeof TypeId;
13
14/**
15 * Action for AI scrapers and crawlers.
16 */
17export type AiBotsProtection = "block" | "disabled" | "only_on_ad_pages";
18
19/**
20 * Robots Access Control License variant.
21 */
22export type CfRobotsVariant = "off" | "policy_only";
23
24/**
25 * Super Bot Fight Mode action for definitely / likely automated traffic.
26 */
27export type SbfmAction = "allow" | "block" | "managed_challenge";
28
29/**
30 * Super Bot Fight Mode action for verified bot traffic.
31 */
32export type SbfmVerifiedBotsAction = "allow" | "block";
33
34/**
35 * The writable bot-management settings. Which fields the Cloudflare API
36 * accepts depends on the zone's plan:
37 *
38 * - **Free** — `fightMode` (Bot Fight Mode)
39 * - **Pro** — `sbfmDefinitelyAutomated`, `sbfmVerifiedBots`,
40 * `sbfmStaticResourceProtection`, `optimizeWordpress`
41 * - **Business / Enterprise (without the Bot Management add-on)** — Pro
42 * fields plus `sbfmLikelyAutomated`
43 * - **Enterprise with Bot Management add-on** — `autoUpdateModel`,
44 * `bmCookieEnabled`, `suppressSessionScore`
45 *
46 * `aiBotsProtection`, `crawlerProtection`, `contentBotsProtection`,
47 * `cfRobotsVariant`, `enableJs`, and `isRobotsTxtManaged` are shared
48 * across plans (though some accounts reject writes to a subset of them).
49 *
50 * Only fields you explicitly set are ever sent to Cloudflare — sending a
51 * field outside the zone's plan shape fails validation server-side.
52 */
53export interface Settings {
54 /**
55 * Action for AI scrapers and crawlers ("block AI bots"). Note
56 * `only_on_ad_pages` is not available for Enterprise zones.
57 */
58 aiBotsProtection?: AiBotsProtection;
59 /**
60 * Punish AI scrapers and crawlers via a link maze (AI Labyrinth).
61 */
62 crawlerProtection?: "enabled" | "disabled";
63 /**
64 * Block content bots — automated traffic with low bot scores, excluding
65 * safe verified bot categories.
66 */
67 contentBotsProtection?: "block" | "disabled";
68 /**
69 * Robots Access Control License variant to use.
70 */
71 cfRobotsVariant?: CfRobotsVariant;
72 /**
73 * Use lightweight, invisible JavaScript detections to improve Bot
74 * Management.
75 */
76 enableJs?: boolean;
77 /**
78 * Serve a Cloudflare-managed robots.txt. If the origin already serves
79 * one, the managed file is prepended to it.
80 */
81 isRobotsTxtManaged?: boolean;
82 /**
83 * Bot Fight Mode (Free-plan zones). Mutually exclusive with the SBFM
84 * fields below.
85 */
86 fightMode?: boolean;
87 /**
88 * Super Bot Fight Mode action for definitely automated requests
89 * (Pro and above).
90 */
91 sbfmDefinitelyAutomated?: SbfmAction;
92 /**
93 * Super Bot Fight Mode action for likely automated requests
94 * (Business and above).
95 */
96 sbfmLikelyAutomated?: SbfmAction;
97 /**
98 * Super Bot Fight Mode action for verified bot requests (Pro and above).
99 */
100 sbfmVerifiedBots?: SbfmVerifiedBotsAction;
101 /**
102 * Super Bot Fight Mode static resource protection (Pro and above).
103 * Enabling can challenge legitimate static-asset consumers.
104 */
105 sbfmStaticResourceProtection?: boolean;
106 /**
107 * Optimize Super Bot Fight Mode protections for WordPress
108 * (Pro and above).
109 */
110 optimizeWordpress?: boolean;
111 /**
112 * Automatically update to the newest bot detection model (Enterprise
113 * Bot Management add-on).
114 */
115 autoUpdateModel?: boolean;
116 /**
117 * Whether the bot management cookie may be placed on end-user devices
118 * (Enterprise Bot Management add-on).
119 * @default true
120 */
121 bmCookieEnabled?: boolean;
122 /**
123 * Disable tracking the highest bot score for a session in the Bot
124 * Management cookie (Enterprise Bot Management add-on).
125 * @default false
126 */
127 suppressSessionScore?: boolean;
128}
129
130export interface Props extends Settings {
131 /**
132 * Zone whose bot-management configuration is managed. Stable — changing
133 * the zone triggers a replacement (which simply re-adopts the new
134 * zone's singleton and restores the old zone's snapshot).
135 */
136 zoneId: string;
137}
138
139export interface Attributes extends Settings {
140 /**
141 * Zone that owns this bot-management configuration.
142 */
143 zoneId: string;
144 /**
145 * Whether the zone is running the latest ML model (read-only,
146 * Enterprise Bot Management add-on).
147 */
148 usingLatestModel: boolean | undefined;
149 /**
150 * Snapshot of the writable settings observed **before** this resource
151 * first wrote to the zone. `delete` restores these values for the
152 * fields this resource managed.
153 */
154 initialSettings: Settings;
155}
156
157export type BotManagement = Resource<
158 TypeId,
159 Props,
160 Attributes,
161 never,
162 Providers
163>;
164
165/**
166 * The bot-management configuration of a Cloudflare zone — a zone-scoped
167 * **singleton**: every zone always has exactly one bot-management config,
168 * so there is no create or delete on the Cloudflare side. Reconciling this
169 * resource adopts the singleton and PUTs only the fields you explicitly
170 * set, leaving every other field untouched.
171 *
172 * Which fields are writable depends on the zone's plan (see
173 * {@link Settings}). Setting a field outside the zone's plan
174 * shape fails validation on Cloudflare's side.
175 *
176 * On destroy, the resource restores the fields it managed to the values
177 * observed before its first write (the `initialSettings` snapshot).
178 * Fields that were never set by this resource are not touched. Settings
179 * changed out-of-band after the snapshot was taken, or fields the zone's
180 * plan no longer accepts, cannot be restored.
181 * ### Super Bot Fight Mode
182 * **Example:** Challenge definitely automated traffic (Pro and above)
183 * ```typescript
184 * yield* Cloudflare.BotManagement.BotManagement("Bots", {
185 * zoneId: zone.zoneId,
186 * sbfmDefinitelyAutomated: "managed_challenge",
187 * sbfmVerifiedBots: "allow",
188 * });
189 * ```
190 *
191 * **Example:** Static resource protection and WordPress optimization
192 * ```typescript
193 * yield* Cloudflare.BotManagement.BotManagement("Bots", {
194 * zoneId: zone.zoneId,
195 * sbfmDefinitelyAutomated: "block",
196 * sbfmStaticResourceProtection: true,
197 * optimizeWordpress: true,
198 * });
199 * ```
200 *
201 * ### AI bot protection
202 * **Example:** Block AI scrapers and crawlers
203 * ```typescript
204 * yield* Cloudflare.BotManagement.BotManagement("Bots", {
205 * zoneId: zone.zoneId,
206 * aiBotsProtection: "block",
207 * crawlerProtection: "enabled",
208 * });
209 * ```
210 *
211 * ### Bot Fight Mode (Free plans)
212 * **Example:** Enable Bot Fight Mode
213 * ```typescript
214 * yield* Cloudflare.BotManagement.BotManagement("Bots", {
215 * zoneId: zone.zoneId,
216 * fightMode: true,
217 * });
218 * ```
219 *
220 * @see https://developers.cloudflare.com/bots/
221 *
222 * @resource
223 * @product Bot Management
224 * @category Application Security
225 */
226export const BotManagement = Resource<BotManagement>(TypeId, {
227 aliases: ["Cloudflare.BotManagement"],
228});
229
230/**
231 * Returns true if the given value is a BotManagement resource.
232 */
233export const isBotManagement = (value: unknown): value is BotManagement =>
234 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
235
236/**
237 * Every writable settings key, used to project observed cloud state and
238 * user props into the only-send-what-is-set PUT body.
239 */
240const SETTINGS_KEYS = [
241 "aiBotsProtection",
242 "crawlerProtection",
243 "contentBotsProtection",
244 "cfRobotsVariant",
245 "enableJs",
246 "isRobotsTxtManaged",
247 "fightMode",
248 "sbfmDefinitelyAutomated",
249 "sbfmLikelyAutomated",
250 "sbfmVerifiedBots",
251 "sbfmStaticResourceProtection",
252 "optimizeWordpress",
253 "autoUpdateModel",
254 "bmCookieEnabled",
255 "suppressSessionScore",
256] as const;
257
258type SettingsKey = (typeof SETTINGS_KEYS)[number];
259
260export const BotManagementProvider = () =>
261 Provider.succeed(BotManagement, {
262 nuke: { singleton: true },
263 stables: ["zoneId", "initialSettings"],
264
265 list: Effect.fn(function* () {
266 const { accountId } = yield* yield* CloudflareEnvironment;
267 // No account-wide API for this zone singleton — enumerate every
268 // zone in the account and read its bot-management config (every
269 // live zone always has exactly one).
270 const allZones = yield* listAllZones(accountId);
271 const rows = yield* Effect.forEach(
272 allZones.map((zone) => zone.id),
273 (zoneId) =>
274 // `observe` maps a dead zone (`InvalidRoute`) to undefined; a
275 // plan-gated zone forbids the read — skip both.
276 observe(zoneId).pipe(
277 Effect.map((observed) =>
278 observed === undefined
279 ? undefined
280 : toAttributes(zoneId, observed, pickSettings(observed)),
281 ),
282 Effect.catchTag("Forbidden", () => Effect.succeed(undefined)),
283 ),
284 { concurrency: 10 },
285 );
286 return rows.filter((row): row is Attributes => row !== undefined);
287 }),
288
289 diff: Effect.fn(function* ({ olds = {}, news, output }) {
290 const o = olds as Props;
291 const n = news as Props;
292 // zoneId is Input<string>; compare only when both sides are concrete.
293 const oldZone = output?.zoneId ?? o.zoneId;
294 if (
295 typeof oldZone === "string" &&
296 typeof n.zoneId === "string" &&
297 oldZone !== n.zoneId
298 ) {
299 return { action: "replace" } as const;
300 }
301 }),
302
303 read: Effect.fn(function* ({ output, olds }) {
304 // The config is a singleton — it exists iff the zone exists.
305 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
306 if (!zoneId) return undefined;
307 const observed = yield* observe(zoneId);
308 if (!observed) return undefined;
309 return toAttributes(
310 zoneId,
311 observed,
312 output?.initialSettings ?? pickSettings(observed),
313 );
314 }),
315
316 reconcile: Effect.fn(function* ({ news, output }) {
317 // Inputs are resolved to concrete strings by Plan.
318 const zoneId = (output?.zoneId ?? news.zoneId) as string;
319
320 // 1. Observe — the singleton always exists for a live zone.
321 let observed = yield* botManagement.getBotManagement({ zoneId });
322
323 // 2. Snapshot — capture pre-management values once; `output` acts
324 // as the cache that keeps the very first observation sticky.
325 const initialSettings = output?.initialSettings ?? pickSettings(observed);
326
327 // 3. Sync — diff observed against the fields the user set and PUT
328 // only when something actually differs. Unset fields are never
329 // sent (plan-shape validation + don't clobber dashboard config).
330 const desired = pickSettings(news);
331 if (!settingsEqual(desired, pickSettings(observed))) {
332 observed = yield* botManagement.putBotManagement({
333 zoneId,
334 ...desired,
335 });
336 }
337
338 // 4. Return fresh attributes.
339 return toAttributes(zoneId, observed, initialSettings);
340 }),
341
342 delete: Effect.fn(function* ({ output, olds }) {
343 // Singleton — nothing to delete on the Cloudflare side. Restore the
344 // fields this resource managed (i.e. the props that were set) to
345 // their pre-management snapshot values. Fields whose snapshot value
346 // is absent (the plan never exposed them, or Cloudflare returned
347 // null) cannot be restored and are left as-is.
348 const observed = yield* observe(output.zoneId);
349 if (!observed) return; // zone is gone — nothing to restore
350 const managed = pickSettings(olds ?? {});
351 const current = pickSettings(observed);
352 const restore: Settings = {};
353 for (const key of SETTINGS_KEYS) {
354 const snapshot = output.initialSettings?.[key];
355 if (
356 managed[key] !== undefined &&
357 snapshot !== undefined &&
358 current[key] !== snapshot
359 ) {
360 (restore as Record<SettingsKey, unknown>)[key] = snapshot;
361 }
362 }
363 if (Object.keys(restore).length > 0) {
364 yield* botManagement.putBotManagement({
365 zoneId: output.zoneId,
366 ...restore,
367 });
368 }
369 }),
370 });
371
372/**
373 * The distilled response is a 4-way union of plan shapes. Widen to a flat
374 * bag of optional fields so observation code can read any field without
375 * narrowing on the plan.
376 */
377interface ObservedBotManagement {
378 readonly aiBotsProtection?: string | null;
379 readonly crawlerProtection?: string | null;
380 readonly contentBotsProtection?: string | null;
381 readonly cfRobotsVariant?: string | null;
382 readonly enableJs?: boolean | null;
383 readonly isRobotsTxtManaged?: boolean | null;
384 readonly fightMode?: boolean | null;
385 readonly sbfmDefinitelyAutomated?: string | null;
386 readonly sbfmLikelyAutomated?: string | null;
387 readonly sbfmVerifiedBots?: string | null;
388 readonly sbfmStaticResourceProtection?: boolean | null;
389 readonly optimizeWordpress?: boolean | null;
390 readonly autoUpdateModel?: boolean | null;
391 readonly bmCookieEnabled?: boolean | null;
392 readonly suppressSessionScore?: boolean | null;
393 readonly usingLatestModel?: boolean | null;
394}
395
396/**
397 * Read the zone's bot-management config, mapping a dead zone
398 * (`InvalidRoute`, Cloudflare code 7003) to `undefined`.
399 */
400const observe = (zoneId: string) =>
401 botManagement
402 .getBotManagement({ zoneId })
403 .pipe(Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)));
404
405const undef = <T>(v: T | null | undefined): T | undefined =>
406 v == null ? undefined : v;
407
408/**
409 * Project any source (observed union member, props, attrs) onto the
410 * writable settings keys, dropping `null`/`undefined` values.
411 */
412const pickSettings = (source: ObservedBotManagement | Settings): Settings => {
413 const bag = source as Record<SettingsKey, unknown>;
414 const out: Record<string, unknown> = {};
415 for (const key of SETTINGS_KEYS) {
416 const value = undef(bag[key]);
417 if (value !== undefined) out[key] = value;
418 }
419 return out as Settings;
420};
421
422/**
423 * True when every field set in `desired` matches `observed`. Unset
424 * desired fields are ignored — they are dashboard/plan-managed.
425 */
426const settingsEqual = (desired: Settings, observed: Settings): boolean =>
427 SETTINGS_KEYS.every(
428 (key) => desired[key] === undefined || desired[key] === observed[key],
429 );
430
431const toAttributes = (
432 zoneId: string,
433 observed: ObservedBotManagement,
434 initialSettings: Settings,
435): Attributes => ({
436 zoneId,
437 ...pickSettings(observed),
438 usingLatestModel: undef(observed.usingLatestModel),
439 initialSettings,
440});