1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
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 { arrayEqualsUnordered } from "../../Util/equal.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14
15const TypeId = "Cloudflare.Gateway.List" as const;
16type TypeId = typeof TypeId;
17
18/**
19 * The kind of values a Gateway list holds. Immutable — changing the type
20 * triggers a replacement.
21 */
22export type ListType =
23 | "SERIAL"
24 | "URL"
25 | "DOMAIN"
26 | "EMAIL"
27 | "IP"
28 | "CATEGORY"
29 | "LOCATION"
30 | "DEVICE"
31 | "AAGUID";
32
33/**
34 * A single entry in a Gateway list.
35 */
36export interface ListItem {
37 /**
38 * The item's value — a domain, URL, IP, email, serial number, etc.
39 * depending on the list's `type`.
40 */
41 value: string;
42 /**
43 * Optional free-form description of the item.
44 */
45 description?: string;
46}
47
48export interface ListProps {
49 /**
50 * Display name for the list. Used as a stable identifier so the provider
51 * can locate the list by name during adoption / state recovery. If
52 * omitted, a unique name is generated from the app, stage, and logical ID.
53 *
54 * @default ${app}-${stage}-${id}
55 */
56 name?: string;
57 /**
58 * The kind of values the list holds (DOMAIN, IP, URL, EMAIL, SERIAL,
59 * CATEGORY, LOCATION, DEVICE, AAGUID). Immutable — changing the type
60 * triggers a replacement.
61 */
62 type: ListType;
63 /**
64 * Free-form description of the list. Mutable.
65 *
66 * @default ""
67 */
68 description?: string;
69 /**
70 * Entries in the list. Reconciled as a full set via PUT — items present
71 * here and missing in the cloud are added; extra cloud items are removed.
72 *
73 * @default []
74 */
75 items?: ListItem[];
76}
77
78export interface ListAttributes {
79 /** UUID of the list, assigned by Cloudflare. */
80 listId: string;
81 /** Cloudflare account that owns the list. */
82 accountId: string;
83 /** Display name of the list. */
84 name: string;
85 /** The kind of values the list holds. */
86 type: ListType;
87 /** Description of the list. */
88 description: string;
89 /** Current entries in the list. */
90 items: ListItem[];
91 /** Number of entries in the list. */
92 count: number;
93 /** ISO8601 creation timestamp. */
94 createdAt: string | undefined;
95 /** ISO8601 last-update timestamp. */
96 updatedAt: string | undefined;
97}
98
99export type List = Resource<
100 TypeId,
101 ListProps,
102 ListAttributes,
103 never,
104 Providers
105>;
106
107/**
108 * A Cloudflare Zero Trust Gateway list — a named set of domains, IPs,
109 * URLs, emails, serial numbers, or device IDs referenced from Gateway
110 * rule wirefilter expressions by UUID (`$<listId>`).
111 *
112 * The list's `type` is immutable (changing it replaces the list); name,
113 * description, and items all converge in place. Items are managed as a
114 * full set — the provider PUTs the complete desired item set and removes
115 * anything not declared.
116 * ### Creating a List
117 * **Example:** Domain list
118 * ```typescript
119 * const blocked = yield* Cloudflare.Gateway.List("BlockedDomains", {
120 * type: "DOMAIN",
121 * description: "domains blocked org-wide",
122 * items: [
123 * { value: "badsite.example.com" },
124 * { value: "malware.example.net", description: "known C2" },
125 * ],
126 * });
127 * ```
128 *
129 * **Example:** IP list
130 * ```typescript
131 * const egress = yield* Cloudflare.Gateway.List("OfficeEgress", {
132 * type: "IP",
133 * items: [{ value: "203.0.113.0/24" }],
134 * });
135 * ```
136 *
137 * ### Referencing from a Gateway Rule
138 * **Example:** Block DNS lookups for every domain in the list
139 * ```typescript
140 * yield* Cloudflare.Gateway.Rule("BlockListedDomains", {
141 * action: "block",
142 * filters: ["dns"],
143 * traffic: `any(dns.domains[*] in $${blocked.listId})`,
144 * });
145 * ```
146 *
147 * @see https://developers.cloudflare.com/cloudflare-one/policies/gateway/lists/
148 *
149 * @resource
150 * @product Gateway
151 * @category Cloudflare One (Zero Trust)
152 */
153export const List = Resource<List>(TypeId);
154
155/**
156 * Returns true if the given value is a List resource.
157 */
158export const isList = (value: unknown): value is List =>
159 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
160
161export const ListProvider = () =>
162 Provider.succeed(List, {
163 stables: ["listId", "accountId", "type", "createdAt"],
164
165 list: Effect.fn(function* () {
166 const { accountId } = yield* yield* CloudflareEnvironment;
167 const lists = yield* zeroTrust.listGatewayLists.items({ accountId }).pipe(
168 Stream.runCollect,
169 Effect.map((chunk) => Array.from(chunk)),
170 );
171 return lists.map((list) => toAttributes(list, accountId));
172 }),
173
174 diff: Effect.fn(function* ({ olds = {}, news, output }) {
175 const { accountId } = yield* yield* CloudflareEnvironment;
176 if (!isResolved(news)) return undefined;
177 if ((output?.accountId ?? accountId) !== accountId) {
178 return { action: "replace" } as const;
179 }
180 // The list type is immutable on Cloudflare's side.
181 const oldType = output?.type ?? (olds as ListProps).type;
182 if (oldType !== undefined && oldType !== news.type) {
183 return { action: "replace" } as const;
184 }
185 return undefined;
186 }),
187
188 read: Effect.fn(function* ({ id, output, olds }) {
189 const { accountId } = yield* yield* CloudflareEnvironment;
190 const acct = output?.accountId ?? accountId;
191
192 // Owned path — refresh by the cached list id.
193 if (output?.listId) {
194 const observed = yield* getList(acct, output.listId);
195 if (observed) return toAttributes(observed, acct);
196 }
197
198 // Cold read — locate by deterministic name. Gateway lists carry no
199 // ownership markers, so report the match as Unowned to gate adoption.
200 const name = yield* resolveName(id, olds?.name ?? output?.name);
201 const match = yield* findByName(acct, name);
202 if (match?.id) {
203 const observed = yield* getList(acct, match.id);
204 if (observed) return Unowned(toAttributes(observed, acct));
205 }
206 return undefined;
207 }),
208
209 reconcile: Effect.fn(function* ({ id, news, output }) {
210 const { accountId } = yield* yield* CloudflareEnvironment;
211 const name = yield* resolveName(id, news.name);
212 const desiredItems = news.items ?? [];
213
214 // 1. Observe — the cached id is a hint, not a guarantee; fall back
215 // to a name scan so out-of-band deletes / lost state converge.
216 let observed = output?.listId
217 ? yield* getList(accountId, output.listId)
218 : undefined;
219 if (!observed) {
220 const match = yield* findByName(accountId, name);
221 if (match?.id) observed = yield* getList(accountId, match.id);
222 }
223
224 // 2. Ensure — create with the full desired body when missing. Names
225 // are not unique on Cloudflare's side, so there is no
226 // AlreadyExists race to tolerate.
227 if (!observed) {
228 const created = yield* zeroTrust.createGatewayList({
229 accountId,
230 name,
231 type: news.type,
232 description: news.description,
233 items: desiredItems,
234 });
235 if (!created.id) {
236 return yield* Effect.fail(
237 new Error("Cloudflare did not return an id for the Gateway list"),
238 );
239 }
240 // Create echoes items without count — re-read for a full shape.
241 const fresh = yield* getList(accountId, created.id);
242 return toAttributes(
243 fresh ?? { ...created, count: desiredItems.length },
244 accountId,
245 );
246 }
247
248 // 3. Sync — diff observed name/description/items against desired;
249 // the update API is a PUT of the full mutable state, so skip the
250 // call entirely on a no-op. Items compare as an unordered set of
251 // values (the API may reorder).
252 const observedItems = (observed.items ?? []).map((i) => ({
253 value: i.value ?? "",
254 ...(i.description != null ? { description: i.description } : {}),
255 }));
256 const dirty =
257 observed.name !== name ||
258 (news.description !== undefined &&
259 (observed.description ?? "") !== news.description) ||
260 !sameItems(observedItems, desiredItems);
261 if (dirty) {
262 yield* zeroTrust.updateGatewayList({
263 accountId,
264 listId: observed.id!,
265 name,
266 description: news.description ?? observed.description ?? undefined,
267 items: desiredItems,
268 });
269 // PUT echoes count but not items — re-read for the full shape.
270 const fresh = yield* getList(accountId, observed.id!);
271 if (fresh) return toAttributes(fresh, accountId);
272 }
273
274 return toAttributes(observed, accountId);
275 }),
276
277 delete: Effect.fn(function* ({ output }) {
278 // Lists still referenced by Gateway rules can fail deletion — rules
279 // that reference the list id as an Input get correct destroy
280 // ordering. A missing list (GatewayListNotFound, code 2218) means
281 // we're done.
282 yield* zeroTrust
283 .deleteGatewayList({
284 accountId: output.accountId,
285 listId: output.listId,
286 })
287 .pipe(Effect.catchTag("GatewayListNotFound", () => Effect.void));
288 }),
289 });
290
291type ObservedList = zeroTrust.GetGatewayListResponse;
292
293/**
294 * Read a list by id, mapping "gone" (`GatewayListNotFound`, Cloudflare
295 * error code 2218) to `undefined`.
296 */
297const getList = (accountId: string, listId: string) =>
298 zeroTrust.getGatewayList({ accountId, listId }).pipe(
299 Effect.map((l): ObservedList | undefined => l),
300 Effect.catchTag("GatewayListNotFound", () => Effect.succeed(undefined)),
301 );
302
303/**
304 * Find a list by exact name. Names are not unique on Cloudflare's side;
305 * pick the oldest match for determinism.
306 */
307const findByName = (accountId: string, name: string) =>
308 zeroTrust.listGatewayLists.items({ accountId }).pipe(
309 Stream.filter((l) => l.name === name),
310 Stream.runCollect,
311 Effect.map((chunk) =>
312 Array.from(chunk)
313 .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""))
314 .at(0),
315 ),
316 );
317
318const resolveName = (id: string, name: string | undefined) =>
319 Effect.gen(function* () {
320 if (name) return name;
321 return yield* createPhysicalName({ id, lowercase: true });
322 });
323
324const sameItems = (
325 observed: ReadonlyArray<ListItem>,
326 desired: ReadonlyArray<ListItem>,
327): boolean =>
328 arrayEqualsUnordered(
329 observed.map((i) => `${i.value}${i.description ?? ""}`),
330 desired.map((i) => `${i.value}${i.description ?? ""}`),
331 );
332
333const toAttributes = (
334 list: ObservedList & { count?: number | null },
335 accountId: string,
336): ListAttributes => ({
337 listId: list.id ?? "",
338 accountId,
339 name: list.name ?? "",
340 type: (list.type ?? "DOMAIN") as ListType,
341 description: list.description ?? "",
342 items: (list.items ?? []).map((i) => ({
343 value: i.value ?? "",
344 ...(i.description != null ? { description: i.description } : {}),
345 })),
346 count: list.count ?? (list.items ?? []).length,
347 createdAt: list.createdAt ?? undefined,
348 updatedAt: list.updatedAt ?? undefined,
349});