1import * as rulesets from "@distilled.cloud/cloudflare/rulesets";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { deepEqual, isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Providers } from "../Providers.ts";
10import type { Attributes, Zone } from "../Zone/index.ts";
11import { listAllZones } from "../Zone/lookup.ts";
12
13export type Phase = rulesets.CreateRulesetForZoneRequest["phase"];
14export type Rule = NonNullable<rulesets.PutPhasForZoneRequest["rules"]>[number];
15export type OutputRule = Omit<
16 NonNullable<rulesets.GetPhasResponse["rules"]>[number],
17 "lastUpdated" | "version"
18>;
19
20export type RulesetProps = {
21 /**
22 * Zone to apply the ruleset to. Pass a `Cloudflare.Zone.Zone`.
23 */
24 zone: Zone;
25 /**
26 * Ruleset phase entrypoint to own.
27 */
28 phase: Phase;
29 /**
30 * Rules to apply to the phase entrypoint.
31 */
32 rules: Rule[];
33 /**
34 * Human-readable name for the ruleset.
35 * @default ${app}-${stage}-${id}
36 */
37 name?: string;
38 /**
39 * Description for the ruleset.
40 */
41 description?: string;
42};
43
44export type Kind = "managed" | "custom" | "root" | "zone" | (string & {});
45
46export type Ruleset = Resource<
47 "Cloudflare.Ruleset.Ruleset",
48 RulesetProps,
49 {
50 /** The unique ID of the ruleset (Cloudflare `id`). */
51 rulesetId: string;
52 /**
53 * Zone the ruleset phase entrypoint belongs to. Alchemy-flattened
54 * identifier — not part of Cloudflare's phase-entrypoint response.
55 */
56 zoneId: string;
57 /** The kind of the ruleset. */
58 kind: Kind;
59 /** The human-readable name of the ruleset. */
60 name: string;
61 /** The phase of the ruleset. */
62 phase: Phase;
63 /** An informative description of the ruleset. */
64 description: string | undefined;
65 /** The list of rules in the ruleset. */
66 rules: OutputRule[];
67 /** The timestamp of when the ruleset was last modified. */
68 lastUpdated: string;
69 /** The version of the ruleset. */
70 version: string;
71 },
72 never,
73 Providers
74>;
75
76/**
77 * A Cloudflare Ruleset phase entrypoint for a zone.
78 *
79 * This resource owns the entire ruleset for a phase entrypoint. Rules managed
80 * elsewhere in the same phase can be overwritten on deploy.
81 * ### WAF Rules
82 * **Example:** Block probes in the custom firewall phase
83 * ```typescript
84 * const zone = yield* Cloudflare.Zone.Zone("MyZone", { name: "example.com" });
85 * const waf = yield* Cloudflare.Ruleset.Ruleset("WafRules", {
86 * zone,
87 * phase: "http_request_firewall_custom",
88 * rules: [
89 * {
90 * description: "Block exploit probes",
91 * expression: `lower(http.request.uri.path) contains "/.env"`,
92 * action: "block",
93 * },
94 * ],
95 * });
96 * ```
97 *
98 * @resource
99 * @product Rulesets
100 * @category Rules & Configuration
101 */
102export const Ruleset = Resource<Ruleset>("Cloudflare.Ruleset.Ruleset", {
103 aliases: ["Cloudflare.Ruleset"],
104})({});
105
106export const RulesetProvider = () =>
107 Provider.succeed(Ruleset, {
108 stables: ["zoneId", "phase"],
109 diff: Effect.fn(function* ({ id, olds, news, output }) {
110 if (!isResolved(news)) return undefined;
111 const desiredZoneId = zoneIdOf(news.zone);
112
113 // The desired zone id may still be an unresolved Output (e.g. the
114 // zone is being created in the same deploy). Only make a zone-change
115 // replacement decision once we have a concrete id.
116 if (desiredZoneId !== undefined) {
117 if (output?.zoneId && desiredZoneId !== output.zoneId) {
118 return { action: "replace" } as const;
119 }
120 const oldZoneId = olds.zone ? zoneIdOf(olds.zone) : undefined;
121 if (oldZoneId !== undefined && oldZoneId !== desiredZoneId) {
122 return { action: "replace" } as const;
123 }
124 }
125 if (olds.phase !== news.phase) {
126 return { action: "replace" } as const;
127 }
128
129 const oldName = output?.name ?? (yield* createRulesetName(id, olds.name));
130 // Auto-generated names are engine-owned: the deployed name stays
131 // authoritative even if the generator would name this id differently
132 // today. Only an explicit user-provided name can force a rename.
133 const name = news.name ?? oldName;
134 if (
135 oldName !== name ||
136 olds.description !== news.description ||
137 !deepEqual(olds.rules, news.rules)
138 ) {
139 return { action: "update" } as const;
140 }
141 }),
142 reconcile: Effect.fn(function* ({ id, news, output }) {
143 const zoneId = output?.zoneId ?? zoneIdOf(news.zone);
144 if (zoneId === undefined) {
145 return yield* Effect.fail(
146 new Error("Cloudflare Ruleset: zone id is not resolved"),
147 );
148 }
149 const name = yield* createRulesetName(id, news.name ?? output?.name);
150 const ruleset = yield* rulesets.putPhasForZone({
151 zoneId,
152 rulesetPhase: news.phase,
153 name,
154 description: news.description,
155 rules: news.rules,
156 });
157 return toRulesetAttributes(zoneId, ruleset);
158 }),
159 delete: Effect.fn(function* ({ olds, output }) {
160 // This resource owns the entire phase entrypoint, so destroy removes
161 // the entrypoint ruleset itself (emptying the rules would leave an
162 // inert-but-listed entrypoint behind on the zone forever). Observe
163 // first so the delete is idempotent and never acts on a stale id.
164 const entrypoint = yield* rulesets
165 .getPhasForZone({
166 zoneId: output.zoneId,
167 rulesetPhase: output.phase ?? olds.phase,
168 })
169 .pipe(
170 Effect.catchTag("RulesetNotFound", () => Effect.succeed(undefined)),
171 );
172 if (entrypoint === undefined) return;
173 yield* rulesets
174 .deleteRulesetForZone({
175 zoneId: output.zoneId,
176 rulesetId: entrypoint.id,
177 })
178 .pipe(Effect.catchTag("RulesetNotFound", () => Effect.void));
179 }),
180 read: Effect.fn(function* ({ olds, output }) {
181 const zoneId = output?.zoneId ?? zoneIdOf(olds.zone);
182 if (zoneId === undefined) return undefined;
183 return yield* rulesets
184 .getPhasForZone({
185 zoneId,
186 rulesetPhase: output?.phase ?? olds.phase,
187 })
188 .pipe(
189 Effect.map((ruleset) => toRulesetAttributes(zoneId, ruleset)),
190 Effect.catchTag("RulesetNotFound", () => Effect.succeed(undefined)),
191 );
192 }),
193 // A `Ruleset` is a zone phase entrypoint (kind: "zone"). There is no
194 // account-wide enumeration for entrypoints, so fan out over every zone
195 // via `listAllZones`, list that zone's rulesets, keep the entrypoints,
196 // and hydrate each into the full Attributes shape (the list response
197 // omits `rules`) via `getPhasForZone`.
198 list: Effect.fn(function* () {
199 const { accountId } = yield* yield* CloudflareEnvironment;
200 const zones = yield* listAllZones(accountId);
201 const rows = yield* Effect.forEach(
202 zones,
203 (zone) =>
204 rulesets.listRulesetsForZone.pages({ zoneId: zone.id }).pipe(
205 Stream.runCollect,
206 Effect.map((chunk) =>
207 Array.from(chunk).flatMap((page) =>
208 (page.result ?? []).filter((r) => r.kind === "zone"),
209 ),
210 ),
211 Effect.flatMap((entrypoints) =>
212 Effect.forEach(
213 entrypoints,
214 (entry) =>
215 rulesets
216 .getPhasForZone({
217 zoneId: zone.id,
218 rulesetPhase: entry.phase,
219 })
220 .pipe(
221 Effect.map((ruleset) =>
222 toRulesetAttributes(zone.id, ruleset),
223 ),
224 // Per-item not-found / plan-gated entrypoints are
225 // skipped rather than failing the whole enumeration.
226 Effect.catchTag(["RulesetNotFound", "Forbidden"], () =>
227 Effect.succeed(undefined),
228 ),
229 ),
230 { concurrency: 10 },
231 ),
232 ),
233 Effect.map((items) =>
234 items.filter(
235 (item): item is Ruleset["Attributes"] =>
236 // An entrypoint with zero rules is inert — it's what other
237 // owners (e.g. Worker redirect cleanup) leave behind after
238 // removing their rules. Don't surface it as a resource.
239 item !== undefined && item.rules.length > 0,
240 ),
241 ),
242 // Plan-gated / partially-provisioned zones reject the route.
243 Effect.catchTag("InvalidRoute", () => Effect.succeed([])),
244 ),
245 { concurrency: 10 },
246 );
247 return rows.flat();
248 }),
249 });
250
251const createRulesetName = (id: string, name: string | undefined) =>
252 Effect.gen(function* () {
253 return name ?? (yield* createPhysicalName({ id }));
254 });
255
256export const toRulesetAttributes = (
257 zoneId: string,
258 ruleset: rulesets.GetPhasResponse | rulesets.PutPhasResponse,
259): Ruleset["Attributes"] => ({
260 rulesetId: ruleset.id,
261 zoneId,
262 kind: ruleset.kind,
263 name: ruleset.name,
264 phase: ruleset.phase,
265 description: ruleset.description ?? undefined,
266 rules: (ruleset.rules ?? []).map(
267 ({ lastUpdated: _lastUpdated, version: _version, ...rule }) => rule,
268 ),
269 lastUpdated: ruleset.lastUpdated,
270 version: ruleset.version,
271});
272
273// A `Zone` prop is resolved to its attributes before a lifecycle op runs, so
274// `zone.zoneId` is normally a plain string even though the `Zone` resource
275// type statically exposes it as an `Output`. During `diff` (plan time) the
276// zone can still be unresolved — e.g. when it's being created in the same
277// deploy — in which case `zoneId` is not yet a string. Callers must treat a
278// non-string result as "not resolved yet". May also receive `undefined`:
279// an Output-valued `zone` doesn't survive a `creating`-state round-trip
280// (it deserializes as `undefined`), and recovery paths hand those props
281// back as `olds`.
282const zoneIdOf = (zone: Zone | undefined): string | undefined => {
283 const zoneId = (zone as unknown as Partial<Attributes> | undefined)?.zoneId;
284 return typeof zoneId === "string" ? zoneId : undefined;
285};