1import * as contentScanning from "@distilled.cloud/cloudflare/content-scanning";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { 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.ContentScanning.ContentScanning" as const;
13type TypeId = typeof TypeId;
14
15/** The wire status values Cloudflare uses for Content Scanning. */
16type ContentScanningStatus = "enabled" | "disabled";
17
18export interface Props {
19 /**
20 * Zone to manage WAF Content Scanning on. Stable — changing the zone
21 * triggers a replacement (the old zone's status is restored to the
22 * value it had before Alchemy managed it).
23 */
24 zoneId: string;
25 /**
26 * Whether Content Scanning is enabled on the zone. Mutable — toggled
27 * in place via the settings endpoint.
28 *
29 * Enabling requires the WAF Content Scanning Enterprise add-on;
30 * without it Cloudflare rejects the call with the typed
31 * `ContentScanningNotEntitled` error.
32 *
33 * @default true
34 */
35 enabled?: boolean;
36}
37
38export interface Attributes {
39 /** Zone the setting belongs to. */
40 zoneId: string;
41 /** Whether Content Scanning is currently enabled. */
42 enabled: boolean;
43 /** ISO 8601 timestamp of the last status modification, if reported. */
44 modified: string | undefined;
45 /**
46 * The status (`enabled`/`disabled`) the zone had before Alchemy first
47 * managed it. Restored on destroy, so deleting the resource puts the
48 * zone back the way it was found.
49 */
50 initialValue: ContentScanningStatus;
51}
52
53export type ContentScanning = Resource<
54 TypeId,
55 Props,
56 Attributes,
57 never,
58 Providers
59>;
60
61/**
62 * WAF Content Scanning (malicious uploads detection) on a Cloudflare zone —
63 * the `/zones/{zone_id}/content-upload-scan/settings` singleton toggle.
64 *
65 * Content Scanning is a zone singleton: the setting always exists (default
66 * `disabled`), so this resource never creates or deletes anything physical.
67 * Reconcile PUTs the status only when the observed value differs from the
68 * desired one; destroy restores the status the zone had before Alchemy
69 * first managed it (captured as `initialValue`).
70 *
71 * Content Scanning is an Enterprise paid add-on. Reading the status works
72 * on every plan, but enabling it on a zone without the add-on fails with
73 * the typed `ContentScanningNotEntitled` error.
74 * ### Enabling Content Scanning
75 * **Example:** Turn on malicious-upload scanning for a zone
76 * ```typescript
77 * const zone = yield* Cloudflare.Zone.Zone("Site", { name: "example.com" });
78 *
79 * yield* Cloudflare.ContentScanning.ContentScanning("UploadScanning", {
80 * zoneId: zone.zoneId,
81 * });
82 * ```
83 *
84 * **Example:** Pin Content Scanning off
85 * ```typescript
86 * yield* Cloudflare.ContentScanning.ContentScanning("UploadScanning", {
87 * zoneId: zone.zoneId,
88 * enabled: false,
89 * });
90 * ```
91 *
92 * ### Custom scan expressions
93 * **Example:** Scan a JSON-embedded file field
94 * ```typescript
95 * const scanning = yield* Cloudflare.ContentScanning.ContentScanning("UploadScanning", {
96 * zoneId: zone.zoneId,
97 * });
98 *
99 * yield* Cloudflare.ContentScanning.Expression("ScanJsonFile", {
100 * zoneId: scanning.zoneId,
101 * payload: 'lookup_json_string(http.request.body.raw, "file")',
102 * });
103 * ```
104 *
105 * @see https://developers.cloudflare.com/waf/detections/malicious-uploads/
106 *
107 * @resource
108 * @product Content Scanning
109 * @category Application Security
110 */
111export const ContentScanning = Resource<ContentScanning>(TypeId, {
112 aliases: ["Cloudflare.ContentScanning"],
113});
114
115/**
116 * Returns true if the given value is a ContentScanning resource.
117 */
118export const isContentScanning = (value: unknown): value is ContentScanning =>
119 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
120
121export const ContentScanningProvider = () =>
122 Provider.succeed(ContentScanning, {
123 nuke: { singleton: true },
124 stables: ["zoneId", "initialValue"],
125
126 list: Effect.fn(function* () {
127 const { accountId } = yield* yield* CloudflareEnvironment;
128 // No account-wide API for this zone singleton — enumerate every
129 // zone in the account and read its status (every zone has one).
130 const allZones = yield* listAllZones(accountId);
131 const rows = yield* Effect.forEach(
132 allZones.map((zone) => zone.id),
133 (zoneId) =>
134 contentScanning.getContentScanning({ zoneId }).pipe(
135 Effect.map((observed) =>
136 toAttributes(zoneId, observed, statusOf(observed)),
137 ),
138 // Plan-gated or partial zones reject the route; skip them.
139 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
140 ),
141 { concurrency: 10 },
142 );
143 return rows.filter((row): row is Attributes => row !== undefined);
144 }),
145
146 diff: Effect.fn(function* ({ olds, news, output }) {
147 // zoneId is Input<string>; compare only once both sides are concrete.
148 if (!isResolved(news)) return undefined;
149 const oldZoneId =
150 output?.zoneId ??
151 (olds !== undefined && isResolved(olds) ? olds.zoneId : undefined);
152 if (oldZoneId !== undefined && oldZoneId !== news.zoneId) {
153 return { action: "replace" } as const;
154 }
155 return undefined;
156 }),
157
158 read: Effect.fn(function* ({ output, olds }) {
159 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
160 if (!zoneId) return undefined;
161 const observed = yield* contentScanning
162 .getContentScanning({ zoneId })
163 .pipe(
164 // Zone deleted out-of-band — the setting is gone with it.
165 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
166 );
167 if (observed === undefined) return undefined;
168 // The setting is a singleton that always exists with a Cloudflare
169 // default — there is nothing to "own", so a cold read adopts freely
170 // (never `Unowned`). The observed status at adoption time becomes
171 // the `initialValue` restored on destroy.
172 const initialValue =
173 output !== undefined ? output.initialValue : statusOf(observed);
174 return toAttributes(zoneId, observed, initialValue);
175 }),
176
177 reconcile: Effect.fn(function* ({ news, output }) {
178 // Inputs have been resolved to concrete strings by Plan.
179 const zoneId = news.zoneId as string;
180
181 // 1. Observe — the setting always exists; read its live status.
182 const observed = yield* contentScanning.getContentScanning({ zoneId });
183
184 // 2. Capture — the pre-management status, restored on destroy.
185 // `output` (including an adoption read) already carries it;
186 // otherwise this is our first touch and the observed status is
187 // the zone's original.
188 const initialValue =
189 output !== undefined ? output.initialValue : statusOf(observed);
190
191 // 3. Sync — PUT only when the observed status differs.
192 const desired: ContentScanningStatus =
193 news.enabled === false ? "disabled" : "enabled";
194 if (statusOf(observed) === desired) {
195 return toAttributes(zoneId, observed, initialValue);
196 }
197 const updated = yield* contentScanning.putContentScanning({
198 zoneId,
199 value: desired,
200 });
201 return toAttributes(zoneId, updated, initialValue);
202 }),
203
204 delete: Effect.fn(function* ({ output }) {
205 const { zoneId, initialValue } = output;
206 // Observe — if the zone itself is gone, so is the setting.
207 const observed = yield* contentScanning
208 .getContentScanning({ zoneId })
209 .pipe(Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)));
210 if (observed === undefined) return;
211 // Restore the pre-management status; skip the call when it already
212 // matches (idempotent re-delete after a crashed run).
213 if (statusOf(observed) === initialValue) return;
214 yield* contentScanning
215 .putContentScanning({ zoneId, value: initialValue })
216 .pipe(Effect.catchTag("InvalidRoute", () => Effect.void));
217 }),
218 });
219
220/**
221 * Normalize the (nullable, open-typed) wire `value` to a concrete status.
222 * Cloudflare's default for zones that have never touched the setting is
223 * `disabled`.
224 */
225const statusOf = (
226 setting:
227 | contentScanning.GetContentScanningResponse
228 | contentScanning.PutContentScanningResponse,
229): ContentScanningStatus =>
230 setting.value === "enabled" ? "enabled" : "disabled";
231
232const toAttributes = (
233 zoneId: string,
234 setting:
235 | contentScanning.GetContentScanningResponse
236 | contentScanning.PutContentScanningResponse,
237 initialValue: ContentScanningStatus,
238): Attributes => ({
239 zoneId,
240 enabled: statusOf(setting) === "enabled",
241 modified: setting.modified ?? undefined,
242 initialValue,
243});