1import * as fraud from "@distilled.cloud/cloudflare/fraud";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { deepEqual, isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Providers } from "../Providers.ts";
10import { listAllZones } from "../Zone/lookup.ts";
11
12const TypeId = "Cloudflare.Fraud.DetectionSettings" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * Criterion for classifying a login authentication outcome from the origin
17 * response. Currently only status-code matching is supported.
18 */
19export interface AuthenticationCriteria {
20 /**
21 * How the origin response is matched. Only `"status_code"` is supported.
22 */
23 kind: "status_code";
24 /**
25 * HTTP status codes from the origin that satisfy this criterion
26 * (e.g. `[200]` for success, `[401, 403]` for failure).
27 */
28 statusCodes?: number[];
29}
30
31/**
32 * Configuration for classifying login authentication outcomes based on the
33 * origin response. Requires `userProfiles` to be `"enabled"`.
34 */
35export interface AuthenticationSettings {
36 /**
37 * Origin responses that count as a successful authentication.
38 */
39 successCriteria?: AuthenticationCriteria;
40 /**
41 * Origin responses that count as a failed authentication.
42 */
43 failureCriteria?: AuthenticationCriteria;
44}
45
46/**
47 * The writable fraud-detection settings of a zone. Only fields you
48 * explicitly set are ever sent to Cloudflare — unset fields are left
49 * untouched on the zone.
50 */
51export interface DetectionSettingsValues {
52 /**
53 * Whether Fraud User Profiles is enabled for the zone.
54 *
55 * Writing this field (even `"disabled"`) requires a fraud detection
56 * subscription on the zone.
57 * @default "disabled"
58 */
59 userProfiles?: "enabled" | "disabled";
60 /**
61 * Wirefilter expressions used to detect usernames in write HTTP
62 * requests (maximum of 10). The full desired list is always sent, so
63 * setting `[]` clears all expressions.
64 */
65 usernameExpressions?: string[];
66 /**
67 * Configuration for classifying login authentication outcomes based on
68 * the origin response. Requires `userProfiles` to be `"enabled"`.
69 */
70 authenticationSettings?: AuthenticationSettings;
71}
72
73export interface DetectionSettingsProps extends DetectionSettingsValues {
74 /**
75 * Zone whose fraud-detection settings are managed. Stable — changing
76 * the zone triggers a replacement (which re-adopts the new zone's
77 * singleton and restores the old zone's snapshot).
78 */
79 zoneId: string;
80}
81
82export interface DetectionSettingsAttributes extends DetectionSettingsValues {
83 /**
84 * Zone that owns this fraud-detection configuration.
85 */
86 zoneId: string;
87 /**
88 * Snapshot of the writable settings observed **before** this resource
89 * first wrote to the zone. `delete` restores these values for the
90 * fields this resource managed.
91 */
92 initialSettings: DetectionSettingsValues;
93}
94
95export type DetectionSettings = Resource<
96 TypeId,
97 DetectionSettingsProps,
98 DetectionSettingsAttributes,
99 never,
100 Providers
101>;
102
103/**
104 * The fraud-detection (Fraud User Profiles) settings of a Cloudflare zone
105 * (`/zones/{zone_id}/fraud_detection/settings`) — a zone-scoped
106 * **singleton**: every zone always has exactly one fraud-detection
107 * settings object, so there is no create or delete on the Cloudflare
108 * side. Reconciling this resource adopts the singleton and PUTs only the
109 * fields you explicitly set, leaving every other field untouched.
110 *
111 * Fraud Detection is a beta, subscription-gated product: writing
112 * `userProfiles`, non-empty `usernameExpressions`, or
113 * `authenticationSettings` fails with `FraudDetectionNotEntitled` unless
114 * the zone has a fraud detection subscription.
115 *
116 * On destroy, the resource restores the fields it managed to the values
117 * observed before its first write (the `initialSettings` snapshot).
118 * Fields that were never set by this resource are not touched.
119 * `authenticationSettings` that did not exist before the first write
120 * cannot be cleared and are left as-is.
121 * ### Fraud User Profiles
122 * **Example:** Enable user profiles with a username expression
123 * ```typescript
124 * yield* Cloudflare.Fraud.DetectionSettings("Fraud", {
125 * zoneId: zone.zoneId,
126 * userProfiles: "enabled",
127 * usernameExpressions: [
128 * 'lookup_json_string(http.request.body.raw, "username")',
129 * ],
130 * });
131 * ```
132 *
133 * ### Authentication outcome classification
134 * **Example:** Classify login success and failure by origin status code
135 * ```typescript
136 * yield* Cloudflare.Fraud.DetectionSettings("Fraud", {
137 * zoneId: zone.zoneId,
138 * userProfiles: "enabled",
139 * authenticationSettings: {
140 * successCriteria: { kind: "status_code", statusCodes: [200] },
141 * failureCriteria: { kind: "status_code", statusCodes: [401, 403] },
142 * },
143 * });
144 * ```
145 *
146 * ### Username expressions only
147 * **Example:** Clear all username expressions
148 * ```typescript
149 * yield* Cloudflare.Fraud.DetectionSettings("Fraud", {
150 * zoneId: zone.zoneId,
151 * usernameExpressions: [],
152 * });
153 * ```
154 *
155 * @see https://developers.cloudflare.com/bots/additional-configurations/fraud-detection/
156 *
157 * @resource
158 * @product Fraud Detection
159 * @category Application Security
160 */
161export const DetectionSettings = Resource<DetectionSettings>(TypeId, {
162 aliases: ["Cloudflare.FraudDetectionSettings"],
163});
164
165/**
166 * Returns true if the given value is a DetectionSettings resource.
167 */
168export const isDetectionSettings = (
169 value: unknown,
170): value is DetectionSettings =>
171 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
172
173/**
174 * Every writable settings key, used to project observed cloud state and
175 * user props into the only-send-what-is-set PUT body.
176 */
177const SETTINGS_KEYS = [
178 "userProfiles",
179 "usernameExpressions",
180 "authenticationSettings",
181] as const;
182
183type SettingsKey = (typeof SETTINGS_KEYS)[number];
184
185export const DetectionSettingsProvider = () =>
186 Provider.succeed(DetectionSettings, {
187 nuke: { singleton: true },
188 stables: ["zoneId", "initialSettings"],
189
190 list: Effect.fn(function* () {
191 const { accountId } = yield* yield* CloudflareEnvironment;
192 // No account-wide API for this zone singleton — enumerate every
193 // zone in the account and read its settings (every live zone has
194 // exactly one). `getFraud` only reads, so the entitlement gate
195 // (which is on `putFraud`) never trips here.
196 const allZones = yield* listAllZones(accountId);
197 const rows = yield* Effect.forEach(
198 allZones.map((zone) => zone.id),
199 (zoneId) =>
200 observe(zoneId).pipe(
201 Effect.map((observed) =>
202 observed === undefined
203 ? undefined
204 : toAttributes(zoneId, observed, pickSettings(observed)),
205 ),
206 ),
207 { concurrency: 10 },
208 );
209 return rows.filter(
210 (row): row is DetectionSettingsAttributes => row !== undefined,
211 );
212 }),
213
214 diff: Effect.fn(function* ({ olds, news, output }) {
215 if (!isResolved(news)) return undefined;
216 // zoneId is Input<string>; compare only when both sides are concrete.
217 const oldZone =
218 output?.zoneId ??
219 (olds !== undefined && typeof olds.zoneId === "string"
220 ? olds.zoneId
221 : undefined);
222 if (oldZone !== undefined && oldZone !== news.zoneId) {
223 return { action: "replace" } as const;
224 }
225 }),
226
227 read: Effect.fn(function* ({ output, olds }) {
228 // The settings object is a singleton — it exists iff the zone exists.
229 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
230 if (!zoneId) return undefined;
231 const observed = yield* observe(zoneId);
232 if (!observed) return undefined;
233 // Singletons always exist with a Cloudflare default — there is
234 // nothing to "own", so a cold read adopts freely (never `Unowned`).
235 // The observed values at adoption time become the snapshot restored
236 // on destroy.
237 return toAttributes(
238 zoneId,
239 observed,
240 output?.initialSettings ?? pickSettings(observed),
241 );
242 }),
243
244 reconcile: Effect.fn(function* ({ news, output }) {
245 // Inputs are resolved to concrete strings by Plan.
246 const zoneId = (output?.zoneId ?? news.zoneId) as string;
247
248 // 1. Observe — the singleton always exists for a live zone.
249 let observed: ObservedFraudSettings = yield* fraud.getFraud({ zoneId });
250
251 // 2. Snapshot — capture pre-management values once; `output` acts
252 // as the cache that keeps the very first observation sticky.
253 const initialSettings = output?.initialSettings ?? pickSettings(observed);
254
255 // 3. Sync — diff observed against the fields the user set and PUT
256 // only when something actually differs. Unset fields are never
257 // sent (omit-means-unchanged on Cloudflare's side), and set
258 // fields are always sent in full (the API treats the expression
259 // list and each criteria object as whole values).
260 const desired = pickSettings(news);
261 if (!settingsEqual(desired, pickSettings(observed))) {
262 observed = yield* fraud.putFraud({ zoneId, ...desired });
263 }
264
265 // 4. Return fresh attributes.
266 return toAttributes(zoneId, observed, initialSettings);
267 }),
268
269 delete: Effect.fn(function* ({ output, olds }) {
270 // Singleton — nothing to delete on the Cloudflare side. Restore the
271 // fields this resource managed (i.e. the props that were set) to
272 // their pre-management snapshot values. Fields whose snapshot value
273 // is absent (e.g. authenticationSettings that never existed before
274 // our first write) cannot be restored and are left as-is.
275 const observed = yield* observe(output.zoneId);
276 if (!observed) return; // zone is gone — nothing to restore
277 const managed = pickSettings(olds ?? {});
278 const current = pickSettings(observed);
279 const restore: DetectionSettingsValues = {};
280 for (const key of SETTINGS_KEYS) {
281 const snapshot = output.initialSettings?.[key];
282 if (
283 managed[key] !== undefined &&
284 snapshot !== undefined &&
285 !deepEqual(current[key], snapshot)
286 ) {
287 (restore as Record<SettingsKey, unknown>)[key] = snapshot;
288 }
289 }
290 if (Object.keys(restore).length > 0) {
291 yield* fraud.putFraud({ zoneId: output.zoneId, ...restore }).pipe(
292 // The zone lost its fraud detection subscription out-of-band —
293 // the snapshot cannot be restored, but delete stays idempotent.
294 Effect.catchTag("FraudDetectionNotEntitled", () => Effect.void),
295 );
296 }
297 }),
298 });
299
300/**
301 * The distilled responses type nested fields as nullable; widen to a flat
302 * bag so observation code can normalize without per-field narrowing.
303 */
304type ObservedFraudSettings = fraud.GetFraudResponse | fraud.PutFraudResponse;
305
306/**
307 * Read the zone's fraud-detection settings, mapping a dead zone
308 * (`InvalidRoute`, Cloudflare code 7003) to `undefined`.
309 */
310const observe = (zoneId: string) =>
311 fraud
312 .getFraud({ zoneId })
313 .pipe(Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)));
314
315const undef = <T>(v: T | null | undefined): T | undefined =>
316 v == null ? undefined : v;
317
318const normalizeCriteria = (
319 criteria:
320 | { kind: "status_code"; statusCodes?: readonly number[] | null }
321 | null
322 | undefined,
323): AuthenticationCriteria | undefined => {
324 if (criteria == null) return undefined;
325 const statusCodes = undef(criteria.statusCodes);
326 return {
327 kind: criteria.kind,
328 ...(statusCodes !== undefined ? { statusCodes: [...statusCodes] } : {}),
329 };
330};
331
332const normalizeAuthenticationSettings = (
333 settings: ObservedFraudSettings["authenticationSettings"],
334): AuthenticationSettings | undefined => {
335 if (settings == null) return undefined;
336 const successCriteria = normalizeCriteria(settings.successCriteria);
337 const failureCriteria = normalizeCriteria(settings.failureCriteria);
338 if (successCriteria === undefined && failureCriteria === undefined) {
339 return undefined;
340 }
341 return {
342 ...(successCriteria !== undefined ? { successCriteria } : {}),
343 ...(failureCriteria !== undefined ? { failureCriteria } : {}),
344 };
345};
346
347/**
348 * Project any source (observed response, props, attrs) onto the writable
349 * settings keys, dropping `null`/`undefined` values and normalizing
350 * nested nullable fields.
351 */
352const pickSettings = (
353 source: ObservedFraudSettings | DetectionSettingsValues,
354): DetectionSettingsValues => {
355 const out: DetectionSettingsValues = {};
356 const userProfiles = undef(source.userProfiles);
357 if (userProfiles === "enabled") {
358 out.userProfiles = "enabled";
359 } else if (userProfiles === "disabled") {
360 out.userProfiles = "disabled";
361 }
362 const usernameExpressions = undef(source.usernameExpressions);
363 if (usernameExpressions !== undefined) {
364 out.usernameExpressions = [...usernameExpressions];
365 }
366 const authenticationSettings = normalizeAuthenticationSettings(
367 source.authenticationSettings,
368 );
369 if (authenticationSettings !== undefined) {
370 out.authenticationSettings = authenticationSettings;
371 }
372 return out;
373};
374
375/**
376 * True when every field set in `desired` matches `observed`. Unset
377 * desired fields are ignored — they are dashboard-managed.
378 */
379const settingsEqual = (
380 desired: DetectionSettingsValues,
381 observed: DetectionSettingsValues,
382): boolean =>
383 SETTINGS_KEYS.every(
384 (key) =>
385 desired[key] === undefined || deepEqual(desired[key], observed[key]),
386 );
387
388const toAttributes = (
389 zoneId: string,
390 observed: ObservedFraudSettings,
391 initialSettings: DetectionSettingsValues,
392): DetectionSettingsAttributes => ({
393 zoneId,
394 ...pickSettings(observed),
395 initialSettings,
396});