1import * as rulesets from "@distilled.cloud/cloudflare/rulesets";
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 { deepEqual, 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 type { OutputRule, Phase } from "./Ruleset.ts";
14
15const TypeId = "Cloudflare.Rulesets.CustomRuleset" as const;
16type TypeId = typeof TypeId;
17
18/**
19 * Kind of a standalone account-level ruleset. `custom` rulesets are deployed
20 * into a phase by an `execute` rule in the account's phase entrypoint;
21 * `root` rulesets are account entrypoint rulesets themselves.
22 */
23export type CustomRulesetKind = "custom" | "root";
24
25/**
26 * A rule inside an account custom ruleset — same shape Cloudflare accepts
27 * on the update (PUT) endpoint.
28 */
29export type CustomRulesetRule = NonNullable<
30 rulesets.UpdateRulesetForAccountRequest["rules"]
31>[number];
32
33export type CustomRulesetProps = {
34 /**
35 * Human-readable name of the ruleset.
36 * @default ${app}-${stage}-${id}
37 */
38 name?: string;
39 /**
40 * The kind of the ruleset. `custom` rulesets are deployed by an `execute`
41 * rule in a phase entrypoint. Changing the kind triggers a replacement.
42 * @default "custom"
43 */
44 kind?: CustomRulesetKind;
45 /**
46 * The phase the ruleset belongs to (e.g. `http_request_firewall_custom`).
47 * Changing the phase triggers a replacement.
48 */
49 phase: Phase;
50 /**
51 * The full list of rules in the ruleset. This resource owns every rule —
52 * rules added out-of-band are overwritten on the next deploy.
53 */
54 rules: CustomRulesetRule[];
55 /**
56 * An informative description of the ruleset.
57 */
58 description?: string;
59};
60
61export type CustomRulesetAttributes = {
62 /** The unique ID of the ruleset (Cloudflare `id`). */
63 rulesetId: string;
64 /**
65 * Account the ruleset belongs to. Alchemy-flattened identifier — not part
66 * of Cloudflare's ruleset response.
67 */
68 accountId: string;
69 /** The kind of the ruleset. */
70 kind: string;
71 /** The human-readable name of the ruleset. */
72 name: string;
73 /** The phase of the ruleset. */
74 phase: Phase;
75 /** An informative description of the ruleset. */
76 description: string | undefined;
77 /** The list of rules in the ruleset. */
78 rules: OutputRule[];
79 /** The timestamp of when the ruleset was last modified. */
80 lastUpdated: string;
81 /** The version of the ruleset. */
82 version: string;
83};
84
85export type CustomRuleset = Resource<
86 TypeId,
87 CustomRulesetProps,
88 CustomRulesetAttributes,
89 never,
90 Providers
91>;
92
93/**
94 * A standalone account-level Cloudflare ruleset (`kind: "custom"`).
95 *
96 * Custom rulesets are the Enterprise WAF deployment workflow: define a
97 * reusable ruleset once at the account level, then deploy it across zones
98 * with an `execute` rule in a phase entrypoint (see
99 * `Cloudflare.Ruleset.AccountEntrypoint`). Account-level WAF phases require
100 * an Enterprise plan — on lower plans, creation fails with the typed
101 * `PhaseNotEntitled` error.
102 *
103 * For zone-level rules, use `Cloudflare.Ruleset.Ruleset` (the zone phase
104 * entrypoint) instead.
105 * ### Custom Rulesets
106 * **Example:** Define an account custom WAF ruleset
107 * ```typescript
108 * const ruleset = yield* Cloudflare.Ruleset.CustomRuleset("SharedWafRules", {
109 * phase: "http_request_firewall_custom",
110 * description: "Org-wide exploit probes",
111 * rules: [
112 * {
113 * description: "Block .env probes",
114 * expression: `lower(http.request.uri.path) contains "/.env"`,
115 * action: "block",
116 * },
117 * ],
118 * });
119 * ```
120 *
121 * **Example:** Deploy the custom ruleset via the account entrypoint
122 * ```typescript
123 * yield* Cloudflare.Ruleset.AccountEntrypoint("WafDeployment", {
124 * phase: "http_request_firewall_custom",
125 * rules: [
126 * {
127 * description: "Deploy shared WAF rules everywhere",
128 * expression: "true",
129 * action: "execute",
130 * actionParameters: { id: ruleset.rulesetId },
131 * },
132 * ],
133 * });
134 * ```
135 *
136 * @see https://developers.cloudflare.com/waf/account/custom-rulesets/
137 *
138 * @resource
139 * @product Rulesets
140 * @category Rules & Configuration
141 */
142export const CustomRuleset = Resource<CustomRuleset>(TypeId);
143
144/**
145 * Returns true if the given value is a CustomRuleset resource.
146 */
147export const isCustomRuleset = (value: unknown): value is CustomRuleset =>
148 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
149
150export const CustomRulesetProvider = () =>
151 Provider.succeed(CustomRuleset, {
152 stables: ["rulesetId", "accountId", "kind", "phase"],
153
154 diff: Effect.fn(function* ({ id, olds, news, output }) {
155 if (!isResolved(news)) return undefined;
156 // kind and phase are immutable on Cloudflare's API.
157 if ((olds.kind ?? "custom") !== (news.kind ?? "custom")) {
158 return { action: "replace" } as const;
159 }
160 if (olds.phase !== news.phase) {
161 return { action: "replace" } as const;
162 }
163 const { accountId } = yield* yield* CloudflareEnvironment;
164 if (output !== undefined && output.accountId !== accountId) {
165 return { action: "replace" } as const;
166 }
167 const oldName =
168 output?.name ?? olds.name ?? (yield* createPhysicalName({ id }));
169 // Auto-generated names are engine-owned: the deployed name stays
170 // authoritative even if the generator would name this id differently
171 // today. Only an explicit user-provided name can force a rename.
172 const name = news.name ?? oldName;
173 if (
174 oldName !== name ||
175 olds.description !== news.description ||
176 !deepEqual(olds.rules, news.rules)
177 ) {
178 return { action: "update" } as const;
179 }
180 }),
181
182 read: Effect.fn(function* ({ id, olds, output }) {
183 const { accountId } = yield* yield* CloudflareEnvironment;
184 if (output !== undefined) {
185 return yield* rulesets
186 .getRulesetForAccount({
187 accountId: output.accountId,
188 rulesetId: output.rulesetId,
189 })
190 .pipe(
191 Effect.map((ruleset) =>
192 toCustomRulesetAttributes(output.accountId, ruleset),
193 ),
194 Effect.catchTag("RulesetNotFound", () => Effect.succeed(undefined)),
195 );
196 }
197 // Cold lookup — no persisted state. Find the ruleset by its
198 // deterministic name + phase + kind. Rulesets carry no tags, so we
199 // cannot prove we created a match — report it `Unowned` and let the
200 // engine gate takeover behind `--adopt`.
201 const name = olds?.name ?? (yield* createPhysicalName({ id }));
202 const kind = olds?.kind ?? "custom";
203 const match = yield* rulesets.listRulesetsForAccount
204 .items({ accountId })
205 .pipe(
206 Stream.runCollect,
207 Effect.map((chunk) =>
208 Array.from(chunk).find(
209 (r) =>
210 r.name === name && r.phase === olds?.phase && r.kind === kind,
211 ),
212 ),
213 );
214 if (match === undefined) return undefined;
215 const ruleset = yield* rulesets
216 .getRulesetForAccount({ accountId, rulesetId: match.id })
217 .pipe(
218 Effect.catchTag("RulesetNotFound", () => Effect.succeed(undefined)),
219 );
220 if (ruleset === undefined) return undefined;
221 return Unowned(toCustomRulesetAttributes(accountId, ruleset));
222 }),
223
224 reconcile: Effect.fn(function* ({ id, news, output }) {
225 const { accountId } = yield* yield* CloudflareEnvironment;
226 // Prefer the deployed name: regenerating would target a different
227 // resource if the generator's output for this id ever drifts.
228 const name =
229 news.name ?? output?.name ?? (yield* createPhysicalName({ id }));
230 const kind = news.kind ?? "custom";
231
232 // 1. Observe — the persisted rulesetId is a cache, not a guarantee.
233 const observed =
234 output !== undefined
235 ? yield* rulesets
236 .getRulesetForAccount({
237 accountId,
238 rulesetId: output.rulesetId,
239 })
240 .pipe(
241 Effect.catchTag("RulesetNotFound", () =>
242 Effect.succeed(undefined),
243 ),
244 )
245 : undefined;
246
247 // 2. Ensure — create when missing.
248 if (observed === undefined) {
249 const created = yield* rulesets.createRulesetForAccount({
250 accountId,
251 kind,
252 name,
253 phase: news.phase,
254 description: news.description,
255 rules: news.rules,
256 });
257 return toCustomRulesetAttributes(accountId, created);
258 }
259
260 // 3. Sync — PUT the full desired state only when it differs from the
261 // observed cloud state.
262 const observedAttributes = toCustomRulesetAttributes(accountId, observed);
263 const desiredRules = normalizeDesiredRules(news.rules);
264 if (
265 observedAttributes.name === name &&
266 observedAttributes.description === news.description &&
267 deepEqual(
268 normalizeObservedRules(observedAttributes.rules),
269 desiredRules,
270 )
271 ) {
272 return observedAttributes;
273 }
274 const updated = yield* rulesets.updateRulesetForAccount({
275 accountId,
276 rulesetId: observedAttributes.rulesetId,
277 name,
278 description: news.description,
279 rules: news.rules,
280 });
281 return toCustomRulesetAttributes(accountId, updated);
282 }),
283
284 delete: Effect.fn(function* ({ output }) {
285 // Idempotent — a ruleset already deleted out-of-band is not an error.
286 yield* rulesets
287 .deleteRulesetForAccount({
288 accountId: output.accountId,
289 rulesetId: output.rulesetId,
290 })
291 .pipe(Effect.catchTag("RulesetNotFound", () => Effect.void));
292 }),
293
294 // Account-scoped enumeration. `listRulesetsForAccount` returns every
295 // ruleset kind (managed/root/custom/zone) but omits each ruleset's rules,
296 // so filter to `custom` and hydrate each via `getRulesetForAccount` to
297 // produce the full `read` Attributes shape. Per-item not-found / Forbidden
298 // blips skip that ruleset rather than failing the whole enumeration.
299 list: Effect.fn(function* () {
300 const { accountId } = yield* yield* CloudflareEnvironment;
301 const summaries = yield* rulesets.listRulesetsForAccount
302 .pages({ accountId })
303 .pipe(
304 Stream.runCollect,
305 Effect.map((chunk) =>
306 Array.from(chunk).flatMap((page) =>
307 (page.result ?? []).filter((r) => r.kind === "custom"),
308 ),
309 ),
310 );
311 const hydrated = yield* Effect.forEach(
312 summaries,
313 (summary) =>
314 rulesets
315 .getRulesetForAccount({ accountId, rulesetId: summary.id })
316 .pipe(
317 Effect.map((ruleset) =>
318 toCustomRulesetAttributes(accountId, ruleset),
319 ),
320 Effect.catchTag(["RulesetNotFound", "Forbidden"], () =>
321 Effect.succeed(undefined),
322 ),
323 ),
324 { concurrency: 10 },
325 );
326 return hydrated.filter(
327 (row): row is CustomRulesetAttributes => row !== undefined,
328 );
329 }),
330 });
331
332const toCustomRulesetAttributes = (
333 accountId: string,
334 ruleset:
335 | rulesets.GetRulesetResponse
336 | rulesets.CreateRulesetResponse
337 | rulesets.UpdateRulesetResponse,
338): CustomRulesetAttributes => ({
339 rulesetId: ruleset.id,
340 accountId,
341 kind: ruleset.kind,
342 name: ruleset.name,
343 phase: ruleset.phase,
344 description: ruleset.description ?? undefined,
345 rules: (ruleset.rules ?? []).map(
346 ({ lastUpdated: _lastUpdated, version: _version, ...rule }) => rule,
347 ),
348 lastUpdated: ruleset.lastUpdated,
349 version: ruleset.version,
350});
351
352/**
353 * Strip server-assigned per-rule fields so observed rules can be compared
354 * structurally against the desired props.
355 */
356const normalizeObservedRules = (rules: OutputRule[]) =>
357 rules.map(({ id: _id, ...rule }) => rule);
358
359const normalizeDesiredRules = (rules: CustomRulesetRule[]) =>
360 rules.map(({ id: _id, ...rule }) => rule);