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 { deepEqual, isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12import type { OutputRule, Phase } from "./Ruleset.ts";
13
14const TypeId = "Cloudflare.Rulesets.AccountEntrypoint" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * A rule inside an account phase entrypoint — same shape Cloudflare accepts
19 * on the entrypoint PUT endpoint.
20 */
21export type AccountEntrypointRule = NonNullable<
22 rulesets.PutPhasForAccountRequest["rules"]
23>[number];
24
25export type AccountEntrypointProps = {
26 /**
27 * Ruleset phase entrypoint to own (e.g. `http_request_firewall_custom`,
28 * `ddos_l4`, `magic_transit`). Changing the phase triggers a
29 * replacement. Account-level phases are Enterprise-gated — on lower
30 * plans, deploys fail with the typed `PhaseNotEntitled` error.
31 */
32 phase: Phase;
33 /**
34 * The full list of rules in the phase entrypoint. This resource owns the
35 * entire entrypoint — rules managed elsewhere in the same phase are
36 * overwritten on deploy. For the Enterprise WAF deployment workflow, use
37 * `execute` rules referencing `Cloudflare.Ruleset.CustomRuleset` ids.
38 */
39 rules: AccountEntrypointRule[];
40 /**
41 * Human-readable name for the entrypoint ruleset.
42 * @default ${app}-${stage}-${id}
43 */
44 name?: string;
45 /**
46 * An informative description of the ruleset.
47 */
48 description?: string;
49};
50
51export type AccountEntrypointAttributes = {
52 /** The unique ID of the entrypoint ruleset (Cloudflare `id`). */
53 rulesetId: string;
54 /**
55 * Account the phase entrypoint belongs to. Alchemy-flattened identifier —
56 * not part of Cloudflare's phase-entrypoint response.
57 */
58 accountId: string;
59 /** The kind of the ruleset (`root` for account entrypoints). */
60 kind: string;
61 /** The human-readable name of the ruleset. */
62 name: string;
63 /** The phase of the ruleset. */
64 phase: Phase;
65 /** An informative description of the ruleset. */
66 description: string | undefined;
67 /** The list of rules in the entrypoint. */
68 rules: OutputRule[];
69 /** The timestamp of when the ruleset was last modified. */
70 lastUpdated: string;
71 /** The version of the ruleset. */
72 version: string;
73};
74
75export type AccountEntrypoint = Resource<
76 TypeId,
77 AccountEntrypointProps,
78 AccountEntrypointAttributes,
79 never,
80 Providers
81>;
82
83/**
84 * A Cloudflare Ruleset phase entrypoint for an account.
85 *
86 * The account-level counterpart of `Cloudflare.Ruleset.Ruleset`: it owns the entire
87 * ruleset for an account phase entrypoint (e.g. deploying custom WAF
88 * rulesets across zones with `execute` rules, or configuring `ddos_l4` /
89 * `magic_transit` rules). The entrypoint is a per-phase singleton — destroy
90 * empties its rules rather than deleting the phase.
91 *
92 * Account-level phases require an Enterprise plan; on lower plans deploys
93 * fail with the typed `PhaseNotEntitled` error.
94 * ### Account WAF Deployment
95 * **Example:** Deploy a custom ruleset across all zones
96 * ```typescript
97 * const ruleset = yield* Cloudflare.Ruleset.CustomRuleset("SharedWafRules", {
98 * phase: "http_request_firewall_custom",
99 * rules: [
100 * {
101 * description: "Block exploit probes",
102 * expression: `lower(http.request.uri.path) contains "/.env"`,
103 * action: "block",
104 * },
105 * ],
106 * });
107 *
108 * yield* Cloudflare.Ruleset.AccountEntrypoint("WafDeployment", {
109 * phase: "http_request_firewall_custom",
110 * rules: [
111 * {
112 * description: "Deploy shared WAF rules",
113 * expression: "true",
114 * action: "execute",
115 * actionParameters: { id: ruleset.rulesetId },
116 * },
117 * ],
118 * });
119 * ```
120 *
121 * @see https://developers.cloudflare.com/waf/account/
122 *
123 * @resource
124 * @product Rulesets
125 * @category Rules & Configuration
126 */
127export const AccountEntrypoint = Resource<AccountEntrypoint>(TypeId);
128
129/**
130 * Returns true if the given value is a AccountEntrypoint resource.
131 */
132export const isAccountEntrypoint = (
133 value: unknown,
134): value is AccountEntrypoint =>
135 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
136
137export const AccountEntrypointProvider = () =>
138 Provider.succeed(AccountEntrypoint, {
139 stables: ["rulesetId", "accountId", "kind", "phase"],
140
141 diff: Effect.fn(function* ({ id, olds, news, output }) {
142 if (!isResolved(news)) return undefined;
143 // The phase is the entrypoint's identity.
144 if (olds.phase !== news.phase) {
145 return { action: "replace" } as const;
146 }
147 const { accountId } = yield* yield* CloudflareEnvironment;
148 if (output !== undefined && output.accountId !== accountId) {
149 return { action: "replace" } as const;
150 }
151 const oldName =
152 output?.name ?? olds.name ?? (yield* createPhysicalName({ id }));
153 // Auto-generated names are engine-owned: the deployed name stays
154 // authoritative even if the generator would name this id differently
155 // today. Only an explicit user-provided name can force a rename.
156 const name = news.name ?? oldName;
157 if (
158 oldName !== name ||
159 olds.description !== news.description ||
160 !deepEqual(olds.rules, news.rules)
161 ) {
162 return { action: "update" } as const;
163 }
164 }),
165
166 list: Effect.fn(function* () {
167 const { accountId } = yield* yield* CloudflareEnvironment;
168 // Account phase entrypoints are the `root`-kind rulesets in the
169 // account's ruleset list. The list response omits the rules, so
170 // hydrate each entrypoint via `getPhasForAccount` (the same call
171 // `read` uses) to produce the exact `read` Attributes shape.
172 const entrypoints = yield* rulesets.listRulesetsForAccount
173 .pages({ accountId })
174 .pipe(
175 Stream.runCollect,
176 Effect.map((chunk) =>
177 Array.from(chunk).flatMap((page) =>
178 (page.result ?? []).filter((r) => r.kind === "root"),
179 ),
180 ),
181 );
182 const rows = yield* Effect.forEach(
183 entrypoints,
184 (entry) =>
185 rulesets
186 .getPhasForAccount({ accountId, rulesetPhase: entry.phase })
187 .pipe(
188 Effect.map((ruleset) => toAttributes(accountId, ruleset)),
189 // Removed/empty out-of-band or plan-gated phases are skipped.
190 Effect.catchTag("RulesetNotFound", () =>
191 Effect.succeed(undefined),
192 ),
193 Effect.catchTag("Forbidden", () => Effect.succeed(undefined)),
194 ),
195 { concurrency: 10 },
196 );
197 return rows.filter(
198 (row): row is AccountEntrypointAttributes =>
199 // An entrypoint with zero rules is inert — it's what destroy
200 // leaves behind after emptying the rules we own. Don't surface
201 // it as a resource.
202 row !== undefined && row.rules.length > 0,
203 );
204 }),
205
206 read: Effect.fn(function* ({ olds, output }) {
207 const accountId =
208 output?.accountId ?? (yield* yield* CloudflareEnvironment).accountId;
209 const phase = output?.phase ?? olds?.phase;
210 if (phase === undefined) return undefined;
211 // The entrypoint is a per-phase singleton that Cloudflare creates
212 // lazily — there is nothing to "own", so a cold read adopts freely
213 // (mirrors the zone-level `Cloudflare.Ruleset.Ruleset`).
214 return yield* rulesets
215 .getPhasForAccount({ accountId, rulesetPhase: phase })
216 .pipe(
217 Effect.map((ruleset) => toAttributes(accountId, ruleset)),
218 Effect.catchTag("RulesetNotFound", () => Effect.succeed(undefined)),
219 );
220 }),
221
222 reconcile: Effect.fn(function* ({ id, news, output }) {
223 const { accountId } = yield* yield* CloudflareEnvironment;
224 const name =
225 news.name ?? output?.name ?? (yield* createPhysicalName({ id }));
226 // PUT is a true upsert on the phase entrypoint — one call observes
227 // nothing and converges everything, whether the entrypoint exists yet
228 // or not.
229 const ruleset = yield* rulesets.putPhasForAccount({
230 accountId,
231 rulesetPhase: news.phase,
232 name,
233 description: news.description,
234 rules: news.rules,
235 });
236 return toAttributes(accountId, ruleset);
237 }),
238
239 delete: Effect.fn(function* ({ olds, output }) {
240 // The entrypoint itself is a singleton — "delete" means emptying the
241 // rules we own. Idempotent: an entrypoint that never materialized (or
242 // was removed out-of-band) is not an error.
243 yield* rulesets
244 .putPhasForAccount({
245 accountId: output.accountId,
246 rulesetPhase: olds.phase,
247 name: output.name,
248 description: output.description,
249 rules: [],
250 })
251 .pipe(Effect.catchTag("RulesetNotFound", () => Effect.void));
252 }),
253 });
254
255const toAttributes = (
256 accountId: string,
257 ruleset: rulesets.GetPhasResponse | rulesets.PutPhasResponse,
258): AccountEntrypointAttributes => ({
259 rulesetId: ruleset.id,
260 accountId,
261 kind: ruleset.kind,
262 name: ruleset.name,
263 phase: ruleset.phase,
264 description: ruleset.description ?? undefined,
265 rules: (ruleset.rules ?? []).map(
266 ({ lastUpdated: _lastUpdated, version: _version, ...rule }) => rule,
267 ),
268 lastUpdated: ruleset.lastUpdated,
269 version: ruleset.version,
270});