1import * as firewall from "@distilled.cloud/cloudflare/firewall";
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 * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11import { listAllZones } from "../Zone/lookup.ts";
12
13const LockdownTypeId = "Cloudflare.Firewall.Lockdown" as const;
14type LockdownTypeId = typeof LockdownTypeId;
15
16/**
17 * A single allow-list entry of a Zone Lockdown rule: a single IP address
18 * (`ip`) or a CIDR range (`ip_range`) that is allowed to access the
19 * locked-down URLs.
20 */
21export interface LockdownConfiguration {
22 /**
23 * Whether `value` is a single IP address (`ip`) or a CIDR range
24 * (`ip_range`).
25 */
26 target: "ip" | "ip_range";
27 /**
28 * The IP address (e.g. `198.51.100.4`) or CIDR range
29 * (e.g. `203.0.113.0/24`) to allow.
30 */
31 value: string;
32}
33
34export interface LockdownProps {
35 /**
36 * Zone the lockdown rule applies to.
37 *
38 * Stable — moving a rule between zones triggers a replacement.
39 */
40 zoneId: string;
41 /**
42 * The URLs to lock down. Each entry is escaped before use, so only simple
43 * wildcard patterns are supported (e.g. `shop.example.com/admin*`).
44 *
45 * Mutable — updated in place via PUT.
46 */
47 urls: string[];
48 /**
49 * The IP addresses and CIDR ranges that are allowed to access the URLs.
50 * Everyone else is blocked.
51 *
52 * Mutable — updated in place via PUT.
53 */
54 configurations: LockdownConfiguration[];
55 /**
56 * An informative summary of the rule. Sanitized server-side (HTML tags
57 * are removed).
58 *
59 * Mutable — updated in place via PUT.
60 */
61 description?: string;
62 /**
63 * When true, the rule is disabled without being deleted.
64 *
65 * Mutable — updated in place via PUT.
66 *
67 * @default false
68 */
69 paused?: boolean;
70 /**
71 * Processing order — a lower number indicates higher priority. Rules
72 * without a priority are processed before rules with one.
73 *
74 * Mutable — updated in place via PUT.
75 */
76 priority?: number;
77}
78
79export interface LockdownAttributes {
80 /** Cloudflare-assigned identifier of the Zone Lockdown rule. */
81 lockdownId: string;
82 /** Zone the rule belongs to. */
83 zoneId: string;
84 /** The locked-down URL patterns. */
85 urls: string[];
86 /** The allowed IP addresses and CIDR ranges. */
87 configurations: LockdownConfiguration[];
88 /** The rule's informative summary, if set. */
89 description: string | undefined;
90 /** Whether the rule is currently paused. */
91 paused: boolean;
92 /** The rule's processing priority, if set. */
93 priority: number | undefined;
94 /** ISO8601 creation timestamp. */
95 createdOn: string;
96 /** ISO8601 last-modified timestamp. */
97 modifiedOn: string;
98}
99
100export type Lockdown = Resource<
101 LockdownTypeId,
102 LockdownProps,
103 LockdownAttributes,
104 never,
105 Providers
106>;
107
108/**
109 * A Cloudflare Zone Lockdown rule — restrict one or more URL patterns on a
110 * zone so that only an allow-list of IP addresses and CIDR ranges can access
111 * them. Every other visitor receives an "Access Denied" page.
112 *
113 * Everything about a lockdown rule is mutable in place: `urls`,
114 * `configurations`, `description`, `paused`, and `priority` are all updated
115 * via PUT without replacing the rule. Only moving the rule to a different
116 * zone triggers a replacement.
117 *
118 * Zone Lockdown is available on Pro plans and above, with per-plan rule
119 * quotas (Pro: 3, Business: 10, Enterprise: 200). Cloudflare rejects a
120 * second rule covering the same URLs with a duplicate error, so a rule's
121 * URL set acts as its identity within a zone.
122 *
123 * Safety: lockdown rules carry no ownership markers. When there is no prior
124 * state, `read` scans the zone for an existing rule with the same URL set
125 * and reports it as `Unowned`, so the engine refuses to take it over unless
126 * `--adopt` (or `adopt(true)`) is set.
127 * ### Locking down a URL
128 * **Example:** Allow a single office IP to reach an admin panel
129 * ```typescript
130 * yield* Cloudflare.Firewall.Lockdown("AdminLockdown", {
131 * zoneId: zone.zoneId,
132 * urls: ["shop.example.com/admin*"],
133 * configurations: [{ target: "ip", value: "198.51.100.4" }],
134 * description: "only the office can reach /admin",
135 * });
136 * ```
137 *
138 * **Example:** Allow a CIDR range across multiple URLs
139 * ```typescript
140 * yield* Cloudflare.Firewall.Lockdown("StaffOnly", {
141 * zoneId: zone.zoneId,
142 * urls: ["example.com/internal*", "example.com/staging*"],
143 * configurations: [
144 * { target: "ip_range", value: "203.0.113.0/24" },
145 * { target: "ip", value: "198.51.100.4" },
146 * ],
147 * });
148 * ```
149 *
150 * ### Pausing a rule
151 * **Example:** Temporarily disable a lockdown without deleting it
152 * ```typescript
153 * yield* Cloudflare.Firewall.Lockdown("AdminLockdown", {
154 * zoneId: zone.zoneId,
155 * urls: ["shop.example.com/admin*"],
156 * configurations: [{ target: "ip", value: "198.51.100.4" }],
157 * paused: true,
158 * });
159 * ```
160 *
161 * @see https://developers.cloudflare.com/waf/tools/zone-lockdown/
162 *
163 * @resource
164 * @product Firewall
165 * @category Application Security
166 */
167export const Lockdown = Resource<Lockdown>(LockdownTypeId);
168
169/**
170 * Returns true if the given value is a Lockdown resource.
171 */
172export const isLockdown = (value: unknown): value is Lockdown =>
173 Predicate.hasProperty(value, "Type") && value.Type === LockdownTypeId;
174
175export const LockdownProvider = () =>
176 Provider.succeed(Lockdown, {
177 stables: ["lockdownId", "zoneId", "createdOn"],
178
179 diff: Effect.fn(function* ({ olds = {}, news }) {
180 const o = olds as LockdownProps;
181 const n = news as LockdownProps;
182 // No prior props to compare against — let the engine decide.
183 if (o.zoneId === undefined) return undefined;
184 // zoneId is Input<string>; compare only once both are concrete.
185 if (
186 typeof o.zoneId === "string" &&
187 typeof n.zoneId === "string" &&
188 o.zoneId !== n.zoneId
189 ) {
190 return { action: "replace" } as const;
191 }
192 return undefined;
193 }),
194
195 read: Effect.fn(function* ({ output, olds }) {
196 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
197 if (zoneId === undefined) return undefined;
198
199 // Owned path: refresh by our persisted lockdown id.
200 if (output?.lockdownId) {
201 const observed = yield* getLockdown(zoneId, output.lockdownId);
202 if (observed) return toAttributes(observed, zoneId);
203 }
204
205 // Adoption path: a rule covering the same URLs may already exist —
206 // Cloudflare rejects overlapping duplicates, so the URL set is the
207 // rule's identity within the zone. Lockdown rules carry no ownership
208 // markers, so brand a match `Unowned` and let the engine gate
209 // takeover behind the adopt policy.
210 const urls = output?.urls ?? olds?.urls;
211 if (urls) {
212 const observed = yield* findByUrls(zoneId, urls);
213 if (observed) return Unowned(toAttributes(observed, zoneId));
214 }
215 return undefined;
216 }),
217
218 reconcile: Effect.fn(function* ({ news, output }) {
219 // Inputs have been resolved to concrete strings by Plan.
220 const zoneId = news.zoneId as string;
221
222 // 1. Observe — the lockdown id cached on `output` is a hint, not a
223 // guarantee: a missing rule falls through to the URL scan and
224 // then to create.
225 let observed = output?.lockdownId
226 ? yield* getLockdown(zoneId, output.lockdownId)
227 : undefined;
228
229 // 2. Fall back to scanning the zone for a rule with the same URL
230 // set. Ownership has already been verified upstream — `read`
231 // reports existing rules as `Unowned` and the engine gates
232 // takeover behind the adopt policy before reconcile ever runs.
233 if (!observed) {
234 observed = yield* findByUrls(zoneId, news.urls);
235 }
236
237 // 3. Ensure — create when missing. A concurrent create surfaces as
238 // `DuplicateLockdown` (Cloudflare code 10009): converge by
239 // re-scanning for the rule that won the race.
240 if (!observed) {
241 observed = yield* firewall
242 .createLockdown({
243 zoneId,
244 urls: news.urls,
245 configurations: news.configurations,
246 description: news.description,
247 paused: news.paused,
248 priority: news.priority,
249 })
250 .pipe(
251 Effect.catchTag("DuplicateLockdown", (error) =>
252 findByUrls(zoneId, news.urls).pipe(
253 Effect.flatMap((existing) =>
254 existing ? Effect.succeed(existing) : Effect.fail(error),
255 ),
256 ),
257 ),
258 );
259 }
260
261 // 4. Sync — diff observed cloud state against desired; skip the PUT
262 // entirely on a no-op. Undefined optional props are treated as
263 // "no constraint" so adoption doesn't clobber foreign settings.
264 const dirty =
265 !sameStringSet(observed.urls, news.urls) ||
266 !sameConfigurationSet(observed.configurations, news.configurations) ||
267 (news.description !== undefined &&
268 (observed.description ?? "") !== news.description) ||
269 (news.paused !== undefined && observed.paused !== news.paused) ||
270 (news.priority !== undefined &&
271 (observed.priority ?? undefined) !== news.priority);
272 if (dirty) {
273 observed = yield* firewall.updateLockdown({
274 zoneId,
275 lockDownsId: observed.id,
276 urls: news.urls,
277 configurations: news.configurations,
278 description: news.description,
279 paused: news.paused,
280 priority: news.priority,
281 });
282 }
283
284 return toAttributes(observed, zoneId);
285 }),
286
287 // Zone Lockdown rules are zone-scoped: there is no account-wide list, so
288 // enumerate every zone and exhaustively paginate its lockdown rules,
289 // hydrating each into the same Attributes shape `read` returns. Zone
290 // Lockdown is a Pro+ feature, so plan-gated zones reject the route with a
291 // typed `Forbidden` — skip those rather than failing the whole list.
292 list: Effect.fn(function* () {
293 const { accountId } = yield* yield* CloudflareEnvironment;
294 const zones = yield* listAllZones(accountId);
295 const rows = yield* Effect.forEach(
296 zones,
297 (zone) =>
298 firewall.listLockdowns.items({ zoneId: zone.id }).pipe(
299 Stream.runCollect,
300 Effect.map((chunk) =>
301 Array.from(chunk).map((rule) => toAttributes(rule, zone.id)),
302 ),
303 Effect.catchTag("Forbidden", () => Effect.succeed([])),
304 ),
305 { concurrency: 10 },
306 );
307 return rows.flat();
308 }),
309
310 delete: Effect.fn(function* ({ output }) {
311 // Cloudflare's DELETE is naturally idempotent (an already-gone rule
312 // answers HTTP 200 echoing the id) — the typed catch covers the rare
313 // not-found envelope race.
314 yield* firewall
315 .deleteLockdown({
316 zoneId: output.zoneId,
317 lockDownsId: output.lockdownId,
318 })
319 .pipe(Effect.catchTag("LockdownNotFound", () => Effect.void));
320 }),
321 });
322
323// ---------------------------------------------------------------------------
324// API helpers
325// ---------------------------------------------------------------------------
326
327type ObservedLockdown = firewall.GetLockdownResponse;
328
329/**
330 * Read a lockdown rule by id, mapping "gone" (`LockdownNotFound`, Cloudflare
331 * error code 10001 `zonelockdown.api.not_found`) to `undefined`.
332 */
333const getLockdown = (zoneId: string, lockdownId: string) =>
334 firewall.getLockdown({ zoneId, lockDownsId: lockdownId }).pipe(
335 Effect.map((rule): ObservedLockdown | undefined => rule),
336 Effect.catchTag("LockdownNotFound", () => Effect.succeed(undefined)),
337 );
338
339/**
340 * Find a lockdown rule by exact URL set within the zone. Cloudflare rejects
341 * a second rule covering the same URLs as `zonelockdown.api.duplicate_of_existing`,
342 * so the URL set acts as a rule's identity.
343 */
344const findByUrls = (zoneId: string, urls: readonly string[]) =>
345 firewall.listLockdowns.items({ zoneId }).pipe(
346 Stream.runCollect,
347 Effect.map((chunk) =>
348 Array.from(chunk).find((rule): rule is ObservedLockdown =>
349 sameStringSet(rule.urls, urls),
350 ),
351 ),
352 );
353
354const sameStringSet = (a: readonly string[], b: readonly string[]) =>
355 a.length === b.length &&
356 [...a].sort().join("\n") === [...b].sort().join("\n");
357
358const configurationKey = (c: {
359 target?: string | null;
360 value?: string | null;
361}) => `${c.target ?? ""}=${c.value ?? ""}`;
362
363const sameConfigurationSet = (
364 a: readonly { target?: string | null; value?: string | null }[],
365 b: readonly LockdownConfiguration[],
366) =>
367 a.length === b.length &&
368 a.map(configurationKey).sort().join("\n") ===
369 b.map(configurationKey).sort().join("\n");
370
371const toAttributes = (
372 rule: ObservedLockdown,
373 zoneId: string,
374): LockdownAttributes => ({
375 lockdownId: rule.id,
376 zoneId,
377 urls: [...rule.urls],
378 configurations: rule.configurations.map((c) => ({
379 // Distilled types each union variant's target/value as optional —
380 // Cloudflare always echoes both for a persisted rule.
381 target: (c.target ?? "ip") as LockdownConfiguration["target"],
382 value: c.value ?? "",
383 })),
384 description: rule.description ?? undefined,
385 paused: rule.paused,
386 priority: rule.priority ?? undefined,
387 createdOn: rule.createdOn,
388 modifiedOn: rule.modifiedOn,
389});