1import * as apiGateway from "@distilled.cloud/cloudflare/api-gateway";
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.ApiShield.Configuration" as const;
12type TypeId = typeof TypeId;
13
14/**
15 * A session identifier ("auth ID characteristic") used by API Shield to
16 * correlate API requests to individual API consumers. Header and cookie
17 * characteristics name the header/cookie carrying the session token; `jwt`
18 * characteristics take a claim path expression in `name`.
19 */
20export type AuthIdCharacteristic =
21 | {
22 /** Name of the header or cookie carrying the session identifier. */
23 name: string;
24 /** Where the session identifier lives on the request. */
25 type: "header" | "cookie";
26 }
27 | {
28 /** Claim path expression locating the session identifier in the JWT. */
29 name: string;
30 /** The session identifier is a claim inside a validated JWT. */
31 type: "jwt";
32 };
33
34export interface ConfigurationProps {
35 /**
36 * Zone whose API Shield configuration is managed.
37 *
38 * Immutable — moving the configuration between zones triggers a
39 * replacement (the old zone's configuration is restored to the value it
40 * had before Alchemy managed it).
41 */
42 zoneId: string;
43 /**
44 * The session identifiers ("auth ID characteristics") API Shield uses to
45 * attribute API requests to individual consumers — used by API Discovery
46 * and volumetric abuse detection. At most 10.
47 *
48 * Mutable — written in place via PUT.
49 */
50 authIdCharacteristics: AuthIdCharacteristic[];
51}
52
53export interface ConfigurationAttributes {
54 /** Zone whose API Shield configuration is managed. */
55 zoneId: string;
56 /** The session identifiers currently configured on the zone. */
57 authIdCharacteristics: AuthIdCharacteristic[];
58 /**
59 * The session identifiers the zone had before Alchemy first managed the
60 * configuration. Restored on destroy, so deleting the resource puts the
61 * zone back the way it was found.
62 */
63 initialAuthIdCharacteristics: AuthIdCharacteristic[];
64}
65
66export type Configuration = Resource<
67 TypeId,
68 ConfigurationProps,
69 ConfigurationAttributes,
70 never,
71 Providers
72>;
73
74/**
75 * The API Shield configuration of a Cloudflare zone — the session
76 * identifiers ("auth ID characteristics") used to attribute API traffic to
77 * individual consumers for API Discovery and volumetric abuse detection.
78 *
79 * The configuration is a zone singleton: it always exists (defaulting to an
80 * empty list), so this resource never creates or deletes anything physical.
81 * Reconcile PUTs the configuration when the observed characteristics differ
82 * from the desired ones; destroy restores the characteristics the zone had
83 * before Alchemy first managed them.
84 *
85 * Requires an API Shield entitlement (Enterprise) — on other plans every
86 * operation fails with Cloudflare's `NotEntitled` error (code 10403).
87 * ### Configuring session identifiers
88 * **Example:** Identify sessions by an Authorization header
89 * ```typescript
90 * yield* Cloudflare.ApiShield.Configuration("SessionIds", {
91 * zoneId: zone.zoneId,
92 * authIdCharacteristics: [{ name: "authorization", type: "header" }],
93 * });
94 * ```
95 *
96 * **Example:** Identify sessions by a cookie and a JWT claim
97 * ```typescript
98 * yield* Cloudflare.ApiShield.Configuration("SessionIds", {
99 * zoneId: zone.zoneId,
100 * authIdCharacteristics: [
101 * { name: "session_id", type: "cookie" },
102 * { name: '$.cf.token_configurations[?(@.title=="api")].sub', type: "jwt" },
103 * ],
104 * });
105 * ```
106 *
107 * @see https://developers.cloudflare.com/api-shield/get-started/#session-identifiers
108 *
109 * @resource
110 * @product API Shield
111 * @category Application Security
112 */
113export const Configuration = Resource<Configuration>(TypeId);
114
115/**
116 * Returns true if the given value is an Configuration resource.
117 */
118export const isConfiguration = (value: unknown): value is Configuration =>
119 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
120
121export const ConfigurationProvider = () =>
122 Provider.succeed(Configuration, {
123 stables: ["zoneId", "initialAuthIdCharacteristics"],
124
125 list: Effect.fn(function* () {
126 const { accountId } = yield* yield* CloudflareEnvironment;
127 // No account-wide API for this zone singleton — enumerate every
128 // zone in the account and read its configuration (it always exists,
129 // defaulting to an empty list, on every entitled zone).
130 const allZones = yield* listAllZones(accountId);
131 const rows = yield* Effect.forEach(
132 allZones.map((zone) => zone.id),
133 (zoneId) =>
134 apiGateway.getConfiguration({ zoneId }).pipe(
135 Effect.map((observed) => {
136 const characteristics = toCharacteristics(
137 observed.authIdCharacteristics,
138 );
139 return toAttributes(
140 zoneId,
141 observed.authIdCharacteristics,
142 // A freshly listed item adopts its observed value as the
143 // pre-management baseline, mirroring a cold `read`.
144 characteristics,
145 );
146 }),
147 // API Shield is entitlement-gated and zones may be partial or
148 // deleted out-of-band — skip any zone we can't read.
149 Effect.catchTag(
150 ["NotEntitled", "InvalidObjectIdentifier", "Forbidden"],
151 () => Effect.succeed(undefined),
152 ),
153 ),
154 { concurrency: 10 },
155 );
156 return rows.filter(
157 (row): row is ConfigurationAttributes => row !== undefined,
158 );
159 }),
160
161 diff: Effect.fn(function* ({ olds, news, output }) {
162 const o = olds as ConfigurationProps | undefined;
163 const n = news as ConfigurationProps;
164 // zoneId is Input<string>; compare only once both sides are concrete.
165 const oldZoneId =
166 output?.zoneId ??
167 (typeof o?.zoneId === "string" ? o.zoneId : undefined);
168 if (
169 oldZoneId !== undefined &&
170 typeof n.zoneId === "string" &&
171 oldZoneId !== n.zoneId
172 ) {
173 return { action: "replace" } as const;
174 }
175 return undefined;
176 }),
177
178 read: Effect.fn(function* ({ output, olds }) {
179 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
180 if (zoneId === undefined) return undefined;
181 const observed = yield* apiGateway.getConfiguration({ zoneId }).pipe(
182 // Zone deleted out-of-band — the configuration is gone with it.
183 Effect.catchTag("InvalidObjectIdentifier", () =>
184 Effect.succeed(undefined),
185 ),
186 );
187 if (observed === undefined) return undefined;
188 // The configuration is a singleton that always exists with a default
189 // (empty) value — there is nothing to "own", so a cold read adopts
190 // freely (never `Unowned`). The observed value at adoption time
191 // becomes the baseline restored on destroy.
192 const initial =
193 output !== undefined
194 ? output.initialAuthIdCharacteristics
195 : toCharacteristics(observed.authIdCharacteristics);
196 return toAttributes(zoneId, observed.authIdCharacteristics, initial);
197 }),
198
199 reconcile: Effect.fn(function* ({ news, output }) {
200 // Inputs have been resolved to concrete strings by Plan.
201 const zoneId = news.zoneId as string;
202
203 // 1. Observe — the configuration always exists; read its live value.
204 const observed = yield* apiGateway.getConfiguration({ zoneId });
205
206 // 2. Capture — the pre-management characteristics, restored on
207 // destroy. `output` (including an adoption read) already carries
208 // them; otherwise this is our first touch and the observed value
209 // is the zone's original.
210 const initial =
211 output !== undefined
212 ? output.initialAuthIdCharacteristics
213 : toCharacteristics(observed.authIdCharacteristics);
214
215 // 3. Sync — PUT only when the observed characteristics differ.
216 if (
217 characteristicsEqual(
218 toCharacteristics(observed.authIdCharacteristics),
219 news.authIdCharacteristics,
220 )
221 ) {
222 return toAttributes(zoneId, observed.authIdCharacteristics, initial);
223 }
224 const synced = yield* apiGateway.putConfiguration({
225 zoneId,
226 authIdCharacteristics: news.authIdCharacteristics,
227 });
228 return toAttributes(zoneId, synced.authIdCharacteristics, initial);
229 }),
230
231 delete: Effect.fn(function* ({ output }) {
232 const { zoneId, initialAuthIdCharacteristics } = output;
233 // Observe — if the zone itself is gone, so is the configuration.
234 const observed = yield* apiGateway
235 .getConfiguration({ zoneId })
236 .pipe(
237 Effect.catchTag("InvalidObjectIdentifier", () =>
238 Effect.succeed(undefined),
239 ),
240 );
241 if (observed === undefined) return;
242 // Restore the pre-management characteristics; skip the call when
243 // they already match (idempotent re-delete after a crashed run).
244 if (
245 characteristicsEqual(
246 toCharacteristics(observed.authIdCharacteristics),
247 initialAuthIdCharacteristics,
248 )
249 ) {
250 return;
251 }
252 yield* apiGateway
253 .putConfiguration({
254 zoneId,
255 authIdCharacteristics: initialAuthIdCharacteristics,
256 })
257 .pipe(Effect.catchTag("InvalidObjectIdentifier", () => Effect.void));
258 }),
259 });
260
261/**
262 * Narrow distilled's open-union characteristics (`"header" | "cookie" |
263 * (string & {})`) to the resource's closed attribute type.
264 */
265const toCharacteristics = (
266 characteristics: readonly { name: string; type: string }[],
267): AuthIdCharacteristic[] =>
268 characteristics.map(
269 (c) =>
270 ({
271 name: c.name,
272 type: c.type,
273 }) as AuthIdCharacteristic,
274 );
275
276/**
277 * Order-insensitive equality of two characteristic lists — Cloudflare
278 * treats the configuration as a set.
279 */
280const characteristicsEqual = (
281 a: AuthIdCharacteristic[],
282 b: AuthIdCharacteristic[],
283): boolean => {
284 if (a.length !== b.length) return false;
285 const key = (c: AuthIdCharacteristic) => `${c.type}${c.name}`;
286 const as = a.map(key).sort();
287 const bs = b.map(key).sort();
288 return as.every((k, i) => k === bs[i]);
289};
290
291const toAttributes = (
292 zoneId: string,
293 observed: readonly { name: string; type: string }[],
294 initial: AuthIdCharacteristic[],
295): ConfigurationAttributes => ({
296 zoneId,
297 authIdCharacteristics: toCharacteristics(observed),
298 initialAuthIdCharacteristics: initial,
299});