1import * as rules from "@distilled.cloud/cloudflare/rules";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Predicate from "effect/Predicate";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8
9import { isResolved } from "../../Diff.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
14import type { Providers } from "../Providers.ts";
15
16const TypeId = "Cloudflare.Rules.List" as const;
17type TypeId = typeof TypeId;
18
19/**
20 * The type of a list. Each kind supports specific list items: IP addresses,
21 * ASNs, hostnames, or URL redirects. Cannot be changed after creation.
22 */
23export type ListKind = "ip" | "asn" | "hostname" | "redirect";
24
25/**
26 * An item in an `ip` list — an IPv4/IPv6 address or CIDR range
27 * (IPv4 prefixes /8–/32, IPv6 prefixes /4–/64).
28 */
29export type ListIpItem = {
30 /**
31 * An IPv4 address, IPv4 CIDR, IPv6 address, or IPv6 CIDR.
32 */
33 ip: string;
34 /**
35 * An informative summary of the item.
36 */
37 comment?: string;
38};
39
40/**
41 * An item in an `asn` list — an autonomous system number.
42 */
43export type ListAsnItem = {
44 /**
45 * A non-negative 32 bit integer.
46 */
47 asn: number;
48 /**
49 * An informative summary of the item.
50 */
51 comment?: string;
52};
53
54/**
55 * An item in a `hostname` list.
56 */
57export type ListHostnameItem = {
58 /**
59 * Valid characters for hostnames are ASCII(7) letters from a to z, the
60 * digits from 0 to 9, wildcards (*), and the hyphen (-).
61 */
62 hostname: {
63 /**
64 * The hostname to match, e.g. `example.com` or `*.example.com`.
65 */
66 urlHostname: string;
67 /**
68 * Only applies to wildcard hostnames (e.g., *.example.com). When `true`,
69 * only subdomains are blocked. When `false`, both the root domain and
70 * subdomains are blocked.
71 * @default false
72 */
73 excludeExactHostname?: boolean;
74 };
75 /**
76 * An informative summary of the item.
77 */
78 comment?: string;
79};
80
81/**
82 * An item in a `redirect` list — a source/target URL pair used by Bulk
83 * Redirect rules.
84 */
85export type ListRedirectItem = {
86 /**
87 * The definition of the redirect.
88 */
89 redirect: {
90 /**
91 * The source URL to match, e.g. `example.com/old-path`.
92 */
93 sourceUrl: string;
94 /**
95 * The URL to redirect to.
96 */
97 targetUrl: string;
98 /**
99 * Whether the redirect also matches subdomains of the source URL.
100 * @default false
101 */
102 includeSubdomains?: boolean;
103 /**
104 * Whether the redirect target URL keeps the path suffix of the request.
105 * @default false
106 */
107 preservePathSuffix?: boolean;
108 /**
109 * Whether the redirect target URL keeps the query string of the request.
110 * @default false
111 */
112 preserveQueryString?: boolean;
113 /**
114 * The HTTP status code used for the redirect.
115 * @default 301
116 */
117 statusCode?: 301 | 302 | 307 | 308;
118 /**
119 * Whether the redirect also matches subpaths of the source URL.
120 * @default false
121 */
122 subpathMatching?: boolean;
123 };
124 /**
125 * An informative summary of the item.
126 */
127 comment?: string;
128};
129
130/**
131 * An item in an account list. The shape must match the list `kind`:
132 * `{ ip }` for `ip` lists, `{ asn }` for `asn` lists, `{ hostname }` for
133 * `hostname` lists, and `{ redirect }` for `redirect` lists.
134 */
135export type ListItem =
136 | ListIpItem
137 | ListAsnItem
138 | ListHostnameItem
139 | ListRedirectItem;
140
141export type ListProps = {
142 /**
143 * An informative name for the list, used in filter and rule expressions
144 * (e.g. `ip.src in $my_list`). Must contain only letters, numbers, and
145 * underscores (max 50 characters). If omitted, a unique name is generated
146 * from the app, stage, and logical ID. Cannot be changed after creation —
147 * updating this property triggers a replacement.
148 * @default ${app}_${stage}_${id}
149 */
150 name?: string;
151 /**
152 * The type of the list. Each kind supports specific list items
153 * (IP addresses, ASNs, hostnames, or redirects). Cannot be changed after
154 * creation — updating this property triggers a replacement.
155 */
156 kind: ListKind;
157 /**
158 * An informative summary of the list.
159 */
160 description?: string;
161 /**
162 * The full contents of the list. Items are replaced idempotently via the
163 * bulk items operation: the provider diffs the observed items against this
164 * desired set and, on any delta, replaces the entire list contents and
165 * polls the asynchronous bulk operation to completion.
166 * @default []
167 */
168 items?: ListItem[];
169};
170
171export type ListAttributes = {
172 /**
173 * The unique ID of the list.
174 */
175 listId: string;
176 /**
177 * The Cloudflare account the list belongs to.
178 */
179 accountId: string;
180 /**
181 * The name of the list, usable in rule expressions as `$name`.
182 */
183 name: string;
184 /**
185 * The type of the list.
186 */
187 kind: ListKind;
188 /**
189 * An informative summary of the list.
190 */
191 description: string | undefined;
192 /**
193 * The number of items in the list.
194 */
195 numItems: number;
196 /**
197 * The number of filters referencing the list.
198 */
199 numReferencingFilters: number;
200 /**
201 * The RFC 3339 timestamp of when the list was created.
202 */
203 createdOn: string;
204 /**
205 * The RFC 3339 timestamp of when the list was last modified.
206 */
207 modifiedOn: string;
208};
209
210export type List = Resource<
211 TypeId,
212 ListProps,
213 ListAttributes,
214 never,
215 Providers
216>;
217
218/**
219 * A Cloudflare account-level List (Lists API) — a named collection of IP
220 * addresses, ASNs, hostnames, or URL redirects referenced from ruleset
221 * expressions (`ip.src in $my_list`) and Bulk Redirect rules.
222 *
223 * `name` and `kind` are immutable and changing either triggers a
224 * replacement. The list's items are managed as part of the resource: on any
225 * change the full contents are replaced via the asynchronous bulk items
226 * operation, which the provider polls to completion.
227 * ### Creating a List
228 * **Example:** IP list with items
229 * ```typescript
230 * const blocklist = yield* Cloudflare.Rules.List("blocklist", {
231 * kind: "ip",
232 * description: "Known bad actors",
233 * items: [
234 * { ip: "203.0.113.7", comment: "scanner" },
235 * { ip: "198.51.100.0/24" },
236 * ],
237 * });
238 * ```
239 *
240 * **Example:** ASN list with an explicit name
241 * ```typescript
242 * const asns = yield* Cloudflare.Rules.List("bad-asns", {
243 * name: "bad_asns",
244 * kind: "asn",
245 * items: [{ asn: 64496 }, { asn: 64511, comment: "spam network" }],
246 * });
247 * ```
248 *
249 * **Example:** Redirect list for Bulk Redirects
250 * ```typescript
251 * const redirects = yield* Cloudflare.Rules.List("redirects", {
252 * kind: "redirect",
253 * items: [
254 * {
255 * redirect: {
256 * sourceUrl: "example.com/old",
257 * targetUrl: "https://example.com/new",
258 * statusCode: 301,
259 * },
260 * },
261 * ],
262 * });
263 * ```
264 *
265 * ### Referencing a List from rules
266 * **Example:** Use the list name in a Ruleset expression
267 * ```typescript
268 * const list = yield* Cloudflare.Rules.List("blocklist", { kind: "ip" });
269 *
270 * // The stable `name` attribute interpolates into rule expressions:
271 * // `ip.src in $<name>`
272 * const expression = list.name.apply((name) => `ip.src in $${name}`);
273 * ```
274 *
275 * @see https://developers.cloudflare.com/waf/tools/lists/
276 *
277 * @resource
278 * @product Rules
279 * @category Rules & Configuration
280 */
281export const List = Resource<List>(TypeId);
282
283/**
284 * Returns true if the given value is a List resource.
285 */
286export const isList = (value: unknown): value is List =>
287 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
288
289/**
290 * The asynchronous bulk items operation finished in a non-`completed`
291 * state (or never completed within the polling budget).
292 */
293export class ListBulkOperationError extends Data.TaggedError(
294 "ListBulkOperationError",
295)<{
296 readonly operationId: string;
297 readonly status: string;
298 readonly message?: string;
299}> {}
300
301export const ListProvider = () =>
302 Provider.succeed(List, {
303 stables: ["listId", "accountId", "name", "kind", "createdOn"],
304 list: Effect.fn(function* () {
305 const { accountId } = yield* yield* CloudflareEnvironment;
306 return yield* rules.listLists.items({ accountId }).pipe(
307 Stream.map((list) => toAttributes(list, accountId)),
308 Stream.runCollect,
309 Effect.map((chunk) => Array.from(chunk)),
310 );
311 }),
312 diff: Effect.fn(function* ({ id, olds, news, output }) {
313 const { accountId } = yield* yield* CloudflareEnvironment;
314 if (!isResolved(news)) return undefined;
315 if ((output?.accountId ?? accountId) !== accountId) {
316 return { action: "replace" } as const;
317 }
318 // Lists cannot be renamed and the kind is immutable.
319 const oldName = output?.name ?? olds?.name;
320 // Auto-generated names are engine-owned: the deployed name stays
321 // authoritative even if the generator would name this id differently
322 // today. Only an explicit user-provided name can force a replace.
323 const desiredName = news.name ?? oldName;
324 if (oldName !== undefined && desiredName !== oldName) {
325 return { action: "replace" } as const;
326 }
327 const oldKind = output?.kind ?? olds?.kind;
328 if (oldKind !== undefined && news.kind !== oldKind) {
329 // List names are unique per account. When the name is unchanged the
330 // replacement cannot be create-first (the create would collide with
331 // the old list), so delete the old list before creating the new one.
332 return { action: "replace", deleteFirst: true } as const;
333 }
334 return undefined;
335 }),
336 read: Effect.fn(function* ({ id, output, olds }) {
337 const { accountId } = yield* yield* CloudflareEnvironment;
338 const acct = output?.accountId ?? accountId;
339
340 if (output?.listId) {
341 const observed = yield* getListById(acct, output.listId);
342 return observed ? toAttributes(observed, acct) : undefined;
343 }
344 // Cold read — recover from lost state by matching the deterministic
345 // physical name. List names are unique per account, so an exact match
346 // is authoritative.
347 const name = yield* createListName(id, olds?.name);
348 const match = yield* findByName(acct, name);
349 return match ? toAttributes(match, acct) : undefined;
350 }),
351 reconcile: Effect.fn(function* ({ id, news, output }) {
352 const { accountId } = yield* yield* CloudflareEnvironment;
353 // Prefer the deployed name: regenerating would target a different
354 // resource if the generator's output for this id ever drifts.
355 const name = yield* createListName(id, news.name ?? output?.name);
356
357 // Observe — the listId cached on `output` is a hint, not a guarantee:
358 // a missing list falls through to "missing" and ensure recreates.
359 let observed = output?.listId
360 ? yield* getListById(output.accountId ?? accountId, output.listId)
361 : undefined;
362 const acct = observed ? (output?.accountId ?? accountId) : accountId;
363
364 // Ensure — create when missing. A duplicate-name conflict is a race
365 // (or an out-of-band create): adopt the existing list with that name
366 // when its kind matches. When the kind differs the list cannot be
367 // converged in place (kind is immutable and names are unique), so the
368 // stale list is deleted and recreated — this is how a replacement of
369 // an explicitly-named list lands, since a create-first replacement
370 // would always collide on the name.
371 if (!observed) {
372 const create = rules.createList({
373 accountId: acct,
374 name,
375 kind: news.kind,
376 description: news.description,
377 });
378 observed = yield* create.pipe(
379 Effect.catchTag("ListAlreadyExists", (error) =>
380 findByName(acct, name).pipe(
381 Effect.flatMap((match) => {
382 if (!match) return Effect.fail(error);
383 if (match.kind === news.kind) return Effect.succeed(match);
384 return rules
385 .deleteList({ accountId: acct, listId: match.id })
386 .pipe(
387 Effect.catchTag("ListNotFound", () => Effect.void),
388 Effect.flatMap(() => create),
389 );
390 }),
391 ),
392 ),
393 );
394 }
395
396 // Sync description — diff observed cloud state against desired and
397 // skip the API call entirely on a no-op.
398 if ((observed.description ?? undefined) !== news.description) {
399 observed = yield* rules.updateList({
400 accountId: acct,
401 listId: observed.id,
402 description: news.description,
403 });
404 }
405
406 // Sync items — read the observed items fresh from the cloud, compare
407 // against the desired set (keyed on value fields + comment, ignoring
408 // server-assigned ids/timestamps), and on any delta replace the full
409 // contents via the asynchronous bulk PUT, polling it to completion.
410 const desiredItems = news.items ?? [];
411 const observedItems = yield* listAllItems(acct, observed.id);
412 if (!sameItems(observedItems, desiredItems)) {
413 const { operationId } = yield* rules.updateListItem({
414 accountId: acct,
415 listId: observed.id,
416 body: desiredItems,
417 });
418 yield* awaitBulkOperation(acct, operationId);
419 }
420
421 // Return — re-read so eventually-consistent counters (numItems) and
422 // timestamps are as fresh as possible.
423 const final = yield* getListById(acct, observed.id);
424 return toAttributes(final ?? observed, acct);
425 }),
426 delete: Effect.fn(function* ({ output }) {
427 // Cloudflare's delete is itself idempotent (deleting a missing list
428 // succeeds), but tolerate a typed not-found anyway.
429 yield* rules
430 .deleteList({
431 accountId: output.accountId,
432 listId: output.listId,
433 })
434 .pipe(Effect.catchTag("ListNotFound", () => Effect.void));
435 }),
436 });
437
438type ObservedList = rules.GetListResponse;
439
440/**
441 * List names only allow letters, numbers, and underscores (max 50 chars), so
442 * the generated physical name swaps the default hyphen delimiter for an
443 * underscore.
444 */
445const createListName = (id: string, name: string | undefined) =>
446 Effect.gen(function* () {
447 if (name !== undefined) return name;
448 const generated = yield* createPhysicalName({
449 id,
450 lowercase: true,
451 delimiter: "_",
452 maxLength: 50,
453 });
454 return generated.replaceAll("-", "_");
455 });
456
457/**
458 * Read a list by id, mapping "gone" (`ListNotFound`, Cloudflare error code
459 * 10001) to `undefined`.
460 */
461const getListById = (accountId: string, listId: string) =>
462 rules
463 .getList({ accountId, listId })
464 .pipe(Effect.catchTag("ListNotFound", () => Effect.succeed(undefined)));
465
466/**
467 * Find a list by exact name. List names are unique per account.
468 */
469const findByName = (accountId: string, name: string) =>
470 rules.listLists.items({ accountId }).pipe(
471 Stream.filter((list) => list.name === name),
472 Stream.runHead,
473 Effect.map(Option.getOrUndefined),
474 );
475
476/**
477 * Read the full observed contents of a list (all pages).
478 */
479const listAllItems = (accountId: string, listId: string) =>
480 rules.listListItems.items({ accountId, listId }).pipe(
481 Stream.runCollect,
482 Effect.map((chunk) => Array.from(chunk)),
483 );
484
485type ComparableItem = ListItem | rules.GetListItemResponse;
486
487/**
488 * Canonicalize an item (desired prop or observed response) into a stable
489 * string key: server-assigned ids/timestamps are ignored and optional fields
490 * are normalized to their API defaults so omitted props compare equal to the
491 * defaults the API materializes.
492 */
493const canonicalItem = (item: ComparableItem): string => {
494 const comment =
495 "comment" in item && item.comment != null && item.comment !== ""
496 ? item.comment
497 : undefined;
498 if ("ip" in item) {
499 return JSON.stringify({ ip: item.ip, comment });
500 }
501 if ("asn" in item) {
502 return JSON.stringify({ asn: item.asn, comment });
503 }
504 if ("hostname" in item) {
505 return JSON.stringify({
506 hostname: {
507 urlHostname: item.hostname.urlHostname,
508 excludeExactHostname: item.hostname.excludeExactHostname ?? false,
509 },
510 comment,
511 });
512 }
513 return JSON.stringify({
514 redirect: {
515 sourceUrl: item.redirect.sourceUrl,
516 targetUrl: item.redirect.targetUrl,
517 includeSubdomains: item.redirect.includeSubdomains ?? false,
518 preservePathSuffix: item.redirect.preservePathSuffix ?? false,
519 preserveQueryString: item.redirect.preserveQueryString ?? false,
520 statusCode: item.redirect.statusCode ?? 301,
521 subpathMatching: item.redirect.subpathMatching ?? false,
522 },
523 comment,
524 });
525};
526
527/**
528 * Compare observed and desired items as multisets of canonical keys.
529 */
530const sameItems = (
531 observed: readonly ComparableItem[],
532 desired: readonly ComparableItem[],
533) => {
534 if (observed.length !== desired.length) return false;
535 const a = observed.map(canonicalItem).sort();
536 const b = desired.map(canonicalItem).sort();
537 return a.every((key, i) => key === b[i]);
538};
539
540/**
541 * Poll an asynchronous bulk items operation until it reaches a terminal
542 * state, failing with a typed error when it does not complete successfully.
543 */
544const awaitBulkOperation = (accountId: string, operationId: string) =>
545 Effect.gen(function* () {
546 const operation = yield* rules
547 .getListBulkOperation({ accountId, operationId })
548 .pipe(
549 Effect.repeat({
550 schedule: Schedule.spaced("2 seconds"),
551 until: (op) => op.status === "completed" || op.status === "failed",
552 times: 90,
553 }),
554 );
555 if (operation.status !== "completed") {
556 return yield* Effect.fail(
557 new ListBulkOperationError({
558 operationId,
559 status: operation.status,
560 message: "error" in operation ? operation.error : undefined,
561 }),
562 );
563 }
564 });
565
566const toAttributes = (
567 list: ObservedList,
568 accountId: string,
569): ListAttributes => ({
570 listId: list.id,
571 accountId,
572 name: list.name,
573 // Distilled widens generated string enums to open unions (`string & {}`).
574 kind: list.kind as ListKind,
575 description: list.description ?? undefined,
576 numItems: list.numItems,
577 numReferencingFilters: list.numReferencingFilters,
578 createdOn: list.createdOn,
579 modifiedOn: list.modifiedOn,
580});