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 TypeId = "Cloudflare.AI.Security.CustomTopics" as const;
12type TypeId = typeof TypeId;
13
14/**
15 * A custom topic category for AI Security for Apps content detection.
16 */
17export type Topic = {
18 /**
19 * Human-readable label for the topic category.
20 */
21 label: string;
22 /**
23 * The topic description used to classify AI (LLM) traffic content.
24 */
25 topic: string;
26};
27
28export type CustomTopicsProps = {
29 /**
30 * Zone the custom topics belong to. Stable — changing the zone
31 * triggers a replacement (the old zone's topic list is restored to
32 * the value it had before Alchemy managed it).
33 */
34 zoneId: string;
35 /**
36 * The full list of custom topic categories. Mutable — the PUT
37 * replaces the entire list, so reconcile syncs the list as a whole.
38 */
39 topics: Topic[];
40};
41
42export type CustomTopicsAttributes = {
43 /** Zone the custom topics belong to. */
44 zoneId: string;
45 /** The custom topic categories currently configured on the zone. */
46 topics: Topic[];
47 /**
48 * The topic list the zone had before Alchemy first managed it.
49 * Restored on destroy, so deleting the resource puts the zone back
50 * the way it was found.
51 */
52 initialTopics: Topic[];
53};
54
55export type CustomTopics = Resource<
56 TypeId,
57 CustomTopicsProps,
58 CustomTopicsAttributes,
59 never,
60 Providers
61>;
62
63/**
64 * Custom topic categories for AI Security for Apps (Firewall for AI)
65 * content detection on a Cloudflare zone
66 * (`/zones/{zone_id}/ai-security/custom-topics`).
67 *
68 * The topic list is a zone singleton — it always exists (defaulting to
69 * empty) and is never created or deleted, only replaced wholesale via
70 * PUT. Reconcile PUTs the desired list when the observed list differs;
71 * destroy restores the list the zone had before Alchemy first managed it.
72 *
73 * Declare at most one `CustomTopics` per zone — two instances
74 * managing the same zone would fight over the single underlying list.
75 *
76 * AI Security for Apps is entitlement-gated: on accounts without the
77 * feature every call fails with the typed `AiSecurityNotEntitled` error
78 * (Cloudflare error code 13101).
79 * ### Managing custom topics
80 * **Example:** Classify traffic into two custom topics
81 * ```typescript
82 * const topics = yield* Cloudflare.AI.CustomTopics("Topics", {
83 * zoneId: zone.zoneId,
84 * topics: [
85 * { label: "billing", topic: "Questions about invoices and payments" },
86 * { label: "abuse", topic: "Harassment or abusive language" },
87 * ],
88 * });
89 * ```
90 *
91 * **Example:** Clear all custom topics
92 * ```typescript
93 * yield* Cloudflare.AI.CustomTopics("Topics", {
94 * zoneId: zone.zoneId,
95 * topics: [],
96 * });
97 * ```
98 *
99 * @see https://developers.cloudflare.com/waf/detections/firewall-for-ai/
100 *
101 * @resource
102 * @product AI Security
103 * @category Application Security
104 */
105export const CustomTopics = Resource<CustomTopics>(TypeId, {
106 aliases: ["Cloudflare.AiSecurity.CustomTopics"],
107});
108
109/**
110 * Returns true if the given value is a CustomTopics resource.
111 */
112export const isCustomTopics = (value: unknown): value is CustomTopics =>
113 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
114
115export const CustomTopicsProvider = () =>
116 Provider.succeed(CustomTopics, {
117 stables: ["zoneId", "initialTopics"],
118
119 list: Effect.fn(function* () {
120 const { accountId } = yield* yield* CloudflareEnvironment;
121 // No account-wide API for this zone singleton — enumerate every
122 // zone in the account and read its custom-topics list (the list
123 // always exists, defaulting to empty, on every entitled zone).
124 const allZones = yield* listAllZones(accountId);
125 const rows = yield* Effect.forEach(
126 allZones.map((zone) => zone.id),
127 (zoneId) =>
128 aiSecurity.getCustomTopic({ zoneId }).pipe(
129 Effect.map((observed) => {
130 const topics = normalizeTopics(observed.topics);
131 return {
132 zoneId,
133 topics,
134 // A freshly listed item adopts its observed list as the
135 // pre-management baseline, mirroring a cold `read`.
136 initialTopics: topics,
137 };
138 }),
139 // AI Security is entitlement-gated and zones may be partial
140 // or deleted out-of-band — skip any zone we can't read.
141 Effect.catchTag(
142 ["AiSecurityNotEntitled", "ZoneNotAuthorized", "Forbidden"],
143 () => Effect.succeed(undefined),
144 ),
145 ),
146 { concurrency: 10 },
147 );
148 return rows.filter(
149 (row): row is CustomTopicsAttributes => row !== undefined,
150 );
151 }),
152
153 diff: Effect.fn(function* ({ olds = {}, news, output }) {
154 const o = olds as CustomTopicsProps;
155 const n = news as CustomTopicsProps;
156 // zoneId is Input<string>; compare only once both sides are concrete.
157 const oldZoneId =
158 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
159 if (
160 oldZoneId !== undefined &&
161 typeof n.zoneId === "string" &&
162 oldZoneId !== n.zoneId
163 ) {
164 return { action: "replace" } as const;
165 }
166 return undefined;
167 }),
168
169 read: Effect.fn(function* ({ output, olds }) {
170 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
171 if (!zoneId) return undefined;
172 const observed = yield* aiSecurity.getCustomTopic({ zoneId }).pipe(
173 // Zone deleted out-of-band — the singleton is gone with it.
174 Effect.catchTag("ZoneNotAuthorized", () => Effect.succeed(undefined)),
175 );
176 if (observed === undefined) return undefined;
177 // The list is a singleton that always exists (default empty) —
178 // there is nothing to "own", so a cold read adopts freely (never
179 // `Unowned`). The observed list at adoption time becomes the
180 // `initialTopics` restored on destroy.
181 const topics = normalizeTopics(observed.topics);
182 const initialTopics =
183 output !== undefined ? output.initialTopics : topics;
184 return { zoneId, topics, initialTopics };
185 }),
186
187 reconcile: Effect.fn(function* ({ news, output }) {
188 // Inputs have been resolved to concrete strings by Plan.
189 const zoneId = news.zoneId as string;
190 const desired = news.topics;
191
192 // 1. Observe — the singleton always exists; read the live list.
193 const observed = yield* aiSecurity.getCustomTopic({ zoneId });
194 const observedTopics = normalizeTopics(observed.topics);
195
196 // 2. Capture — the pre-management list, restored on destroy.
197 // `output` (including an adoption read) already carries it;
198 // otherwise this is our first touch and the observed list is
199 // the zone's original.
200 const initialTopics =
201 output !== undefined ? output.initialTopics : observedTopics;
202
203 // 3. Sync — PUT the full desired list only when it differs.
204 if (topicsEqual(observedTopics, desired)) {
205 return { zoneId, topics: observedTopics, initialTopics };
206 }
207 const updated = yield* aiSecurity.putCustomTopic({
208 zoneId,
209 topics: desired,
210 });
211 return {
212 zoneId,
213 topics: normalizeTopics(updated.topics ?? desired),
214 initialTopics,
215 };
216 }),
217
218 delete: Effect.fn(function* ({ output }) {
219 const { zoneId, initialTopics } = output;
220 // Observe — if the zone itself is gone, so is the list; if the
221 // entitlement was revoked, the list is unreachable and there is
222 // nothing we can restore.
223 const observed = yield* aiSecurity.getCustomTopic({ zoneId }).pipe(
224 Effect.catchTag("ZoneNotAuthorized", () => Effect.succeed(undefined)),
225 Effect.catchTag("AiSecurityNotEntitled", () =>
226 Effect.succeed(undefined),
227 ),
228 );
229 if (observed === undefined) return;
230 // Restore the pre-management list; skip the call when it already
231 // matches (idempotent re-delete after a crashed run).
232 if (topicsEqual(normalizeTopics(observed.topics), initialTopics)) return;
233 yield* aiSecurity
234 .putCustomTopic({ zoneId, topics: initialTopics })
235 .pipe(Effect.catchTag("ZoneNotAuthorized", () => Effect.void));
236 }),
237 });
238
239/**
240 * Normalize the API's `topics ?? null` into a concrete (mutable) array.
241 */
242const normalizeTopics = (
243 topics: readonly Topic[] | null | undefined,
244): Topic[] => (topics ?? []).map((t) => ({ ...t }));
245
246/**
247 * Order-insensitive structural equality on the `topic` key — the PUT
248 * replaces the whole list, so two lists are equal when they contain the
249 * same `{ label, topic }` pairs regardless of order.
250 */
251const topicsEqual = (a: readonly Topic[], b: readonly Topic[]): boolean => {
252 if (a.length !== b.length) return false;
253 const key = (t: Topic) => `${t.topic}${t.label}`;
254 const as = a.map(key).sort();
255 const bs = b.map(key).sort();
256 return as.every((k, i) => k === bs[i]);
257};