1import * as pageShield from "@distilled.cloud/cloudflare/page-shield";
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.PageShield.Settings" as const;
13type TypeId = typeof TypeId;
14
15export interface SettingsProps {
16 /**
17 * Zone whose Page Shield configuration is managed. Stable — changing
18 * the zone triggers a replacement (the old zone's configuration is
19 * restored to the values it had before Alchemy managed it).
20 */
21 zoneId: string;
22 /**
23 * Whether Page Shield (client-side script monitoring) is enabled on
24 * the zone. The resource exists to turn the feature on, so this
25 * defaults to `true`. Mutable — updated in place.
26 * @default true
27 */
28 enabled?: boolean;
29 /**
30 * When true, CSP reports are sent to Cloudflare's own reporting
31 * endpoint (`https://csp-reporting.cloudflare.com/...`) instead of
32 * the zone's `/cdn-cgi/script_monitor/report` path. Mutable.
33 *
34 * Setting this to `false` requires the dedicated-domain-reporting
35 * entitlement — on non-entitled zones the write fails with the typed
36 * `NotEntitled` error.
37 * @default true
38 */
39 useCloudflareReportingEndpoint?: boolean;
40 /**
41 * When true, the paths associated with connection URLs are also
42 * analyzed (not just the host). Mutable.
43 *
44 * Setting this to `true` requires the connection-monitor entitlement
45 * (Business/Enterprise) — on non-entitled zones the write fails with
46 * the typed `NotEntitled` error.
47 * @default false
48 */
49 useConnectionUrlPath?: boolean;
50}
51
52export interface SettingsAttributes {
53 /** Zone the configuration belongs to. */
54 zoneId: string;
55 /** Whether Page Shield is enabled. */
56 enabled: boolean;
57 /** Whether CSP reports are sent to Cloudflare's reporting endpoint. */
58 useCloudflareReportingEndpoint: boolean;
59 /** Whether connection URL paths are analyzed. */
60 useConnectionUrlPath: boolean;
61 /** When the Page Shield configuration was last updated. */
62 updatedAt: string;
63 /**
64 * The `enabled` flag the zone had before Alchemy first touched the
65 * configuration. Restored on destroy.
66 */
67 initialEnabled: boolean;
68 /**
69 * The `useCloudflareReportingEndpoint` flag the zone had before
70 * Alchemy first touched the configuration. Restored on destroy.
71 */
72 initialUseCloudflareReportingEndpoint: boolean;
73 /**
74 * The `useConnectionUrlPath` flag the zone had before Alchemy first
75 * touched the configuration. Restored on destroy.
76 */
77 initialUseConnectionUrlPath: boolean;
78}
79
80export type Settings = Resource<
81 TypeId,
82 SettingsProps,
83 SettingsAttributes,
84 never,
85 Providers
86>;
87
88/**
89 * The Page Shield configuration of a Cloudflare zone
90 * (`/zones/{zone_id}/page_shield`).
91 *
92 * Page Shield monitors the JavaScript and connections loaded by your
93 * visitors' browsers to detect supply-chain attacks (e.g. Magecart). The
94 * configuration is a zone **singleton** — it always exists (default
95 * disabled), so this resource never creates or deletes anything physical.
96 * Reconcile PUTs the configuration when the observed flags differ from the
97 * desired ones; destroy restores the flags the zone had before Alchemy
98 * first managed them.
99 *
100 * Script/connection *monitoring data* requires a Business or Enterprise
101 * zone plan, but the configuration endpoints themselves accept reads and
102 * `enabled` writes on lower plans. Setting `useConnectionUrlPath: true`
103 * or `useCloudflareReportingEndpoint: false` on a non-entitled zone fails
104 * with the typed `NotEntitled` error.
105 *
106 * Only one `Settings` resource per zone makes sense — two
107 * instances managing the same zone would fight over the singleton.
108 * ### Managing Page Shield
109 * **Example:** Enable Page Shield on a zone
110 * ```typescript
111 * const zone = yield* Cloudflare.Zone.Zone("Site", { name: "example.com" });
112 *
113 * yield* Cloudflare.PageShield.Settings("PageShield", {
114 * zoneId: zone.zoneId,
115 * });
116 * ```
117 *
118 * **Example:** Analyze connection URL paths too
119 * ```typescript
120 * yield* Cloudflare.PageShield.Settings("PageShield", {
121 * zoneId: zone.zoneId,
122 * useConnectionUrlPath: true,
123 * });
124 * ```
125 *
126 * **Example:** Report CSP violations to the zone instead of Cloudflare
127 * ```typescript
128 * yield* Cloudflare.PageShield.Settings("PageShield", {
129 * zoneId: zone.zoneId,
130 * useCloudflareReportingEndpoint: false,
131 * });
132 * ```
133 *
134 * @see https://developers.cloudflare.com/page-shield/
135 *
136 * @resource
137 * @product Page Shield
138 * @category Application Security
139 */
140export const Settings = Resource<Settings>(TypeId);
141
142/**
143 * Returns true if the given value is a Settings resource.
144 */
145export const isSettings = (value: unknown): value is Settings =>
146 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
147
148interface DesiredSettings {
149 enabled: boolean;
150 useCloudflareReportingEndpoint: boolean;
151 useConnectionUrlPath: boolean;
152}
153
154const desiredSettings = (props: SettingsProps): DesiredSettings => ({
155 enabled: props.enabled ?? true,
156 useCloudflareReportingEndpoint: props.useCloudflareReportingEndpoint ?? true,
157 useConnectionUrlPath: props.useConnectionUrlPath ?? false,
158});
159
160export const SettingsProvider = () =>
161 Provider.succeed(Settings, {
162 nuke: { singleton: true },
163 stables: [
164 "zoneId",
165 "initialEnabled",
166 "initialUseCloudflareReportingEndpoint",
167 "initialUseConnectionUrlPath",
168 ],
169
170 list: Effect.fn(function* () {
171 const { accountId } = yield* yield* CloudflareEnvironment;
172 // No account-wide API for this zone singleton — enumerate every
173 // zone in the account and read its configuration (every zone has
174 // one, defaulting to disabled).
175 const allZones = yield* listAllZones(accountId);
176 const rows = yield* Effect.forEach(
177 allZones.map((zone) => zone.id),
178 (zoneId) =>
179 pageShield.getPageShield({ zoneId }).pipe(
180 // On a cold enumeration the observed flags are the zone's
181 // current state and double as the `initial*` baseline.
182 Effect.map((observed) => toAttributes(zoneId, observed, observed)),
183 // Zones the scoped token can't read (partial / restricted)
184 // reject with the typed `Forbidden` error; skip them.
185 Effect.catchTag("Forbidden", () => Effect.succeed(undefined)),
186 ),
187 { concurrency: 10 },
188 );
189 return rows.filter((row): row is SettingsAttributes => row !== undefined);
190 }),
191
192 diff: Effect.fn(function* ({ news, output }) {
193 if (!isResolved(news)) return undefined;
194 // zoneId is the singleton's identity — moving the resource to a
195 // different zone replaces it (restoring the old zone on delete).
196 if (output !== undefined && output.zoneId !== news.zoneId) {
197 return { action: "replace" } as const;
198 }
199 return undefined;
200 }),
201
202 read: Effect.fn(function* ({ output, olds }) {
203 const zoneId =
204 output?.zoneId ??
205 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
206 if (!zoneId) return undefined;
207 const observed = yield* pageShield.getPageShield({ zoneId });
208 // The configuration is a singleton that always exists with a
209 // Cloudflare default — there is nothing to "own", so a cold read
210 // adopts freely (never `Unowned`). The observed flags at adoption
211 // time become the `initial*` values restored on destroy.
212 const initial: DesiredSettings =
213 output !== undefined
214 ? {
215 enabled: output.initialEnabled,
216 useCloudflareReportingEndpoint:
217 output.initialUseCloudflareReportingEndpoint,
218 useConnectionUrlPath: output.initialUseConnectionUrlPath,
219 }
220 : observed;
221 return toAttributes(zoneId, observed, initial);
222 }),
223
224 reconcile: Effect.fn(function* ({ news, output }) {
225 // Inputs have been resolved to concrete strings by Plan.
226 const zoneId = news.zoneId as string;
227
228 // 1. Observe — the configuration always exists; read its live state.
229 const observed = yield* pageShield.getPageShield({ zoneId });
230
231 // 2. Capture — the pre-management flags, restored on destroy.
232 // `output` (including an adoption read) already carries them;
233 // otherwise this is our first touch and the observed flags are
234 // the zone's original.
235 const initial: DesiredSettings =
236 output !== undefined
237 ? {
238 enabled: output.initialEnabled,
239 useCloudflareReportingEndpoint:
240 output.initialUseCloudflareReportingEndpoint,
241 useConnectionUrlPath: output.initialUseConnectionUrlPath,
242 }
243 : observed;
244
245 // 3. Sync — PUT the full desired body only when something differs.
246 const desired = desiredSettings(news);
247 if (sameSettings(observed, desired)) {
248 return toAttributes(zoneId, observed, initial);
249 }
250 const updated = yield* pageShield.putPageShield({
251 zoneId,
252 ...desired,
253 });
254 return toAttributes(zoneId, updated, initial);
255 }),
256
257 delete: Effect.fn(function* ({ output }) {
258 const { zoneId } = output;
259 const initial: DesiredSettings = {
260 enabled: output.initialEnabled,
261 useCloudflareReportingEndpoint:
262 output.initialUseCloudflareReportingEndpoint,
263 useConnectionUrlPath: output.initialUseConnectionUrlPath,
264 };
265 // Observe, then restore the pre-management flags; skip the call
266 // when they already match (idempotent re-delete after a crash).
267 const observed = yield* pageShield.getPageShield({ zoneId });
268 if (sameSettings(observed, initial)) return;
269 yield* pageShield.putPageShield({ zoneId, ...initial });
270 }),
271 });
272
273const sameSettings = (a: DesiredSettings, b: DesiredSettings): boolean =>
274 a.enabled === b.enabled &&
275 a.useCloudflareReportingEndpoint === b.useCloudflareReportingEndpoint &&
276 a.useConnectionUrlPath === b.useConnectionUrlPath;
277
278const toAttributes = (
279 zoneId: string,
280 observed: pageShield.GetPageShieldResponse | pageShield.PutPageShieldResponse,
281 initial: DesiredSettings,
282): SettingsAttributes => ({
283 zoneId,
284 enabled: observed.enabled,
285 useCloudflareReportingEndpoint: observed.useCloudflareReportingEndpoint,
286 useConnectionUrlPath: observed.useConnectionUrlPath,
287 updatedAt: observed.updatedAt,
288 initialEnabled: initial.enabled,
289 initialUseCloudflareReportingEndpoint: initial.useCloudflareReportingEndpoint,
290 initialUseConnectionUrlPath: initial.useConnectionUrlPath,
291});