| 1 | import * as botManagement from "@distilled.cloud/cloudflare/bot-management"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | |
| 5 | import * as Provider from "../../Provider.ts"; |
| 6 | import { Resource } from "../../Resource.ts"; |
| 7 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 8 | import type { Providers } from "../Providers.ts"; |
| 9 | import { listAllZones } from "../Zone/lookup.ts"; |
| 10 | |
| 11 | const TypeId = "Cloudflare.BotManagement.BotManagement" as const; |
| 12 | type TypeId = typeof TypeId; |
| 13 | |
| 14 | /** |
| 15 | * Action for AI scrapers and crawlers. |
| 16 | */ |
| 17 | export type AiBotsProtection = "block" | "disabled" | "only_on_ad_pages"; |
| 18 | |
| 19 | /** |
| 20 | * Robots Access Control License variant. |
| 21 | */ |
| 22 | export type CfRobotsVariant = "off" | "policy_only"; |
| 23 | |
| 24 | /** |
| 25 | * Super Bot Fight Mode action for definitely / likely automated traffic. |
| 26 | */ |
| 27 | export type SbfmAction = "allow" | "block" | "managed_challenge"; |
| 28 | |
| 29 | /** |
| 30 | * Super Bot Fight Mode action for verified bot traffic. |
| 31 | */ |
| 32 | export 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 | */ |
| 53 | export 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 | |
| 130 | export 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 | |
| 139 | export 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 | |
| 157 | export 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 | */ |
| 226 | export const BotManagement = Resource<BotManagement>(TypeId, { |
| 227 | aliases: ["Cloudflare.BotManagement"], |
| 228 | }); |
| 229 | |
| 230 | /** |
| 231 | * Returns true if the given value is a BotManagement resource. |
| 232 | */ |
| 233 | export 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 | */ |
| 240 | const 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 | |
| 258 | type SettingsKey = (typeof SETTINGS_KEYS)[number]; |
| 259 | |
| 260 | export 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 | */ |
| 377 | interface 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 | */ |
| 400 | const observe = (zoneId: string) => |
| 401 | botManagement |
| 402 | .getBotManagement({ zoneId }) |
| 403 | .pipe(Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined))); |
| 404 | |
| 405 | const 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 | */ |
| 412 | const 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 | */ |
| 426 | const settingsEqual = (desired: Settings, observed: Settings): boolean => |
| 427 | SETTINGS_KEYS.every( |
| 428 | (key) => desired[key] === undefined || desired[key] === observed[key], |
| 429 | ); |
| 430 | |
| 431 | const 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 | }); |