1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { arrayEqualsUnordered } from "../../Util/equal.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14
15const TypeId = "Cloudflare.Gateway.Location" as const;
16type TypeId = typeof TypeId;
17
18/**
19 * A source network range (CIDR) requests at a Gateway location may
20 * originate from.
21 */
22export interface LocationNetwork {
23 /**
24 * IPv4 CIDR, e.g. `203.0.113.0/24`.
25 */
26 network: string;
27}
28
29/**
30 * Destination endpoint configuration for a Gateway location. Re-exports
31 * distilled's request shape so every server-recognised knob (DoH networks
32 * and token requirement, DoT, IPv4/IPv6 toggles) is available without
33 * re-declaring the structure.
34 */
35export type LocationEndpoints = NonNullable<
36 zeroTrust.UpdateGatewayLocationRequest["endpoints"]
37>;
38
39export interface LocationProps {
40 /**
41 * Display name for the location. Used as a stable identifier so the
42 * provider can locate it by name during adoption / state recovery. If
43 * omitted, a unique name is generated from the app, stage, and logical ID.
44 *
45 * @default ${app}-${stage}-${id}
46 */
47 name?: string;
48 /**
49 * Whether this location is the default for the account. Cloudflare
50 * rejects demoting the current default directly — promote another
51 * location instead. Mutable.
52 *
53 * @default false
54 */
55 clientDefault?: boolean;
56 /**
57 * Whether the location must resolve EDNS (EDNS Client Subnet) queries.
58 * Mutable.
59 *
60 * @default false
61 */
62 ecsSupport?: boolean;
63 /**
64 * Identifier of the pair of DNS destination IPv4 addresses assigned to
65 * this location. When absent, Cloudflare's shared IPv4 pair is used.
66 * Mutable.
67 */
68 dnsDestinationIpsId?: string;
69 /**
70 * Destination endpoint configuration (DoH / DoT / IPv4 / IPv6 toggles
71 * and source networks). Mutable.
72 */
73 endpoints?: LocationEndpoints;
74 /**
75 * Source network ranges (IPv4 CIDRs) requests at this location originate
76 * from. Only takes effect when non-empty and the IPv4 endpoint is
77 * enabled. Mutable.
78 *
79 * @default []
80 */
81 networks?: LocationNetwork[];
82}
83
84export interface LocationAttributes {
85 /** UUID of the location, assigned by Cloudflare. */
86 locationId: string;
87 /** Cloudflare account that owns the location. */
88 accountId: string;
89 /** Display name of the location. */
90 name: string;
91 /** Whether this location is the account default. */
92 clientDefault: boolean;
93 /** Whether the location resolves EDNS queries. */
94 ecsSupport: boolean;
95 /**
96 * Server-generated DNS-over-HTTPS subdomain that receives this
97 * location's DNS requests (`https://<dohSubdomain>.cloudflare-gateway.com/dns-query`).
98 */
99 dohSubdomain: string | undefined;
100 /** Auto-generated IPv6 destination IP assigned to this location. */
101 ip: string | undefined;
102 /** Primary DNS destination IPv4 address (read-only, server-assigned). */
103 ipv4Destination: string | undefined;
104 /** Identifier of the DNS destination IPv4 pair, if a dedicated pair is assigned. */
105 dnsDestinationIpsId: string | undefined;
106 /** Source network ranges configured for this location. */
107 networks: LocationNetwork[];
108 /** ISO8601 creation timestamp. */
109 createdAt: string | undefined;
110 /** ISO8601 last-update timestamp. */
111 updatedAt: string | undefined;
112}
113
114export type Location = Resource<
115 TypeId,
116 LocationProps,
117 LocationAttributes,
118 never,
119 Providers
120>;
121
122/**
123 * A Cloudflare Zero Trust Gateway DNS location — a configured source of
124 * DNS traffic (an office, a home network, a device fleet) with its own
125 * DNS-over-HTTPS endpoint and optional dedicated destination IPs.
126 *
127 * Cloudflare assigns each location a stable `dohSubdomain`; point your
128 * network's DoH resolver at
129 * `https://<dohSubdomain>.cloudflare-gateway.com/dns-query` and Gateway
130 * DNS policies apply to its traffic. All declared properties converge in
131 * place — nothing on a location forces a replacement.
132 * ### Creating a Location
133 * **Example:** DoH-only location
134 * ```typescript
135 * const office = yield* Cloudflare.Gateway.Location("Office", {
136 * ecsSupport: false,
137 * });
138 * // Point your resolver at the assigned DoH endpoint:
139 * const doh = office.dohSubdomain;
140 * ```
141 *
142 * **Example:** Location with IPv4 source networks
143 * ```typescript
144 * const office = yield* Cloudflare.Gateway.Location("Office", {
145 * networks: [{ network: "203.0.113.0/24" }],
146 * endpoints: {
147 * doh: { enabled: true },
148 * dot: { enabled: false },
149 * ipv4: { enabled: true },
150 * ipv6: { enabled: false },
151 * },
152 * });
153 * ```
154 *
155 * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/agentless/dns/locations/
156 *
157 * @resource
158 * @product Gateway
159 * @category Cloudflare One (Zero Trust)
160 */
161export const Location = Resource<Location>(TypeId);
162
163/**
164 * Returns true if the given value is a Location resource.
165 */
166export const isLocation = (value: unknown): value is Location =>
167 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
168
169/**
170 * Right after a location create/update, Cloudflare's edge intermittently
171 * answers the write with a 401 `Unauthorized` carrying "This account does
172 * not have access to this feature." even though the account is fully
173 * entitled — the same transient edge/token-propagation blip the read path
174 * rides out as a 403 `Forbidden`. It clears within a few hundred ms, so we
175 * retry the mutation while we see exactly that message. A genuine
176 * entitlement loss carries the same tag, but the bounded retry surfaces it
177 * quickly instead of looping. Matching the message keeps real auth failures
178 * (a bad/expired token) non-retryable.
179 */
180const isTransientFeatureAccessBlip = (e: {
181 readonly _tag: string;
182 readonly message?: string;
183}): boolean =>
184 e._tag === "Unauthorized" &&
185 (e.message ?? "").includes("does not have access to this feature");
186
187export const LocationProvider = () =>
188 Provider.succeed(Location, {
189 stables: ["locationId", "accountId", "dohSubdomain", "ip", "createdAt"],
190
191 // Account-scoped collection: enumerate every Gateway location in the
192 // ambient account, exhaustively paginating, and hydrate each into the
193 // exact `read` Attributes shape. The list response already carries the
194 // full per-location state, so no follow-up get is needed.
195 list: Effect.fn(function* () {
196 const { accountId } = yield* yield* CloudflareEnvironment;
197 return yield* zeroTrust.listGatewayLocations.pages({ accountId }).pipe(
198 Stream.runCollect,
199 Effect.map((chunk) =>
200 Array.from(chunk).flatMap((page) =>
201 (page.result ?? [])
202 .map((l) => toAttributes(l, accountId))
203 // The account's default location can't be deleted while it is
204 // the client default (`CannotDeleteDefaultGatewayLocation`);
205 // never enumerate it for account-wide teardown.
206 .filter((l) => !l.clientDefault),
207 ),
208 ),
209 );
210 }),
211
212 diff: Effect.fn(function* ({ output }) {
213 const { accountId } = yield* yield* CloudflareEnvironment;
214 if ((output?.accountId ?? accountId) !== accountId) {
215 return { action: "replace" } as const;
216 }
217 // Everything declared on a location converges via PUT.
218 return undefined;
219 }),
220
221 read: Effect.fn(function* ({ id, output, olds }) {
222 const { accountId } = yield* yield* CloudflareEnvironment;
223 const acct = output?.accountId ?? accountId;
224
225 // Owned path — refresh by the cached location id.
226 if (output?.locationId) {
227 const observed = yield* getLocation(acct, output.locationId);
228 if (observed) return toAttributes(observed, acct);
229 }
230
231 // Cold read — locate by deterministic name. Locations carry no
232 // ownership markers, so report the match as Unowned to gate adoption.
233 const name = yield* resolveName(id, olds?.name ?? output?.name);
234 const match = yield* findByName(acct, name);
235 if (match) return Unowned(toAttributes(match, acct));
236 return undefined;
237 }),
238
239 reconcile: Effect.fn(function* ({ id, news, output }) {
240 const { accountId } = yield* yield* CloudflareEnvironment;
241 const name = yield* resolveName(id, news.name);
242
243 // 1. Observe — the cached id is a hint; fall back to a name scan so
244 // out-of-band deletes / lost state converge.
245 let observed = output?.locationId
246 ? yield* getLocation(accountId, output.locationId)
247 : undefined;
248 if (!observed) {
249 observed = yield* findByName(accountId, name);
250 }
251
252 // 2. Ensure — create with the full desired body when missing. Names
253 // are not unique on Cloudflare's side, so there is no
254 // AlreadyExists race to tolerate.
255 if (!observed) {
256 const created = yield* zeroTrust
257 .createGatewayLocation({
258 accountId,
259 name,
260 clientDefault: news.clientDefault,
261 ecsSupport: news.ecsSupport,
262 dnsDestinationIpsId: news.dnsDestinationIpsId,
263 endpoints: news.endpoints,
264 networks: news.networks,
265 })
266 // See `isTransientFeatureAccessBlip` — ride out the transient
267 // 401 "does not have access to this feature" edge blip.
268 .pipe(
269 Effect.retry({
270 while: isTransientFeatureAccessBlip,
271 schedule: Schedule.exponential("500 millis"),
272 times: 8,
273 }),
274 );
275 return toAttributes(created, accountId);
276 }
277
278 // 3. Sync — diff observed cloud state against desired; the update
279 // API is a PUT of the full mutable state, so send everything but
280 // skip the call entirely on a no-op. Networks compare as an
281 // unordered set; endpoints stringify-compare only when declared.
282 const desired = {
283 name,
284 clientDefault: news.clientDefault ?? observed.clientDefault ?? false,
285 ecsSupport: news.ecsSupport ?? observed.ecsSupport ?? false,
286 dnsDestinationIpsId: news.dnsDestinationIpsId,
287 endpoints: news.endpoints,
288 networks: news.networks,
289 };
290 const dirty =
291 observed.name !== desired.name ||
292 (observed.clientDefault ?? false) !== desired.clientDefault ||
293 (observed.ecsSupport ?? false) !== desired.ecsSupport ||
294 (news.dnsDestinationIpsId !== undefined &&
295 observed.dnsDestinationIpsId !== news.dnsDestinationIpsId) ||
296 (news.networks !== undefined &&
297 !sameNetworks(observed.networks ?? [], news.networks)) ||
298 (news.endpoints !== undefined &&
299 !sameEndpoints(observed.endpoints, news.endpoints));
300 if (dirty) {
301 const updated = yield* zeroTrust
302 .updateGatewayLocation({
303 accountId,
304 locationId: observed.id!,
305 name: desired.name,
306 clientDefault: desired.clientDefault,
307 ecsSupport: desired.ecsSupport,
308 dnsDestinationIpsId: desired.dnsDestinationIpsId,
309 endpoints: desired.endpoints,
310 networks: desired.networks,
311 })
312 // See `isTransientFeatureAccessBlip` — ride out the transient
313 // 401 "does not have access to this feature" edge blip.
314 .pipe(
315 Effect.retry({
316 while: isTransientFeatureAccessBlip,
317 schedule: Schedule.exponential("500 millis"),
318 times: 8,
319 }),
320 );
321 return toAttributes(updated, accountId);
322 }
323
324 return toAttributes(observed, accountId);
325 }),
326
327 delete: Effect.fn(function* ({ output }) {
328 // Deleting the default location while it is the client default is a
329 // non-retryable 4xx (CannotDeleteDefaultGatewayLocation, code 1217)
330 // and propagates. A missing location (GatewayLocationNotFound, code
331 // 1103) means we're done.
332 yield* zeroTrust
333 .deleteGatewayLocation({
334 accountId: output.accountId,
335 locationId: output.locationId,
336 })
337 .pipe(Effect.catchTag("GatewayLocationNotFound", () => Effect.void));
338 }),
339 });
340
341type ObservedLocation = zeroTrust.GetGatewayLocationResponse;
342
343/**
344 * Read a location by id, mapping "gone" (`GatewayLocationNotFound`,
345 * Cloudflare error code 1103) to `undefined`.
346 */
347const getLocation = (accountId: string, locationId: string) =>
348 zeroTrust.getGatewayLocation({ accountId, locationId }).pipe(
349 Effect.map((l): ObservedLocation | undefined => l),
350 Effect.catchTag("GatewayLocationNotFound", () => Effect.succeed(undefined)),
351 );
352
353/**
354 * Find a location by exact name. Names are not unique on Cloudflare's
355 * side; pick the oldest match for determinism.
356 */
357const findByName = (accountId: string, name: string) =>
358 zeroTrust.listGatewayLocations.items({ accountId }).pipe(
359 Stream.filter((l) => l.name === name),
360 Stream.runCollect,
361 Effect.map(
362 (chunk): ObservedLocation | undefined =>
363 Array.from(chunk)
364 .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""))
365 .at(0) as ObservedLocation | undefined,
366 ),
367 );
368
369const resolveName = (id: string, name: string | undefined) =>
370 Effect.gen(function* () {
371 if (name) return name;
372 return yield* createPhysicalName({ id, lowercase: true });
373 });
374
375const sameNetworks = (
376 observed: ReadonlyArray<LocationNetwork>,
377 desired: ReadonlyArray<LocationNetwork>,
378): boolean =>
379 arrayEqualsUnordered(
380 observed.map((n) => n.network),
381 desired.map((n) => n.network),
382 );
383
384/**
385 * Endpoints are a deeply nested object whose server echo includes extra
386 * `null`/default fields. Compare only the fields the caller declared.
387 */
388const sameEndpoints = (
389 observed: ObservedLocation["endpoints"],
390 desired: LocationEndpoints,
391): boolean => {
392 if (!observed) return false;
393 const sameNetworkList = (
394 o: ReadonlyArray<{ network: string }> | null | undefined,
395 d: ReadonlyArray<{ network: string }> | null | undefined,
396 ) =>
397 d === undefined ||
398 arrayEqualsUnordered(
399 (o ?? []).map((n) => n.network),
400 (d ?? []).map((n) => n.network),
401 );
402 const sameToggle = (o: boolean | null | undefined, d: boolean | undefined) =>
403 d === undefined || (o ?? false) === d;
404 return (
405 sameToggle(observed.doh.enabled, desired.doh.enabled) &&
406 sameToggle(observed.doh.requireToken, desired.doh.requireToken) &&
407 sameNetworkList(observed.doh.networks, desired.doh.networks) &&
408 sameToggle(observed.dot.enabled, desired.dot.enabled) &&
409 sameNetworkList(observed.dot.networks, desired.dot.networks) &&
410 sameToggle(observed.ipv4.enabled, desired.ipv4.enabled) &&
411 sameToggle(observed.ipv6.enabled, desired.ipv6.enabled) &&
412 sameNetworkList(observed.ipv6.networks, desired.ipv6.networks)
413 );
414};
415
416type ListedLocation = NonNullable<
417 zeroTrust.ListGatewayLocationsResponse["result"]
418>[number];
419
420const toAttributes = (
421 location:
422 | ObservedLocation
423 | zeroTrust.UpdateGatewayLocationResponse
424 | ListedLocation,
425 accountId: string,
426): LocationAttributes => ({
427 locationId: location.id ?? "",
428 accountId,
429 name: location.name ?? "",
430 clientDefault: location.clientDefault ?? false,
431 ecsSupport: location.ecsSupport ?? false,
432 dohSubdomain: location.dohSubdomain ?? undefined,
433 ip: location.ip ?? undefined,
434 ipv4Destination: location.ipv4Destination ?? undefined,
435 dnsDestinationIpsId: location.dnsDestinationIpsId ?? undefined,
436 networks: (location.networks ?? []).map((n) => ({ network: n.network })),
437 createdAt: location.createdAt ?? undefined,
438 updatedAt: location.updatedAt ?? undefined,
439});