1import * as aiSecurity from "@distilled.cloud/cloudflare/ai-security";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
8import type { Providers } from "../Providers.ts";
9import { listAllZones } from "../Zone/lookup.ts";
10
11const AiSecuritySettingsTypeId = "Cloudflare.AI.SecuritySettings" as const;
12type AiSecuritySettingsTypeId = typeof AiSecuritySettingsTypeId;
13
14export type SecuritySettingsProps = {
15 /**
16 * Zone the AI Security settings belong to. Stable — changing the zone
17 * triggers a replacement (the old zone's setting is restored to the
18 * value it had before Alchemy managed it).
19 */
20 zoneId: string;
21 /**
22 * Whether AI Security for Apps (Firewall for AI) is enabled on the
23 * zone. Mutable — toggled in place via PUT.
24 *
25 * @default false
26 */
27 enabled?: boolean;
28};
29
30export type SecuritySettingsAttributes = {
31 /** Zone the AI Security settings belong to. */
32 zoneId: string;
33 /** Whether AI Security for Apps is currently enabled on the zone. */
34 enabled: boolean;
35 /**
36 * The value `enabled` had before Alchemy first managed the setting.
37 * Restored on destroy, so deleting the resource puts the zone back
38 * the way it was found.
39 */
40 initialEnabled: boolean;
41};
42
43/**
44 * Returns true if the given value is a SecuritySettings resource.
45 */
46export const isSecuritySettings = (value: unknown): value is SecuritySettings =>
47 Predicate.hasProperty(value, "Type") &&
48 value.Type === AiSecuritySettingsTypeId;
49
50export type SecuritySettings = Resource<
51 AiSecuritySettingsTypeId,
52 SecuritySettingsProps,
53 SecuritySettingsAttributes,
54 never,
55 Providers
56>;
57
58/**
59 * AI Security for Apps (Firewall for AI) settings on a Cloudflare zone
60 * (`/zones/{zone_id}/ai-security/settings`).
61 *
62 * The settings object is a zone singleton — it always exists and is never
63 * created or deleted, only toggled. Reconcile PUTs the desired `enabled`
64 * value when the observed value differs; destroy restores the value the
65 * zone had before Alchemy first managed it.
66 *
67 * Declare at most one `SecuritySettings` per zone — two instances
68 * managing the same zone would fight over the single underlying setting.
69 *
70 * AI Security for Apps is entitlement-gated: on accounts without the
71 * feature every call fails with the typed `AiSecurityNotEntitled` error
72 * (Cloudflare error code 13101).
73 * ### Enabling AI Security
74 * **Example:** Enable AI Security for Apps on a zone
75 * ```typescript
76 * const securitySettings = yield* Cloudflare.AI.SecuritySettings("AiSecurity", {
77 * zoneId: zone.zoneId,
78 * enabled: true,
79 * });
80 * ```
81 *
82 * **Example:** Pin AI Security off
83 * ```typescript
84 * yield* Cloudflare.AI.SecuritySettings("AiSecurity", {
85 * zoneId: zone.zoneId,
86 * enabled: false,
87 * });
88 * ```
89 *
90 * @see https://developers.cloudflare.com/waf/detections/firewall-for-ai/
91 *
92 * @resource
93 * @product AI Security
94 * @category Application Security
95 */
96export const SecuritySettings = Resource<SecuritySettings>(
97 AiSecuritySettingsTypeId,
98 {
99 aliases: ["Cloudflare.AiSecurity.Settings"],
100 },
101);
102
103export const SecuritySettingsProvider = () =>
104 Provider.succeed(SecuritySettings, {
105 nuke: { singleton: true },
106 stables: ["zoneId", "initialEnabled"],
107
108 list: Effect.fn(function* () {
109 const { accountId } = yield* yield* CloudflareEnvironment;
110 // No account-wide API for this zone singleton — enumerate every
111 // zone in the account and read its setting. The observed value at
112 // read time is its own `initialEnabled` (nothing is being managed).
113 const allZones = yield* listAllZones(accountId);
114 const rows = yield* Effect.forEach(
115 allZones.map((zone) => zone.id),
116 (zoneId) =>
117 aiSecurity.getAiSecurity({ zoneId }).pipe(
118 Effect.map((observed): SecuritySettingsAttributes => {
119 const enabled = observed.enabled ?? false;
120 return { zoneId, enabled, initialEnabled: enabled };
121 }),
122 // Entitlement-gated or out-of-band-deleted zones reject the
123 // route; skip them rather than failing the whole enumeration.
124 Effect.catchTag("AiSecurityNotEntitled", () =>
125 Effect.succeed(undefined),
126 ),
127 Effect.catchTag("ZoneNotAuthorized", () =>
128 Effect.succeed(undefined),
129 ),
130 ),
131 { concurrency: 10 },
132 );
133 return rows.filter(
134 (row): row is SecuritySettingsAttributes => row !== undefined,
135 );
136 }),
137
138 diff: Effect.fn(function* ({ olds = {}, news, output }) {
139 const o = olds as SecuritySettingsProps;
140 const n = news as SecuritySettingsProps;
141 // zoneId is Input<string>; compare only once both sides are concrete.
142 const oldZoneId =
143 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
144 if (
145 oldZoneId !== undefined &&
146 typeof n.zoneId === "string" &&
147 oldZoneId !== n.zoneId
148 ) {
149 return { action: "replace" } as const;
150 }
151 return undefined;
152 }),
153
154 read: Effect.fn(function* ({ output, olds }) {
155 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
156 if (!zoneId) return undefined;
157 const observed = yield* aiSecurity.getAiSecurity({ zoneId }).pipe(
158 // Zone deleted out-of-band — the singleton is gone with it.
159 Effect.catchTag("ZoneNotAuthorized", () => Effect.succeed(undefined)),
160 );
161 if (observed === undefined) return undefined;
162 // The setting is a singleton that always exists with a Cloudflare
163 // default — there is nothing to "own", so a cold read adopts
164 // freely (never `Unowned`). The observed value at adoption time
165 // becomes the `initialEnabled` restored on destroy.
166 const enabled = observed.enabled ?? false;
167 const initialEnabled =
168 output !== undefined ? output.initialEnabled : enabled;
169 return { zoneId, enabled, initialEnabled };
170 }),
171
172 reconcile: Effect.fn(function* ({ news, output }) {
173 // Inputs have been resolved to concrete strings by Plan.
174 const zoneId = news.zoneId as string;
175 const desired = news.enabled ?? false;
176
177 // 1. Observe — the singleton always exists; read its live value.
178 const observed = yield* aiSecurity.getAiSecurity({ zoneId });
179 const observedEnabled = observed.enabled ?? false;
180
181 // 2. Capture — the pre-management value, restored on destroy.
182 // `output` (including an adoption read) already carries it;
183 // otherwise this is our first touch and the observed value is
184 // the zone's original.
185 const initialEnabled =
186 output !== undefined ? output.initialEnabled : observedEnabled;
187
188 // 3. Sync — PUT only when the observed value differs.
189 if (observedEnabled === desired) {
190 return { zoneId, enabled: observedEnabled, initialEnabled };
191 }
192 const updated = yield* aiSecurity.putAiSecurity({
193 zoneId,
194 enabled: desired,
195 });
196 return { zoneId, enabled: updated.enabled ?? desired, initialEnabled };
197 }),
198
199 delete: Effect.fn(function* ({ output }) {
200 const { zoneId, initialEnabled } = output;
201 // Observe — if the zone itself is gone, so is the setting; if the
202 // entitlement was revoked, the setting is unreachable and there is
203 // nothing we can restore.
204 const observed = yield* aiSecurity.getAiSecurity({ zoneId }).pipe(
205 Effect.catchTag("ZoneNotAuthorized", () => Effect.succeed(undefined)),
206 Effect.catchTag("AiSecurityNotEntitled", () =>
207 Effect.succeed(undefined),
208 ),
209 );
210 if (observed === undefined) return;
211 // Restore the pre-management value; skip the call when it already
212 // matches (idempotent re-delete after a crashed run).
213 if ((observed.enabled ?? false) === initialEnabled) return;
214 yield* aiSecurity
215 .putAiSecurity({ zoneId, enabled: initialEnabled })
216 .pipe(Effect.catchTag("ZoneNotAuthorized", () => Effect.void));
217 }),
218 });