1import * as tokenValidation from "@distilled.cloud/cloudflare/token-validation";
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 { 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 { listAllZones } from "../Zone/lookup.ts";
14
15const TypeId = "Cloudflare.TokenValidation.Rule" as const;
16type TypeId = typeof TypeId;
17
18/**
19 * Action applied to requests that match the selector and fail the rule's
20 * expression.
21 */
22export type RuleAction = "log" | "block";
23
24/**
25 * Selects the operations covered by a token validation rule.
26 */
27export interface RuleSelector {
28 /**
29 * Operations to include, by host.
30 */
31 include?: {
32 /** Hostnames whose operations the rule covers. */
33 host?: string[];
34 }[];
35 /**
36 * Operations to exclude, by API Shield operation ID.
37 */
38 exclude?: {
39 /** API Shield operation IDs the rule must not cover. */
40 operationIds?: string[];
41 }[];
42}
43
44/**
45 * Desired ordering of a rule among the zone's token validation rules.
46 * Applied via PATCH whenever set; ordering is not tracked in attributes.
47 */
48export type RulePosition =
49 | { /** Absolute position in the rule list. */ index: number }
50 | { /** Place the rule before this rule ID. */ before: string }
51 | { /** Place the rule after this rule ID. */ after: string };
52
53export interface RuleProps {
54 /**
55 * Zone the rule applies to.
56 *
57 * Stable — moving a rule between zones triggers a replacement.
58 */
59 zoneId: string;
60 /**
61 * Human-readable name for the rule. If omitted, a unique name is
62 * generated from the app, stage, and logical ID.
63 * @default ${app}-${stage}-${id}
64 */
65 title?: string;
66 /**
67 * A description that gives more details than `title`.
68 * @default ""
69 */
70 description?: string;
71 /**
72 * Toggle the rule on or off.
73 * @default true
74 */
75 enabled?: boolean;
76 /**
77 * Action to take on requests that match the selector and fail the
78 * expression: `log` or `block`.
79 */
80 action: RuleAction;
81 /**
82 * Rule expression — requests that fail it are subject to `action`,
83 * e.g. `is_jwt_valid("<configId>")`. Reference a
84 * {@link TokenConfiguration} by interpolating its `configId` output.
85 */
86 expression: string;
87 /**
88 * Which operations the rule covers (include by host, exclude by API
89 * Shield operation ID).
90 */
91 selector: RuleSelector;
92 /**
93 * Desired ordering among the zone's rules. Applied via PATCH on every
94 * deploy it is set; omit to leave ordering untouched (new rules append).
95 */
96 position?: RulePosition;
97}
98
99export interface RuleAttributes {
100 /** Cloudflare-assigned UUID of the rule. */
101 ruleId: string;
102 /** Zone the rule belongs to. */
103 zoneId: string;
104 /** Human-readable name of the rule. */
105 title: string;
106 /** Description of the rule. */
107 description: string;
108 /** Whether the rule is enabled. */
109 enabled: boolean;
110 /** Action applied to matching requests that fail the expression. */
111 action: RuleAction;
112 /** The rule's expression. */
113 expression: string;
114 /** Which operations the rule covers. */
115 selector: {
116 include?: { host?: string[] }[];
117 exclude?: { operationIds?: string[] }[];
118 };
119 /** ISO8601 creation timestamp, when reported by the API. */
120 createdAt: string | undefined;
121 /** ISO8601 last-modified timestamp, when reported by the API. */
122 lastUpdated: string | undefined;
123}
124
125export type Rule = Resource<
126 TypeId,
127 RuleProps,
128 RuleAttributes,
129 never,
130 Providers
131>;
132
133/**
134 * An API Shield JWT validation rule — selects operations/hosts on a zone
135 * and enforces a token validation expression with a `log` or `block`
136 * action.
137 *
138 * A rule references a {@link TokenConfiguration} by UUID inside its
139 * `expression` (e.g. `is_jwt_valid("<configId>")`). Keep the rule
140 * depending on the configuration through its output so destroy order is
141 * rule first, configuration second.
142 *
143 * JWT validation is an API Shield feature (Enterprise add-on) — accounts
144 * without the entitlement receive the typed `TokenValidationNotEntitled`
145 * error (Cloudflare code 10403) on every call.
146 *
147 * All fields are patched in place; only `zoneId` forces a replacement.
148 * ### Creating a Rule
149 * **Example:** Log requests with invalid JWTs
150 * ```typescript
151 * const rule = yield* Cloudflare.TokenValidation.Rule("LogInvalidJwt", {
152 * zoneId: zone.zoneId,
153 * action: "log",
154 * expression: Output.interpolate`is_jwt_valid("${config.configId}")`,
155 * selector: { include: [{ host: ["api.example.com"] }] },
156 * });
157 * ```
158 *
159 * **Example:** Block invalid JWTs, excluding a public operation
160 * ```typescript
161 * yield* Cloudflare.TokenValidation.Rule("BlockInvalidJwt", {
162 * zoneId: zone.zoneId,
163 * action: "block",
164 * expression: Output.interpolate`is_jwt_valid("${config.configId}")`,
165 * selector: {
166 * include: [{ host: ["api.example.com"] }],
167 * exclude: [{ operationIds: [healthCheck.operationId] }],
168 * },
169 * });
170 * ```
171 *
172 * ### Updating a Rule
173 * **Example:** Disable a rule in place
174 * ```typescript
175 * yield* Cloudflare.TokenValidation.Rule("BlockInvalidJwt", {
176 * zoneId: zone.zoneId,
177 * enabled: false,
178 * action: "block",
179 * expression: Output.interpolate`is_jwt_valid("${config.configId}")`,
180 * selector: { include: [{ host: ["api.example.com"] }] },
181 * });
182 * ```
183 *
184 * @see https://developers.cloudflare.com/api-shield/security/jwt-validation/
185 *
186 * @resource
187 * @product Token Validation
188 * @category Application Security
189 */
190export const Rule = Resource<Rule>(TypeId);
191
192/**
193 * Returns true if the given value is a Rule resource.
194 */
195export const isRule = (value: unknown): value is Rule =>
196 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
197
198export const RuleProvider = () =>
199 Provider.succeed(Rule, {
200 stables: ["ruleId", "zoneId", "createdAt"],
201
202 diff: Effect.fn(function* ({ olds, news, output }) {
203 if (!isResolved(news)) return undefined;
204 // Moving between zones replaces the rule.
205 const oldZoneId = output?.zoneId ?? olds?.zoneId;
206 if (typeof oldZoneId === "string" && oldZoneId !== news.zoneId) {
207 return { action: "replace" } as const;
208 }
209 return undefined;
210 }),
211
212 read: Effect.fn(function* ({ id, output, olds }) {
213 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
214 if (zoneId === undefined) return undefined;
215
216 // Owned path: refresh by our persisted UUID.
217 if (output?.ruleId) {
218 const observed = yield* getRule(zoneId, output.ruleId);
219 if (observed) return toAttributes(observed, zoneId);
220 }
221
222 // Cold read — recover from lost state by matching the deterministic
223 // physical title (client-side; the list API cannot filter by title).
224 // Rules carry no ownership markers, so an existing match is reported
225 // as `Unowned` and the engine gates takeover behind the adopt policy.
226 const title = yield* createRuleTitle(id, olds?.title);
227 const match = yield* findByTitle(zoneId, title);
228 if (match?.id) {
229 const observed = yield* getRule(zoneId, match.id);
230 if (observed) return Unowned(toAttributes(observed, zoneId));
231 }
232 return undefined;
233 }),
234
235 reconcile: Effect.fn(function* ({ id, news, output }) {
236 // Inputs have been resolved to concrete values by Plan.
237 const zoneId = news.zoneId as string;
238 const expression = news.expression as string;
239 const title = yield* createRuleTitle(id, news.title);
240 const desired = {
241 title,
242 description: news.description ?? "",
243 enabled: news.enabled ?? true,
244 action: news.action,
245 expression,
246 selector: toApiSelector(news.selector),
247 };
248
249 // 1. Observe — the UUID cached on `output` is a hint, not a
250 // guarantee: a 404 falls through to "missing" and we recreate.
251 const observed = output?.ruleId
252 ? yield* getRule(zoneId, output.ruleId)
253 : undefined;
254
255 // 2. Ensure — greenfield (or out-of-band delete): create with the
256 // full desired body. Titles are not unique on Cloudflare's side,
257 // so there is no AlreadyExists race to tolerate.
258 if (!observed) {
259 const created = yield* tokenValidation.createRule({
260 zoneId,
261 ...desired,
262 });
263 // Ordering is only expressible via PATCH; apply it when requested.
264 if (news.position !== undefined && created.id) {
265 const positioned = yield* tokenValidation.patchRule({
266 zoneId,
267 ruleId: created.id,
268 position: news.position,
269 });
270 return toAttributes(positioned, zoneId);
271 }
272 return toAttributes(created, zoneId);
273 }
274
275 // 3. Sync — diff observed cloud state against desired field by
276 // field; PATCH only when something differs (or an explicit
277 // `position` must be enforced — ordering cannot be observed
278 // cheaply, so a set position is re-asserted on every deploy).
279 const dirty =
280 observed.title !== desired.title ||
281 observed.description !== desired.description ||
282 observed.enabled !== desired.enabled ||
283 observed.action !== desired.action ||
284 observed.expression !== desired.expression ||
285 !sameSelector(observed.selector, desired.selector);
286
287 if (!dirty && news.position === undefined) {
288 return toAttributes(observed, zoneId);
289 }
290
291 const patched = yield* tokenValidation.patchRule({
292 zoneId,
293 ruleId: observed.id ?? output!.ruleId,
294 ...desired,
295 ...(news.position !== undefined ? { position: news.position } : {}),
296 });
297 return toAttributes(patched, zoneId);
298 }),
299
300 delete: Effect.fn(function* ({ output }) {
301 yield* tokenValidation
302 .deleteRule({ zoneId: output.zoneId, ruleId: output.ruleId })
303 .pipe(
304 Effect.catchTag("TokenValidationRuleNotFound", () => Effect.void),
305 );
306 }),
307
308 list: Effect.fn(function* () {
309 const { accountId } = yield* yield* CloudflareEnvironment;
310 // Token validation rules live inside a zone with no account-wide
311 // enumeration API — fan out across every zone and list rules per
312 // zone, paginating each zone exhaustively.
313 const zones = yield* listAllZones(accountId);
314 const rows = yield* Effect.forEach(
315 zones,
316 (zone) =>
317 tokenValidation.listRules.pages({ zoneId: zone.id }).pipe(
318 Stream.runCollect,
319 Effect.map((chunk) =>
320 Array.from(chunk).flatMap((page) =>
321 (page.result ?? []).map((rule): RuleAttributes =>
322 toAttributes(rule, zone.id),
323 ),
324 ),
325 ),
326 // JWT validation is an API Shield (Enterprise) feature; zones
327 // without the entitlement, or with partial permissions, reject
328 // the route — skip them rather than failing the whole listing.
329 Effect.catchTag(["TokenValidationNotEntitled", "Forbidden"], () =>
330 Effect.succeed([] as RuleAttributes[]),
331 ),
332 ),
333 { concurrency: 10 },
334 );
335 return rows.flat();
336 }),
337 });
338
339type ObservedRule = tokenValidation.GetRuleResponse;
340type ApiSelector = tokenValidation.CreateRuleRequest["selector"];
341
342/**
343 * Read a rule by UUID, mapping "gone" (`TokenValidationRuleNotFound`,
344 * HTTP 404) to `undefined`.
345 */
346const getRule = (zoneId: string, ruleId: string) =>
347 tokenValidation.getRule({ zoneId, ruleId }).pipe(
348 Effect.map((r): ObservedRule | undefined => r),
349 Effect.catchTag("TokenValidationRuleNotFound", () =>
350 Effect.succeed(undefined),
351 ),
352 );
353
354/**
355 * Find a rule by exact title. The list API cannot filter by title, so
356 * match client-side; if several rules share the title, pick the oldest
357 * for determinism.
358 */
359const findByTitle = (zoneId: string, title: string) =>
360 tokenValidation.listRules.items({ zoneId }).pipe(
361 Stream.runCollect,
362 Effect.map((chunk) =>
363 Array.from(chunk)
364 .filter((r) => r.title === title)
365 .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""))
366 .at(0),
367 ),
368 );
369
370const createRuleTitle = (id: string, title: string | undefined) =>
371 Effect.gen(function* () {
372 return title ?? (yield* createPhysicalName({ id, lowercase: true }));
373 });
374
375/**
376 * Convert resolved props' selector to the API request shape (the
377 * `Input<string>` operation IDs have been resolved by Plan).
378 */
379const toApiSelector = (selector: RuleSelector): ApiSelector => ({
380 ...(selector.include !== undefined
381 ? { include: selector.include.map((entry) => ({ host: entry.host })) }
382 : {}),
383 ...(selector.exclude !== undefined
384 ? {
385 exclude: selector.exclude.map((entry) => ({
386 operationIds: entry.operationIds as string[] | undefined,
387 })),
388 }
389 : {}),
390});
391
392/** Canonical, order-insensitive form of a selector for diffing. */
393const canonicalSelector = (selector: ObservedRule["selector"] | ApiSelector) =>
394 JSON.stringify({
395 include: (selector.include ?? [])
396 .map((entry) => [...(entry.host ?? [])].sort())
397 .sort(),
398 exclude: (selector.exclude ?? [])
399 .map((entry) => [...(entry.operationIds ?? [])].sort())
400 .sort(),
401 });
402
403const sameSelector = (
404 observed: ObservedRule["selector"],
405 desired: ApiSelector,
406) => canonicalSelector(observed) === canonicalSelector(desired);
407
408const toAttributes = (
409 rule:
410 | ObservedRule
411 | tokenValidation.CreateRuleResponse
412 | tokenValidation.PatchRuleResponse
413 | tokenValidation.ListRulesResponse["result"][number],
414 zoneId: string,
415): RuleAttributes => ({
416 ruleId: rule.id ?? "",
417 zoneId,
418 title: rule.title,
419 description: rule.description,
420 enabled: rule.enabled,
421 // Distilled widens the action enum to an open union (`string & {}`).
422 action: rule.action as RuleAction,
423 expression: rule.expression,
424 selector: {
425 ...(rule.selector.include != null
426 ? {
427 include: rule.selector.include.map((entry) =>
428 entry.host != null ? { host: [...entry.host] } : {},
429 ),
430 }
431 : {}),
432 ...(rule.selector.exclude != null
433 ? {
434 exclude: rule.selector.exclude.map((entry) =>
435 entry.operationIds != null
436 ? { operationIds: [...entry.operationIds] }
437 : {},
438 ),
439 }
440 : {}),
441 },
442 createdAt: rule.createdAt ?? undefined,
443 lastUpdated: rule.lastUpdated ?? undefined,
444});