1import * as pageShield from "@distilled.cloud/cloudflare/page-shield";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13import { listAllZones } from "../Zone/lookup.ts";
14
15const TypeId = "Cloudflare.PageShield.Policy" as const;
16type TypeId = typeof TypeId;
17
18/**
19 * Action a Page Shield policy takes when its expression matches:
20 * `allow` blocks everything not covered by the CSP, `log` only reports
21 * violations, and `add_reporting_directives` injects report-to /
22 * report-uri directives.
23 */
24export type PolicyAction = "allow" | "log" | "add_reporting_directives";
25
26export interface PolicyProps {
27 /**
28 * Zone the policy belongs to. Stable — changing the zone triggers a
29 * replacement.
30 */
31 zoneId: string;
32 /**
33 * Human readable description of the policy. Page Shield policies have
34 * no `name` field, so the description doubles as the resource's
35 * identity for cold-state recovery. If omitted, a deterministic name
36 * is generated from the app, stage, and logical ID. Mutable.
37 * @default ${app}-${stage}-${id}
38 */
39 description?: string;
40 /**
41 * The action to take when `expression` matches: `allow` (enforce the
42 * CSP), `log` (report only), or `add_reporting_directives`. Mutable.
43 */
44 action: PolicyAction;
45 /**
46 * Whether the policy is enabled. Mutable.
47 * @default true
48 */
49 enabled?: boolean;
50 /**
51 * The expression that must match for the policy to be applied, in
52 * Cloudflare's Firewall rule expression syntax (e.g.
53 * `http.host eq "example.com"`). Mutable.
54 */
55 expression: string;
56 /**
57 * The Content Security Policy to apply (e.g. `script-src 'self'`).
58 * Mutable.
59 */
60 value: string;
61}
62
63export interface PolicyAttributes {
64 /** Auto-assigned identifier of the policy. */
65 policyId: string;
66 /** Zone the policy belongs to. */
67 zoneId: string;
68 /** The action taken when the expression matches. */
69 action: PolicyAction;
70 /** Human readable description of the policy. */
71 description: string;
72 /** Whether the policy is enabled. */
73 enabled: boolean;
74 /** The expression that must match for the policy to be applied. */
75 expression: string;
76 /** The Content Security Policy applied by this policy. */
77 value: string;
78}
79
80export type Policy = Resource<
81 TypeId,
82 PolicyProps,
83 PolicyAttributes,
84 never,
85 Providers
86>;
87
88/**
89 * A Page Shield policy — a Content Security Policy rule
90 * (`/zones/{zone_id}/page_shield/policies`) that is applied when its
91 * expression matches a request.
92 *
93 * Policies let you enforce (or log violations of) a CSP at the edge,
94 * positively blocking resources Page Shield hasn't approved. All fields
95 * are mutable in place; only the zone forces a replacement.
96 *
97 * **Entitlement-gated**: CSP policies are an Enterprise add-on. On
98 * non-entitled zones, creation fails with the typed `PolicyQuotaExceeded`
99 * error ("exceeded the maximum number of rules in the phase
100 * http_response_page_shield: 1 out of 0"). Page Shield itself should be
101 * enabled on the zone first — see `Cloudflare.PageShield.Settings`.
102 * ### Creating a Policy
103 * **Example:** Log-only CSP policy
104 * ```typescript
105 * const zone = yield* Cloudflare.Zone.Zone("Site", { name: "example.com" });
106 *
107 * yield* Cloudflare.PageShield.Settings("PageShield", {
108 * zoneId: zone.zoneId,
109 * });
110 *
111 * yield* Cloudflare.PageShield.Policy("LogScripts", {
112 * zoneId: zone.zoneId,
113 * action: "log",
114 * expression: 'http.host eq "example.com"',
115 * value: "script-src 'self'",
116 * });
117 * ```
118 *
119 * **Example:** Enforcing CSP policy with a description
120 * ```typescript
121 * yield* Cloudflare.PageShield.Policy("EnforceScripts", {
122 * zoneId: zone.zoneId,
123 * description: "block third-party scripts on checkout",
124 * action: "allow",
125 * expression: 'starts_with(http.request.uri.path, "/checkout")',
126 * value: "script-src 'self' https://js.stripe.com",
127 * });
128 * ```
129 *
130 * @see https://developers.cloudflare.com/page-shield/policies/
131 *
132 * @resource
133 * @product Page Shield
134 * @category Application Security
135 */
136export const Policy = Resource<Policy>(TypeId);
137
138/**
139 * Returns true if the given value is a Policy resource.
140 */
141export const isPolicy = (value: unknown): value is Policy =>
142 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
143
144export const PolicyProvider = () =>
145 Provider.succeed(Policy, {
146 stables: ["policyId", "zoneId"],
147
148 diff: Effect.fn(function* ({ news, output }) {
149 if (!isResolved(news)) return undefined;
150 if (output !== undefined && output.zoneId !== news.zoneId) {
151 return { action: "replace" } as const;
152 }
153 return undefined;
154 }),
155
156 read: Effect.fn(function* ({ id, output, olds }) {
157 const zoneId =
158 output?.zoneId ??
159 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
160 if (!zoneId) return undefined;
161
162 if (output?.policyId) {
163 const observed = yield* getPolicy(zoneId, output.policyId);
164 return observed ? toAttributes(zoneId, observed) : undefined;
165 }
166 // Cold read — recover from lost state by matching the deterministic
167 // description (policies have no name and carry no tags, so the
168 // description is the best identity we have). A match is branded
169 // `Unowned` so the engine gates takeover behind `--adopt`.
170 const description = yield* createDescription(id, olds?.description);
171 const list = yield* pageShield.listPolicies({ zoneId });
172 const match = list.result.find((p) => p.description === description);
173 return match ? Unowned(toAttributes(zoneId, match)) : undefined;
174 }),
175
176 reconcile: Effect.fn(function* ({ id, news, output }) {
177 // Inputs have been resolved to concrete strings by Plan.
178 const zoneId = news.zoneId as string;
179 const description = yield* createDescription(id, news.description);
180
181 // 1. Observe — the policyId cached on `output` is a hint, not a
182 // guarantee: a PolicyNotFound falls through to "missing".
183 const observed = output?.policyId
184 ? yield* getPolicy(zoneId, output.policyId)
185 : undefined;
186
187 const desired = {
188 action: news.action,
189 description,
190 enabled: news.enabled ?? true,
191 expression: news.expression,
192 value: news.value,
193 };
194
195 // 2. Ensure — greenfield (or out-of-band delete): create with the
196 // full desired body. Descriptions are not unique on Cloudflare's
197 // side, so there is no AlreadyExists race to tolerate.
198 if (!observed) {
199 const created = yield* pageShield.createPolicy({ zoneId, ...desired });
200 return toAttributes(zoneId, created);
201 }
202
203 // 3. Sync — diff observed cloud state against desired; the update
204 // API is a PUT, so send the full body, but skip the call
205 // entirely on a no-op.
206 const dirty =
207 observed.action !== desired.action ||
208 observed.description !== desired.description ||
209 observed.enabled !== desired.enabled ||
210 observed.expression !== desired.expression ||
211 observed.value !== desired.value;
212 if (!dirty) {
213 return toAttributes(zoneId, observed);
214 }
215 const updated = yield* pageShield.updatePolicy({
216 zoneId,
217 policyId: observed.id,
218 ...desired,
219 });
220 return toAttributes(zoneId, updated);
221 }),
222
223 list: Effect.fn(function* () {
224 // Page Shield policies are zone-scoped
225 // (`/zones/{zone_id}/page_shield/policies`). Fan out across every
226 // zone in the account, exhaustively paginate each, and hydrate into
227 // the same Attributes shape `read` produces. Zones without Page
228 // Shield entitlement reject with the typed `Forbidden` tag — skip
229 // them rather than failing the whole enumeration.
230 const { accountId } = yield* yield* CloudflareEnvironment;
231 const zones = yield* listAllZones(accountId);
232 const rows = yield* Effect.forEach(
233 zones,
234 (zone) =>
235 pageShield.listPolicies.pages({ zoneId: zone.id }).pipe(
236 Stream.runCollect,
237 Effect.map((chunk) =>
238 Array.from(chunk).flatMap((page) =>
239 (page.result ?? []).map((policy) =>
240 toAttributes(zone.id, policy),
241 ),
242 ),
243 ),
244 Effect.catchTag("Forbidden", () => Effect.succeed([])),
245 ),
246 { concurrency: 10 },
247 );
248 return rows.flat();
249 }),
250
251 delete: Effect.fn(function* ({ output }) {
252 // Cloudflare returns 204 even for missing policies, but ride out a
253 // racing delete via the typed tag anyway.
254 yield* pageShield
255 .deletePolicy({ zoneId: output.zoneId, policyId: output.policyId })
256 .pipe(Effect.catchTag("PolicyNotFound", () => Effect.void));
257 }),
258 });
259
260/**
261 * Read a policy by id, mapping "gone" (`PolicyNotFound`, HTTP 404) to
262 * `undefined`.
263 */
264const getPolicy = (zoneId: string, policyId: string) =>
265 pageShield
266 .getPolicy({ zoneId, policyId })
267 .pipe(Effect.catchTag("PolicyNotFound", () => Effect.succeed(undefined)));
268
269const createDescription = (id: string, description: string | undefined) =>
270 Effect.gen(function* () {
271 return description ?? (yield* createPhysicalName({ id, lowercase: true }));
272 });
273
274type ObservedPolicy =
275 | pageShield.GetPolicyResponse
276 | pageShield.CreatePolicyResponse
277 | pageShield.UpdatePolicyResponse
278 | pageShield.ListPoliciesResponse["result"][number];
279
280const toAttributes = (
281 zoneId: string,
282 policy: ObservedPolicy,
283): PolicyAttributes => ({
284 policyId: policy.id,
285 zoneId,
286 // Distilled widens generated string enums to open unions (`string & {}`).
287 action: policy.action as PolicyAction,
288 description: policy.description,
289 enabled: policy.enabled,
290 expression: policy.expression,
291 value: policy.value,
292});