| 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 | import * as Stream from "effect/Stream"; |
| 5 | |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { Resource } from "../../Resource.ts"; |
| 10 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 11 | import type { Providers } from "../Providers.ts"; |
| 12 | import type { DeviceDefaultProfile } from "./DefaultProfile.ts"; |
| 13 | |
| 14 | const TypeId = "Cloudflare.Devices.CustomProfile" as const; |
| 15 | type TypeId = typeof TypeId; |
| 16 | |
| 17 | /** |
| 18 | * Configuration for a WARP custom device profile. |
| 19 | * |
| 20 | * A custom profile applies its settings to the subset of devices matched |
| 21 | * by the `match` wirefilter expression, evaluated in ascending |
| 22 | * `precedence` order. Devices not matched by any custom profile fall back |
| 23 | * to the account's default profile. |
| 24 | */ |
| 25 | export interface DeviceCustomProfileProps { |
| 26 | /** |
| 27 | * Name of the device settings profile. If omitted, a unique name is |
| 28 | * generated from the app, stage, and logical ID. |
| 29 | * @default ${app}-${stage}-${id} |
| 30 | */ |
| 31 | name?: string; |
| 32 | /** |
| 33 | * The wirefilter expression to match devices, e.g. |
| 34 | * `identity.email == "user@example.com"` or `os.name == "windows"`. |
| 35 | * Mutable — patched in place. |
| 36 | */ |
| 37 | match: string; |
| 38 | /** |
| 39 | * The precedence of the profile. Lower values indicate higher |
| 40 | * precedence; profiles are evaluated in ascending order. Must be |
| 41 | * unique within the account. |
| 42 | */ |
| 43 | precedence: number; |
| 44 | /** |
| 45 | * Whether the profile is applied to matching devices. |
| 46 | * @default true |
| 47 | */ |
| 48 | enabled?: boolean; |
| 49 | /** |
| 50 | * A human-readable description of the profile. |
| 51 | */ |
| 52 | description?: string; |
| 53 | /** |
| 54 | * Routes that WARP will tunnel for matched devices. Mutually exclusive |
| 55 | * with {@link exclude}. Pushed to the per-profile include list endpoint. |
| 56 | */ |
| 57 | include?: DeviceDefaultProfile.SplitTunnelEntry[]; |
| 58 | /** |
| 59 | * Routes that WARP will bypass for matched devices. Mutually exclusive |
| 60 | * with {@link include}. Pushed to the per-profile exclude list endpoint. |
| 61 | */ |
| 62 | exclude?: DeviceDefaultProfile.SplitTunnelEntry[]; |
| 63 | /** |
| 64 | * Local-fallback DNS suffixes for matched devices. For each suffix, |
| 65 | * WARP resolves matching names via the given DNS servers. |
| 66 | */ |
| 67 | fallbackDomains?: DeviceDefaultProfile.FallbackDomain[]; |
| 68 | /** |
| 69 | * Seconds to wait before activating the captive-portal bypass. A value |
| 70 | * of `0` disables the bypass. |
| 71 | */ |
| 72 | captivePortal?: number; |
| 73 | /** |
| 74 | * Seconds to wait before WARP reconnects after the user disables it. |
| 75 | * `0` keeps WARP disabled until the user re-enables it. |
| 76 | */ |
| 77 | autoConnect?: number; |
| 78 | /** |
| 79 | * Whether the user is allowed to remove the device from the team. |
| 80 | */ |
| 81 | allowedToLeave?: boolean; |
| 82 | /** |
| 83 | * Whether the user is allowed to switch WARP between Gateway-with-WARP |
| 84 | * and other modes. |
| 85 | */ |
| 86 | allowModeSwitch?: boolean; |
| 87 | /** |
| 88 | * Whether WARP should surface available client updates to the user. |
| 89 | */ |
| 90 | allowUpdates?: boolean; |
| 91 | /** |
| 92 | * When `true`, fallback domains without an explicit `dnsServer` are NOT |
| 93 | * resolved via the system resolver — WARP refuses to resolve them. |
| 94 | */ |
| 95 | disableAutoFallback?: boolean; |
| 96 | /** |
| 97 | * When `true`, Microsoft 365 service IP ranges are added to the |
| 98 | * split-tunnel exclude list automatically. |
| 99 | */ |
| 100 | excludeOfficeIps?: boolean; |
| 101 | /** |
| 102 | * When `true`, the user cannot turn the WARP switch off. |
| 103 | */ |
| 104 | switchLocked?: boolean; |
| 105 | /** |
| 106 | * Selects WARP's tunneling behavior: `"warp"` for the full WireGuard |
| 107 | * tunnel, `"proxy"` for a local SOCKS proxy on the chosen `port`. |
| 108 | */ |
| 109 | serviceModeV2?: DeviceDefaultProfile.ServiceModeV2; |
| 110 | /** |
| 111 | * Minutes of LAN access permitted after a WARP reconnect. `0` means |
| 112 | * unrestricted until the next WARP cycle. |
| 113 | */ |
| 114 | lanAllowMinutes?: number; |
| 115 | /** |
| 116 | * Prefix length of the LAN subnet that {@link lanAllowMinutes} applies |
| 117 | * to (e.g. `24` for a `/24`). |
| 118 | */ |
| 119 | lanAllowSubnetSize?: number; |
| 120 | /** |
| 121 | * When `true`, the OS registers WARP's local interface IP with the |
| 122 | * on-premises DNS server. |
| 123 | */ |
| 124 | registerInterfaceIpWithDns?: boolean; |
| 125 | /** |
| 126 | * When `true`, WARP signals SCCM that the device is inside a VPN |
| 127 | * boundary. Windows only. |
| 128 | */ |
| 129 | sccmVpnBoundarySupport?: boolean; |
| 130 | /** |
| 131 | * URL launched when the user clicks "Send Feedback" in the client. |
| 132 | */ |
| 133 | supportUrl?: string; |
| 134 | /** |
| 135 | * Which underlying tunnel protocol WARP should use (e.g. `"wireguard"`, |
| 136 | * `"masque"`). |
| 137 | */ |
| 138 | tunnelProtocol?: string; |
| 139 | } |
| 140 | |
| 141 | export type DeviceCustomProfileAttributes = { |
| 142 | /** API UUID of the profile. */ |
| 143 | policyId: string; |
| 144 | /** Account that owns the profile. */ |
| 145 | accountId: string; |
| 146 | /** Observed profile name. */ |
| 147 | name: string; |
| 148 | /** Observed wirefilter match expression. */ |
| 149 | match: string | undefined; |
| 150 | /** Observed precedence. */ |
| 151 | precedence: number | undefined; |
| 152 | /** Whether the profile is applied to matching devices. */ |
| 153 | enabled: boolean | undefined; |
| 154 | /** Observed description. */ |
| 155 | description: string | undefined; |
| 156 | /** Always `false` — custom profiles are never the default profile. */ |
| 157 | default: boolean; |
| 158 | /** Observed per-profile split-tunnel include list. */ |
| 159 | include: DeviceDefaultProfile.SplitTunnelEntry[]; |
| 160 | /** Observed per-profile split-tunnel exclude list. */ |
| 161 | exclude: DeviceDefaultProfile.SplitTunnelEntry[]; |
| 162 | /** Observed per-profile fallback domains. */ |
| 163 | fallbackDomains: DeviceDefaultProfile.FallbackDomain[]; |
| 164 | /** Observed captive-portal timeout (seconds). */ |
| 165 | captivePortal: number | undefined; |
| 166 | /** Observed auto-connect timeout (seconds). */ |
| 167 | autoConnect: number | undefined; |
| 168 | /** Whether devices are allowed to leave the organization. */ |
| 169 | allowedToLeave: boolean | undefined; |
| 170 | /** Whether the user may switch WARP modes. */ |
| 171 | allowModeSwitch: boolean | undefined; |
| 172 | /** Whether update notifications are shown. */ |
| 173 | allowUpdates: boolean | undefined; |
| 174 | /** Whether auto-fallback to system DNS is disabled. */ |
| 175 | disableAutoFallback: boolean | undefined; |
| 176 | /** Whether Microsoft IPs are auto-excluded. */ |
| 177 | excludeOfficeIps: boolean | undefined; |
| 178 | /** Whether the WARP switch is locked on. */ |
| 179 | switchLocked: boolean | undefined; |
| 180 | /** Tunneling mode configuration. */ |
| 181 | serviceModeV2: DeviceDefaultProfile.ServiceModeV2 | undefined; |
| 182 | /** Whether the WARP IP is registered with the on-prem DNS server. */ |
| 183 | registerInterfaceIpWithDns: boolean | undefined; |
| 184 | /** Whether SCCM VPN-boundary support is enabled. */ |
| 185 | sccmVpnBoundarySupport: boolean | undefined; |
| 186 | /** Send-Feedback URL. */ |
| 187 | supportUrl: string | undefined; |
| 188 | /** Tunnel protocol selection. */ |
| 189 | tunnelProtocol: string | undefined; |
| 190 | }; |
| 191 | |
| 192 | export type DeviceCustomProfile = Resource< |
| 193 | TypeId, |
| 194 | DeviceCustomProfileProps, |
| 195 | DeviceCustomProfileAttributes, |
| 196 | never, |
| 197 | Providers |
| 198 | >; |
| 199 | |
| 200 | /** |
| 201 | * A Cloudflare WARP **custom device profile** — a settings profile applied |
| 202 | * to the subset of devices matched by a wirefilter `match` expression at a |
| 203 | * given `precedence`. |
| 204 | * |
| 205 | * All properties are mutable in place: the profile itself is patched, and |
| 206 | * the per-profile split-tunnel include/exclude and fallback-domain lists |
| 207 | * are replaced via their dedicated endpoints. Deleting the resource |
| 208 | * deletes the profile; matched devices fall back to the account's default |
| 209 | * profile. |
| 210 | * ### Creating a profile |
| 211 | * **Example:** Profile for a user group |
| 212 | * ```typescript |
| 213 | * const profile = yield* Cloudflare.Devices.DeviceCustomProfile("Contractors", { |
| 214 | * match: 'identity.groups.name == "contractors"', |
| 215 | * precedence: 100, |
| 216 | * description: "Locked-down profile for contractors", |
| 217 | * switchLocked: true, |
| 218 | * }); |
| 219 | * ``` |
| 220 | * |
| 221 | * ### Split tunneling |
| 222 | * **Example:** Exclude internal ranges from the tunnel |
| 223 | * ```typescript |
| 224 | * yield* Cloudflare.Devices.DeviceCustomProfile("Engineering", { |
| 225 | * match: 'identity.groups.name == "engineering"', |
| 226 | * precedence: 50, |
| 227 | * exclude: [ |
| 228 | * { address: "10.0.0.0/8", description: "RFC1918" }, |
| 229 | * ], |
| 230 | * }); |
| 231 | * ``` |
| 232 | * |
| 233 | * ### Fallback domains |
| 234 | * **Example:** Resolve a private suffix via an on-prem DNS server |
| 235 | * ```typescript |
| 236 | * yield* Cloudflare.Devices.DeviceCustomProfile("CorpDns", { |
| 237 | * match: 'identity.email matches ".*@corp.example.com"', |
| 238 | * precedence: 10, |
| 239 | * fallbackDomains: [ |
| 240 | * { suffix: "corp.example.com", dnsServer: ["10.0.0.53"] }, |
| 241 | * ], |
| 242 | * }); |
| 243 | * ``` |
| 244 | * |
| 245 | * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/warp/configure-warp/device-profiles/ |
| 246 | * |
| 247 | * @resource |
| 248 | * @product Devices |
| 249 | * @category Cloudflare One (Zero Trust) |
| 250 | */ |
| 251 | export const DeviceCustomProfile = Resource<DeviceCustomProfile>(TypeId); |
| 252 | |
| 253 | /** |
| 254 | * Returns true if the given value is a DeviceCustomProfile resource. |
| 255 | */ |
| 256 | export const isDeviceCustomProfile = ( |
| 257 | value: unknown, |
| 258 | ): value is DeviceCustomProfile => |
| 259 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 260 | |
| 261 | export const DeviceCustomProfileProvider = () => |
| 262 | Provider.succeed(DeviceCustomProfile, { |
| 263 | stables: ["policyId", "accountId", "default"], |
| 264 | |
| 265 | read: Effect.fn(function* ({ id, output, olds }) { |
| 266 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 267 | const acct = output?.accountId ?? accountId; |
| 268 | |
| 269 | // Owned path: refresh by our persisted policy id. |
| 270 | if (output?.policyId) { |
| 271 | const observed = yield* observeProfile(acct, output.policyId); |
| 272 | return observed ? yield* buildAttrs(acct, observed) : undefined; |
| 273 | } |
| 274 | |
| 275 | // Cold lookup: recover from lost state by exact name. Profile names |
| 276 | // carry no ownership markers, so brand the match `Unowned` and let |
| 277 | // the engine gate takeover behind the adopt policy. |
| 278 | const name = yield* createProfileName(id, olds?.name); |
| 279 | const match = yield* findByName(acct, name); |
| 280 | if (match?.policyId) { |
| 281 | const observed = yield* observeProfile(acct, match.policyId); |
| 282 | if (observed) return Unowned(yield* buildAttrs(acct, observed)); |
| 283 | } |
| 284 | return undefined; |
| 285 | }), |
| 286 | |
| 287 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 288 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 289 | const name = yield* createProfileName(id, news.name); |
| 290 | |
| 291 | // 1. Observe — the policy id cached on `output` is a hint, not a |
| 292 | // guarantee: a missing profile falls through to create. |
| 293 | let observed = output?.policyId |
| 294 | ? yield* observeProfile(accountId, output.policyId) |
| 295 | : undefined; |
| 296 | |
| 297 | // 2. Ensure — create when missing. Names are not unique on |
| 298 | // Cloudflare's side, so there is no AlreadyExists race; a |
| 299 | // precedence collision is a real validation error and propagates. |
| 300 | if (!observed) { |
| 301 | const created = yield* zeroTrust.createDevicePolicyCustom({ |
| 302 | accountId, |
| 303 | name, |
| 304 | match: news.match, |
| 305 | precedence: news.precedence, |
| 306 | enabled: news.enabled, |
| 307 | description: news.description, |
| 308 | include: news.include?.map(encodeSplit), |
| 309 | exclude: news.exclude?.map(encodeSplit), |
| 310 | captivePortal: news.captivePortal, |
| 311 | autoConnect: news.autoConnect, |
| 312 | allowedToLeave: news.allowedToLeave, |
| 313 | allowModeSwitch: news.allowModeSwitch, |
| 314 | allowUpdates: news.allowUpdates, |
| 315 | disableAutoFallback: news.disableAutoFallback, |
| 316 | excludeOfficeIps: news.excludeOfficeIps, |
| 317 | switchLocked: news.switchLocked, |
| 318 | serviceModeV2: news.serviceModeV2, |
| 319 | lanAllowMinutes: news.lanAllowMinutes, |
| 320 | lanAllowSubnetSize: news.lanAllowSubnetSize, |
| 321 | registerInterfaceIpWithDns: news.registerInterfaceIpWithDns, |
| 322 | sccmVpnBoundarySupport: news.sccmVpnBoundarySupport, |
| 323 | supportUrl: news.supportUrl, |
| 324 | tunnelProtocol: news.tunnelProtocol, |
| 325 | }); |
| 326 | const policyId = created?.policyId; |
| 327 | if (!policyId) { |
| 328 | return yield* Effect.fail( |
| 329 | new Error( |
| 330 | "Cloudflare did not return a policy id for the created custom device profile", |
| 331 | ), |
| 332 | ); |
| 333 | } |
| 334 | observed = yield* observeProfile(accountId, policyId); |
| 335 | if (!observed) { |
| 336 | return yield* Effect.fail( |
| 337 | new Error( |
| 338 | `custom device profile ${policyId} disappeared right after create`, |
| 339 | ), |
| 340 | ); |
| 341 | } |
| 342 | } |
| 343 | const policyId = observed.policyId!; |
| 344 | |
| 345 | // 3. Sync (a) — patch the profile body with only the changed fields. |
| 346 | const patch: zeroTrust.PatchDevicePolicyCustomRequest = { |
| 347 | accountId, |
| 348 | policyId, |
| 349 | }; |
| 350 | let dirty = false; |
| 351 | const setIf = < |
| 352 | K extends Exclude< |
| 353 | keyof zeroTrust.PatchDevicePolicyCustomRequest, |
| 354 | "accountId" | "policyId" |
| 355 | >, |
| 356 | >( |
| 357 | key: K, |
| 358 | desired: zeroTrust.PatchDevicePolicyCustomRequest[K], |
| 359 | observedValue: unknown, |
| 360 | ) => { |
| 361 | if (desired === undefined) return; |
| 362 | if (!sameJSON(desired, observedValue)) { |
| 363 | patch[key] = desired; |
| 364 | dirty = true; |
| 365 | } |
| 366 | }; |
| 367 | setIf("name", name, observed.name ?? undefined); |
| 368 | setIf("match", news.match, denull(observed.match)); |
| 369 | setIf("precedence", news.precedence, denull(observed.precedence)); |
| 370 | setIf("enabled", news.enabled, denull(observed.enabled)); |
| 371 | setIf("description", news.description, denull(observed.description)); |
| 372 | setIf( |
| 373 | "captivePortal", |
| 374 | news.captivePortal, |
| 375 | denull(observed.captivePortal), |
| 376 | ); |
| 377 | setIf("autoConnect", news.autoConnect, denull(observed.autoConnect)); |
| 378 | setIf( |
| 379 | "allowedToLeave", |
| 380 | news.allowedToLeave, |
| 381 | denull(observed.allowedToLeave), |
| 382 | ); |
| 383 | setIf( |
| 384 | "allowModeSwitch", |
| 385 | news.allowModeSwitch, |
| 386 | denull(observed.allowModeSwitch), |
| 387 | ); |
| 388 | setIf("allowUpdates", news.allowUpdates, denull(observed.allowUpdates)); |
| 389 | setIf( |
| 390 | "disableAutoFallback", |
| 391 | news.disableAutoFallback, |
| 392 | denull(observed.disableAutoFallback), |
| 393 | ); |
| 394 | setIf( |
| 395 | "excludeOfficeIps", |
| 396 | news.excludeOfficeIps, |
| 397 | denull(observed.excludeOfficeIps), |
| 398 | ); |
| 399 | setIf("switchLocked", news.switchLocked, denull(observed.switchLocked)); |
| 400 | setIf( |
| 401 | "registerInterfaceIpWithDns", |
| 402 | news.registerInterfaceIpWithDns, |
| 403 | denull(observed.registerInterfaceIpWithDns), |
| 404 | ); |
| 405 | setIf( |
| 406 | "sccmVpnBoundarySupport", |
| 407 | news.sccmVpnBoundarySupport, |
| 408 | denull(observed.sccmVpnBoundarySupport), |
| 409 | ); |
| 410 | setIf("supportUrl", news.supportUrl, denull(observed.supportUrl)); |
| 411 | setIf( |
| 412 | "tunnelProtocol", |
| 413 | news.tunnelProtocol, |
| 414 | denull(observed.tunnelProtocol), |
| 415 | ); |
| 416 | if ( |
| 417 | news.serviceModeV2 !== undefined && |
| 418 | !sameJSON( |
| 419 | news.serviceModeV2, |
| 420 | normalizeServiceMode(observed.serviceModeV2), |
| 421 | ) |
| 422 | ) { |
| 423 | patch.serviceModeV2 = news.serviceModeV2; |
| 424 | dirty = true; |
| 425 | } |
| 426 | // lanAllow* are accepted by PATCH but not reliably echoed on GET, |
| 427 | // so push them whenever the user sets them — the API is idempotent. |
| 428 | if (news.lanAllowMinutes !== undefined) { |
| 429 | patch.lanAllowMinutes = news.lanAllowMinutes; |
| 430 | dirty = true; |
| 431 | } |
| 432 | if (news.lanAllowSubnetSize !== undefined) { |
| 433 | patch.lanAllowSubnetSize = news.lanAllowSubnetSize; |
| 434 | dirty = true; |
| 435 | } |
| 436 | if (dirty) { |
| 437 | yield* zeroTrust.patchDevicePolicyCustom(patch); |
| 438 | } |
| 439 | |
| 440 | // 3. Sync (b) — replace the per-profile lists when they differ from |
| 441 | // the observed cloud state. |
| 442 | const lists = yield* observeLists(accountId, policyId); |
| 443 | if ( |
| 444 | news.include !== undefined && |
| 445 | !sameJSON(news.include, lists.include) |
| 446 | ) { |
| 447 | yield* zeroTrust.putDevicePolicyCustomInclude({ |
| 448 | accountId, |
| 449 | policyId, |
| 450 | body: news.include.map(encodeSplit), |
| 451 | }); |
| 452 | } |
| 453 | if ( |
| 454 | news.exclude !== undefined && |
| 455 | !sameJSON(news.exclude, lists.exclude) |
| 456 | ) { |
| 457 | yield* zeroTrust.putDevicePolicyCustomExclude({ |
| 458 | accountId, |
| 459 | policyId, |
| 460 | body: news.exclude.map(encodeSplit), |
| 461 | }); |
| 462 | } |
| 463 | if ( |
| 464 | news.fallbackDomains !== undefined && |
| 465 | !sameJSON(news.fallbackDomains, lists.fallbackDomains) |
| 466 | ) { |
| 467 | yield* zeroTrust.putDevicePolicyCustomFallbackDomain({ |
| 468 | accountId, |
| 469 | policyId, |
| 470 | domains: news.fallbackDomains.map(encodeFallback), |
| 471 | }); |
| 472 | } |
| 473 | |
| 474 | // 4. Return — re-read so attrs reflect post-sync truth. |
| 475 | const final = yield* observeProfile(accountId, policyId); |
| 476 | if (!final) { |
| 477 | return yield* Effect.fail( |
| 478 | new Error( |
| 479 | `custom device profile ${policyId} disappeared during reconcile`, |
| 480 | ), |
| 481 | ); |
| 482 | } |
| 483 | return yield* buildAttrs(accountId, final); |
| 484 | }), |
| 485 | |
| 486 | delete: Effect.fn(function* ({ output }) { |
| 487 | yield* zeroTrust |
| 488 | .deleteDevicePolicyCustom({ |
| 489 | accountId: output.accountId, |
| 490 | policyId: output.policyId, |
| 491 | }) |
| 492 | .pipe(Effect.catchTag("DevicePolicyNotFound", () => Effect.void)); |
| 493 | }), |
| 494 | |
| 495 | // Account collection: enumerate every custom device profile in the |
| 496 | // ambient account, then hydrate each into the exact `read` Attributes |
| 497 | // shape (same `observeProfile` + `buildAttrs` path read uses, so the |
| 498 | // per-profile split-tunnel/fallback lists are fetched too). Bounded |
| 499 | // concurrency keeps the fan-out polite; a profile that vanishes |
| 500 | // mid-enumeration is dropped via the typed `DevicePolicyNotFound` map. |
| 501 | list: Effect.fn(function* () { |
| 502 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 503 | const items = yield* zeroTrust.listDevicePolicyCustoms |
| 504 | .pages({ accountId }) |
| 505 | .pipe( |
| 506 | Stream.runCollect, |
| 507 | Effect.map((chunk) => |
| 508 | Array.from(chunk).flatMap((page) => page.result ?? []), |
| 509 | ), |
| 510 | ); |
| 511 | const rows = yield* Effect.forEach( |
| 512 | items.filter((p) => p.policyId != null), |
| 513 | (p) => |
| 514 | Effect.gen(function* () { |
| 515 | const observed = yield* observeProfile(accountId, p.policyId!); |
| 516 | return observed |
| 517 | ? yield* buildAttrs(accountId, observed) |
| 518 | : undefined; |
| 519 | }), |
| 520 | { concurrency: 10 }, |
| 521 | ); |
| 522 | return rows.filter( |
| 523 | (row): row is DeviceCustomProfileAttributes => row !== undefined, |
| 524 | ); |
| 525 | }), |
| 526 | }); |
| 527 | |
| 528 | type ObservedProfile = NonNullable<zeroTrust.GetDevicePolicyCustomResponse>; |
| 529 | |
| 530 | /** |
| 531 | * Read a custom profile by id, mapping "gone" (`DevicePolicyNotFound`, |
| 532 | * Cloudflare error code 2052) to `undefined`. |
| 533 | */ |
| 534 | const observeProfile = (accountId: string, policyId: string) => |
| 535 | zeroTrust.getDevicePolicyCustom({ accountId, policyId }).pipe( |
| 536 | Effect.map((p): ObservedProfile | undefined => p ?? undefined), |
| 537 | Effect.catchTag("DevicePolicyNotFound", () => Effect.succeed(undefined)), |
| 538 | ); |
| 539 | |
| 540 | /** |
| 541 | * Read the three per-profile list endpoints. |
| 542 | */ |
| 543 | const observeLists = Effect.fn(function* (accountId: string, policyId: string) { |
| 544 | const [include, exclude, fallback] = yield* Effect.all( |
| 545 | [ |
| 546 | zeroTrust.getDevicePolicyCustomInclude({ accountId, policyId }), |
| 547 | zeroTrust.getDevicePolicyCustomExclude({ accountId, policyId }), |
| 548 | zeroTrust.getDevicePolicyCustomFallbackDomain({ accountId, policyId }), |
| 549 | ], |
| 550 | { concurrency: "unbounded" }, |
| 551 | ); |
| 552 | return { |
| 553 | include: (include.result ?? []).map(normalizeSplit), |
| 554 | exclude: (exclude.result ?? []).map(normalizeSplit), |
| 555 | fallbackDomains: (fallback.result ?? []).map(normalizeFallback), |
| 556 | }; |
| 557 | }); |
| 558 | |
| 559 | /** |
| 560 | * Find a profile by exact name via the list endpoint (oldest-id-first for |
| 561 | * determinism when names collide). |
| 562 | */ |
| 563 | const findByName = (accountId: string, name: string) => |
| 564 | zeroTrust.listDevicePolicyCustoms.items({ accountId }).pipe( |
| 565 | Stream.filter((p) => p.name === name && p.policyId != null), |
| 566 | Stream.runCollect, |
| 567 | Effect.map((chunk) => |
| 568 | Array.from(chunk) |
| 569 | .sort((a, b) => (a.policyId ?? "").localeCompare(b.policyId ?? "")) |
| 570 | .at(0), |
| 571 | ), |
| 572 | ); |
| 573 | |
| 574 | const createProfileName = (id: string, name: string | undefined) => |
| 575 | Effect.gen(function* () { |
| 576 | return name ?? (yield* createPhysicalName({ id, lowercase: true })); |
| 577 | }); |
| 578 | |
| 579 | const buildAttrs = Effect.fn(function* ( |
| 580 | accountId: string, |
| 581 | profile: ObservedProfile, |
| 582 | ) { |
| 583 | const policyId = profile.policyId!; |
| 584 | const lists = yield* observeLists(accountId, policyId); |
| 585 | const attrs: DeviceCustomProfileAttributes = { |
| 586 | policyId, |
| 587 | accountId, |
| 588 | name: profile.name ?? "", |
| 589 | match: denull(profile.match), |
| 590 | precedence: denull(profile.precedence), |
| 591 | enabled: denull(profile.enabled), |
| 592 | description: denull(profile.description), |
| 593 | default: profile.default ?? false, |
| 594 | include: lists.include, |
| 595 | exclude: lists.exclude, |
| 596 | fallbackDomains: lists.fallbackDomains, |
| 597 | captivePortal: denull(profile.captivePortal), |
| 598 | autoConnect: denull(profile.autoConnect), |
| 599 | allowedToLeave: denull(profile.allowedToLeave), |
| 600 | allowModeSwitch: denull(profile.allowModeSwitch), |
| 601 | allowUpdates: denull(profile.allowUpdates), |
| 602 | disableAutoFallback: denull(profile.disableAutoFallback), |
| 603 | excludeOfficeIps: denull(profile.excludeOfficeIps), |
| 604 | switchLocked: denull(profile.switchLocked), |
| 605 | serviceModeV2: normalizeServiceMode(profile.serviceModeV2), |
| 606 | registerInterfaceIpWithDns: denull(profile.registerInterfaceIpWithDns), |
| 607 | sccmVpnBoundarySupport: denull(profile.sccmVpnBoundarySupport), |
| 608 | supportUrl: denull(profile.supportUrl), |
| 609 | tunnelProtocol: denull(profile.tunnelProtocol), |
| 610 | }; |
| 611 | return attrs; |
| 612 | }); |
| 613 | |
| 614 | /** |
| 615 | * Strip Cloudflare's `null` echoes to `undefined` so structural equality |
| 616 | * (`JSON.stringify`) works. |
| 617 | */ |
| 618 | const denull = <T>(v: T | null | undefined): T | undefined => |
| 619 | v == null ? undefined : v; |
| 620 | |
| 621 | const normalizeServiceMode = ( |
| 622 | sm: { mode?: string | null; port?: number | null } | null | undefined, |
| 623 | ): DeviceDefaultProfile.ServiceModeV2 | undefined => |
| 624 | sm && sm.mode != null |
| 625 | ? { |
| 626 | mode: (sm.mode === "proxy" ? "proxy" : "warp") as "warp" | "proxy", |
| 627 | port: denull(sm.port), |
| 628 | } |
| 629 | : undefined; |
| 630 | |
| 631 | const normalizeSplit = ( |
| 632 | entry: |
| 633 | | { address: string; description?: string | null } |
| 634 | | { host: string; description?: string | null }, |
| 635 | ): DeviceDefaultProfile.SplitTunnelEntry => ({ |
| 636 | address: "address" in entry ? entry.address : "", |
| 637 | host: "host" in entry ? entry.host : undefined, |
| 638 | description: denull(entry.description), |
| 639 | }); |
| 640 | |
| 641 | const normalizeFallback = (entry: { |
| 642 | suffix: string; |
| 643 | description?: string | null; |
| 644 | dnsServer?: string[] | null; |
| 645 | }): DeviceDefaultProfile.FallbackDomain => ({ |
| 646 | suffix: entry.suffix, |
| 647 | description: denull(entry.description), |
| 648 | dnsServer: denull(entry.dnsServer), |
| 649 | }); |
| 650 | |
| 651 | /** |
| 652 | * Encode a SplitTunnelEntry for the API's union shape (`address` OR `host`). |
| 653 | * Prefers `host` when set since the address would otherwise be empty. |
| 654 | */ |
| 655 | const encodeSplit = ( |
| 656 | e: DeviceDefaultProfile.SplitTunnelEntry, |
| 657 | ): |
| 658 | | { address: string; description?: string } |
| 659 | | { host: string; description?: string } => { |
| 660 | if (e.host && !e.address) { |
| 661 | return e.description !== undefined |
| 662 | ? { host: e.host, description: e.description } |
| 663 | : { host: e.host }; |
| 664 | } |
| 665 | return e.description !== undefined |
| 666 | ? { address: e.address, description: e.description } |
| 667 | : { address: e.address }; |
| 668 | }; |
| 669 | |
| 670 | const encodeFallback = ( |
| 671 | d: DeviceDefaultProfile.FallbackDomain, |
| 672 | ): { suffix: string; description?: string; dnsServer?: string[] } => { |
| 673 | const out: { suffix: string; description?: string; dnsServer?: string[] } = { |
| 674 | suffix: d.suffix, |
| 675 | }; |
| 676 | if (d.description !== undefined) out.description = d.description; |
| 677 | if (d.dnsServer !== undefined) out.dnsServer = d.dnsServer; |
| 678 | return out; |
| 679 | }; |
| 680 | |
| 681 | /** Structural deep-equality via canonical JSON. */ |
| 682 | const sameJSON = (a: unknown, b: unknown): boolean => |
| 683 | JSON.stringify(a) === JSON.stringify(b); |