1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
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";
9
10const TypeId = "Cloudflare.Gateway.Configuration" as const;
11type TypeId = typeof TypeId;
12
13/**
14 * The Gateway account settings blocks this resource can manage. Only the
15 * blocks you declare are patched (and captured for restore-on-destroy);
16 * everything else on the account configuration is left untouched.
17 */
18export interface ConfigurationSettings {
19 /** Activity logging master toggle for the account. */
20 activityLog?: { enabled?: boolean };
21 /** Anti-virus scanning of file downloads/uploads. */
22 antivirus?: {
23 /** Scan on file download. */
24 enabledDownloadPhase?: boolean;
25 /** Scan on file upload. */
26 enabledUploadPhase?: boolean;
27 /** Block requests for files that cannot be scanned. */
28 failClosed?: boolean;
29 /** Block-page notification settings shown when a file is blocked. */
30 notificationSettings?: {
31 enabled?: boolean;
32 includeContext?: boolean;
33 msg?: string;
34 supportUrl?: string;
35 };
36 };
37 /** Custom block page configuration. */
38 blockPage?: {
39 /** Background color in hex (e.g. `#1f2937`). */
40 backgroundColor?: string;
41 /** Enable the custom block page (disabled shows the default page). */
42 enabled?: boolean;
43 /** Footer text. */
44 footerText?: string;
45 /** Header text. */
46 headerText?: string;
47 /** Include rule/policy context on the page. */
48 includeContext?: boolean;
49 /** URL of a logo image to display. */
50 logoPath?: string;
51 /** Admin email shown as a mailto: link. */
52 mailtoAddress?: string;
53 /** Subject prefilled in the mailto: link. */
54 mailtoSubject?: string;
55 /** Block page mode. */
56 mode?: "" | "customized_block_page" | "redirect_uri";
57 /** Block page name. */
58 name?: string;
59 /** Hide the Cloudflare footer. */
60 suppressFooter?: boolean;
61 /** Redirect URI when `mode` is `redirect_uri`. */
62 targetUri?: string;
63 };
64 /** HTTP body scanning behaviour. */
65 bodyScanning?: { inspectionMode?: "deep" | "shallow" };
66 /** Clientless browser isolation. */
67 browserIsolation?: {
68 nonIdentityEnabled?: boolean;
69 urlBrowserIsolationEnabled?: boolean;
70 };
71 /**
72 * The Gateway-managed certificate used for TLS interception. Reference
73 * a `Cloudflare.Gateway.Certificate`'s `certificateId` here.
74 */
75 certificate?: { id: string };
76 /** Match on both email aliases and the primary address. */
77 extendedEmailMatching?: { enabled?: boolean };
78 /** FIPS-compliant TLS-only enforcement. */
79 fips?: { tls?: boolean };
80 /** Host selector (egress by hostname) support. */
81 hostSelector?: { enabled?: boolean };
82 /** Traffic inspection mode. */
83 inspection?: { mode?: "static" | "dynamic" };
84 /** Detect protocols on the initial bytes of a connection. */
85 protocolDetection?: { enabled?: boolean };
86 /** File sandboxing. */
87 sandbox?: { enabled?: boolean; fallbackAction?: "allow" | "block" };
88 /** TLS decryption (required for HTTP inspection). */
89 tlsDecrypt?: { enabled?: boolean };
90}
91
92export type ConfigurationBlockKey = keyof ConfigurationSettings;
93
94export interface ConfigurationProps {
95 /**
96 * The settings blocks to manage. Reconcile patches only the declared
97 * blocks (Cloudflare PATCH semantics — undeclared blocks are never
98 * touched), and destroy restores each declared block to the value it
99 * had before Alchemy first managed it.
100 */
101 settings: ConfigurationSettings;
102}
103
104/**
105 * A captured snapshot of the managed settings blocks as observed on
106 * Cloudflare before Alchemy first patched them. `null` records a block
107 * that was absent at capture time.
108 */
109export type ConfigurationSnapshot = Partial<
110 Record<ConfigurationBlockKey, unknown>
111>;
112
113export interface ConfigurationAttributes {
114 /** Account that owns the Gateway configuration singleton. */
115 accountId: string;
116 /** The full observed Gateway settings after reconciliation. */
117 settings: unknown;
118 /**
119 * The managed blocks' pre-management values, restored on destroy so
120 * deleting the resource puts the account back the way it was found.
121 */
122 initialSettings: ConfigurationSnapshot;
123 /** ISO8601 creation timestamp of the configuration. */
124 createdAt: string | undefined;
125 /** ISO8601 last-update timestamp of the configuration. */
126 updatedAt: string | undefined;
127}
128
129export type Configuration = Resource<
130 TypeId,
131 ConfigurationProps,
132 ConfigurationAttributes,
133 never,
134 Providers
135>;
136
137/**
138 * Manages the **singleton** Cloudflare Zero Trust **Gateway configuration**
139 * for an account (`/accounts/{accountId}/gateway/configuration`) —
140 * account-wide settings like activity logging, TLS decryption, the block
141 * page, anti-virus scanning, and browser isolation.
142 *
143 * The singleton always exists, so reconcile patches only the settings
144 * blocks you declare and never clobbers unmanaged blocks. The
145 * pre-management value of each managed block is captured on first touch
146 * and restored on destroy (capture-and-restore). Blocks that were unset
147 * before Alchemy managed them cannot be restored (Cloudflare's API has no
148 * way to unset a block) — destroy leaves the last managed value and logs
149 * a warning.
150 * ### Managing Gateway settings
151 * **Example:** Enable activity logging and TLS decryption
152 * ```typescript
153 * yield* Cloudflare.Gateway.Configuration("Gateway", {
154 * settings: {
155 * activityLog: { enabled: true },
156 * tlsDecrypt: { enabled: true },
157 * },
158 * });
159 * ```
160 *
161 * **Example:** Custom block page
162 * ```typescript
163 * yield* Cloudflare.Gateway.Configuration("Gateway", {
164 * settings: {
165 * blockPage: {
166 * enabled: true,
167 * headerText: "Blocked by IT",
168 * footerText: "Contact support@example.com",
169 * backgroundColor: "#1f2937",
170 * },
171 * },
172 * });
173 * ```
174 *
175 * ### TLS interception
176 * **Example:** Use a Gateway certificate for inspection
177 * ```typescript
178 * const cert = yield* Cloudflare.Gateway.Certificate("InspectionCa", {});
179 * yield* Cloudflare.Gateway.Configuration("Gateway", {
180 * settings: {
181 * tlsDecrypt: { enabled: true },
182 * certificate: { id: cert.certificateId },
183 * },
184 * });
185 * ```
186 *
187 * @see https://developers.cloudflare.com/cloudflare-one/policies/gateway/
188 *
189 * @resource
190 * @product Gateway
191 * @category Cloudflare One (Zero Trust)
192 */
193export const Configuration = Resource<Configuration>(TypeId);
194
195/**
196 * Returns true if the given value is a Configuration resource.
197 */
198export const isConfiguration = (value: unknown): value is Configuration =>
199 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
200
201export const ConfigurationProvider = () =>
202 Provider.succeed(Configuration, {
203 nuke: { singleton: true },
204 stables: ["accountId", "initialSettings", "createdAt"],
205
206 // Account-wide singleton: the Gateway configuration always exists for
207 // the ambient account, so enumeration is a single read returning a
208 // one-element array. There is no prior management context, so the
209 // restore snapshot is empty.
210 list: Effect.fn(function* () {
211 const { accountId } = yield* yield* CloudflareEnvironment;
212 const observed = yield* zeroTrust.getGatewayConfiguration({
213 accountId,
214 });
215 return [toAttributes(accountId, observed, {})];
216 }),
217
218 read: Effect.fn(function* ({ output, olds }) {
219 const { accountId } = yield* yield* CloudflareEnvironment;
220 const acct = output?.accountId ?? accountId;
221 const observed = yield* zeroTrust.getGatewayConfiguration({
222 accountId: acct,
223 });
224 // The singleton always exists with account defaults — there is
225 // nothing to "own", so a cold read adopts freely. The observed
226 // blocks at adoption time become the restore target.
227 const managed = managedKeys(olds?.settings ?? {});
228 const initialSettings =
229 output?.initialSettings ??
230 captureBlocks(observed.settings ?? {}, managed);
231 return toAttributes(acct, observed, initialSettings);
232 }),
233
234 reconcile: Effect.fn(function* ({ news, output }) {
235 const { accountId } = yield* yield* CloudflareEnvironment;
236 const desired = (news.settings ?? {}) as ConfigurationSettings;
237 const managed = managedKeys(desired);
238
239 // 1. Observe — the singleton always exists; read its live state.
240 const observed = yield* zeroTrust.getGatewayConfiguration({ accountId });
241 const observedSettings = (observed.settings ?? {}) as Record<
242 string,
243 unknown
244 >;
245
246 // 2. Capture — pre-management values for every managed block,
247 // restored on destroy. Blocks newly managed by this update are
248 // captured now; previously captured blocks keep their original
249 // snapshot.
250 const initialSettings: ConfigurationSnapshot = {
251 ...captureBlocks(observedSettings, managed),
252 ...output?.initialSettings,
253 };
254
255 // 3. Sync — patch only when a declared block diverges from the
256 // observed state; skip the API entirely on a no-op. PATCH
257 // replaces each provided block wholly and leaves the rest alone.
258 const dirty = managed.some(
259 (key) => !subsetEquals(desired[key], observedSettings[key]),
260 );
261 if (!dirty) {
262 return toAttributes(accountId, observed, initialSettings);
263 }
264 yield* zeroTrust.patchGatewayConfiguration({
265 accountId,
266 settings: pickBlocks(
267 desired,
268 managed,
269 ) as zeroTrust.PatchGatewayConfigurationRequest["settings"],
270 });
271
272 // 4. Return — re-read so attrs reflect post-sync truth.
273 const final = yield* zeroTrust.getGatewayConfiguration({ accountId });
274 return toAttributes(accountId, final, initialSettings);
275 }),
276
277 delete: Effect.fn(function* ({ output }) {
278 const { accountId, initialSettings } = output;
279 const managed = Object.keys(initialSettings) as ConfigurationBlockKey[];
280 if (managed.length === 0) return;
281 // Observe — restore only the blocks that still diverge from their
282 // captured pre-management value (idempotent re-delete).
283 const observed = yield* zeroTrust.getGatewayConfiguration({ accountId });
284 const observedSettings = (observed.settings ?? {}) as Record<
285 string,
286 unknown
287 >;
288 const restore: Record<string, unknown> = {};
289 for (const key of managed) {
290 const initial = sanitizeBlock(initialSettings[key]);
291 const current = sanitizeBlock(observedSettings[key]);
292 if (deepEquals(initial ?? null, current ?? null)) continue;
293 if (initial === undefined) {
294 // The block was absent before we managed it. Cloudflare's PATCH
295 // ignores `null` blocks, so an absent block cannot be restored —
296 // the last managed value is left in place.
297 yield* Effect.logWarning(
298 `Gateway configuration block "${key}" was unset before Alchemy managed it and cannot be restored; leaving the current value in place.`,
299 );
300 continue;
301 }
302 restore[key] = initial;
303 }
304 if (Object.keys(restore).length === 0) return;
305 yield* zeroTrust.patchGatewayConfiguration({
306 accountId,
307 settings:
308 restore as zeroTrust.PatchGatewayConfigurationRequest["settings"],
309 });
310 }),
311 });
312
313const managedKeys = (
314 settings: ConfigurationSettings,
315): ConfigurationBlockKey[] =>
316 (Object.keys(settings) as ConfigurationBlockKey[]).filter(
317 (key) => settings[key] !== undefined,
318 );
319
320/**
321 * Capture the observed value of each managed block (read-only fields
322 * stripped so the snapshot can be patched straight back). Absent blocks
323 * are recorded as `null`; they cannot be restored on destroy because
324 * Cloudflare's PATCH ignores `null` blocks.
325 */
326const captureBlocks = (
327 observedSettings: Partial<Record<ConfigurationBlockKey, unknown>>,
328 keys: ConfigurationBlockKey[],
329): ConfigurationSnapshot => {
330 const snapshot: ConfigurationSnapshot = {};
331 for (const key of keys) {
332 snapshot[key] = sanitizeBlock(observedSettings[key]) ?? null;
333 }
334 return snapshot;
335};
336
337const pickBlocks = (
338 settings: ConfigurationSettings,
339 keys: ConfigurationBlockKey[],
340): Record<string, unknown> => {
341 const picked: Record<string, unknown> = {};
342 for (const key of keys) picked[key] = settings[key];
343 return picked;
344};
345
346/**
347 * Read-only fields Cloudflare reports on settings blocks that the PATCH
348 * endpoint does not accept back.
349 */
350const READ_ONLY_FIELDS = new Set([
351 "readOnly",
352 "sourceAccount",
353 "version",
354 "bindingStatus",
355 "updatedAt",
356]);
357
358/**
359 * Strip read-only fields and `null`-valued leaves from an observed block
360 * so it can be compared with (and patched back as) a desired block.
361 */
362const sanitizeBlock = (block: unknown): unknown => {
363 if (block === null || block === undefined) return undefined;
364 if (Array.isArray(block)) return block.map(sanitizeBlock);
365 if (typeof block === "object") {
366 const out: Record<string, unknown> = {};
367 for (const [k, v] of Object.entries(block as Record<string, unknown>)) {
368 if (READ_ONLY_FIELDS.has(k)) continue;
369 if (v === null || v === undefined) continue;
370 out[k] = sanitizeBlock(v);
371 }
372 return out;
373 }
374 return block;
375};
376
377/**
378 * True when every field declared in `desired` matches `observed`
379 * (recursively). Fields the user did not declare are ignored, so an
380 * observed block carrying extra server-side fields still counts as
381 * converged.
382 */
383const subsetEquals = (desired: unknown, observed: unknown): boolean => {
384 if (desired === undefined) return true;
385 if (
386 desired !== null &&
387 typeof desired === "object" &&
388 !Array.isArray(desired)
389 ) {
390 if (observed === null || observed === undefined) return false;
391 if (typeof observed !== "object" || Array.isArray(observed)) return false;
392 return Object.entries(desired as Record<string, unknown>).every(([k, v]) =>
393 subsetEquals(v, (observed as Record<string, unknown>)[k]),
394 );
395 }
396 return deepEquals(desired, observed ?? null);
397};
398
399const deepEquals = (a: unknown, b: unknown): boolean => {
400 if (a === b) return true;
401 if (a === null || b === null) return false;
402 if (Array.isArray(a) || Array.isArray(b)) {
403 if (!Array.isArray(a) || !Array.isArray(b)) return false;
404 if (a.length !== b.length) return false;
405 return a.every((v, i) => deepEquals(v, b[i]));
406 }
407 if (typeof a === "object" && typeof b === "object") {
408 const ak = Object.keys(a as Record<string, unknown>);
409 const bk = Object.keys(b as Record<string, unknown>);
410 if (ak.length !== bk.length) return false;
411 return ak.every((k) =>
412 deepEquals(
413 (a as Record<string, unknown>)[k],
414 (b as Record<string, unknown>)[k],
415 ),
416 );
417 }
418 return false;
419};
420
421const toAttributes = (
422 accountId: string,
423 observed: zeroTrust.GetGatewayConfigurationResponse,
424 initialSettings: ConfigurationSnapshot,
425): ConfigurationAttributes => ({
426 accountId,
427 settings: observed.settings ?? {},
428 initialSettings,
429 createdAt: observed.createdAt ?? undefined,
430 updatedAt: observed.updatedAt ?? undefined,
431});