| 1 | import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | |
| 5 | import * as Provider from "../../Provider.ts"; |
| 6 | import { Resource } from "../../Resource.ts"; |
| 7 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 8 | import type { Providers } from "../Providers.ts"; |
| 9 | |
| 10 | const TypeId = "Cloudflare.Gateway.Configuration" as const; |
| 11 | type 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 | */ |
| 18 | export 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 | |
| 92 | export type ConfigurationBlockKey = keyof ConfigurationSettings; |
| 93 | |
| 94 | export 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 | */ |
| 109 | export type ConfigurationSnapshot = Partial< |
| 110 | Record<ConfigurationBlockKey, unknown> |
| 111 | >; |
| 112 | |
| 113 | export 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 | |
| 129 | export 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 | */ |
| 193 | export const Configuration = Resource<Configuration>(TypeId); |
| 194 | |
| 195 | /** |
| 196 | * Returns true if the given value is a Configuration resource. |
| 197 | */ |
| 198 | export const isConfiguration = (value: unknown): value is Configuration => |
| 199 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 200 | |
| 201 | export 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 | |
| 313 | const 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 | */ |
| 326 | const 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 | |
| 337 | const 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 | */ |
| 350 | const 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 | */ |
| 362 | const 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 | */ |
| 383 | const 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 | |
| 399 | const 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 | |
| 421 | const 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 | }); |