1import * as addressing from "@distilled.cloud/cloudflare/addressing";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Providers } from "../Providers.ts";
10
11const TypeId = "Cloudflare.Addressing.AddressMap" as const;
12type TypeId = typeof TypeId;
13
14/**
15 * A zone or account membership on an Address Map. Zones (or whole accounts)
16 * listed here are assigned the IPs on the map. A zone membership takes
17 * priority over an account membership.
18 */
19export interface AddressMapMembership {
20 /**
21 * The zone id (for `kind: "zone"`) or account id (for `kind: "account"`)
22 * to assign the map's IPs to.
23 *
24 * Note: due to the shape of the Cloudflare API, account memberships can
25 * only target the account that owns the Address Map.
26 */
27 identifier: string;
28 /**
29 * Whether the membership targets a zone or a whole account.
30 */
31 kind: "zone" | "account";
32}
33
34export interface AddressMapProps {
35 /**
36 * An optional description used to describe the types of IPs or zones on
37 * the map. Mutable — patched in place.
38 */
39 description?: string;
40 /**
41 * Whether the Address Map is enabled. Cloudflare's DNS will not respond
42 * with the map's IP addresses until the map is enabled. Mutable.
43 * @default false
44 */
45 enabled?: boolean;
46 /**
47 * Default SNI to present to legacy TLS clients that do not send the TLS
48 * server name indicator. Mutable — patch-only (set in a post-create sync
49 * step).
50 */
51 defaultSni?: string;
52 /**
53 * The set of IPs on the Address Map. Mutable — synced per-IP via
54 * PUT/DELETE against observed cloud state.
55 * @default []
56 */
57 ips?: string[];
58 /**
59 * Zones and accounts assigned IPs on this Address Map. Mutable — synced
60 * per-membership via PUT/DELETE against observed cloud state.
61 * @default []
62 */
63 memberships?: AddressMapMembership[];
64}
65
66export interface AddressMapAttributes {
67 /** Cloudflare-assigned identifier of the Address Map. */
68 addressMapId: string;
69 /** The Cloudflare account the Address Map belongs to. */
70 accountId: string;
71 /**
72 * If `false`, the Address Map cannot be deleted via the API (true for
73 * Cloudflare-managed maps).
74 */
75 canDelete: boolean;
76 /**
77 * If `false`, the IPs on the Address Map cannot be modified via the API
78 * (true for Cloudflare-managed maps).
79 */
80 canModifyIps: boolean;
81 /** The map's description, if set. */
82 description: string | undefined;
83 /** Whether the Address Map is enabled. */
84 enabled: boolean;
85 /** Default SNI presented to legacy TLS clients, if set. */
86 defaultSni: string | undefined;
87 /** The set of IPs currently on the Address Map. */
88 ips: string[];
89 /** Zone/account memberships currently on the Address Map. */
90 memberships: { identifier: string; kind: "zone" | "account" }[];
91 /** ISO8601 creation timestamp. */
92 createdAt: string | undefined;
93 /** ISO8601 last-modified timestamp. */
94 modifiedAt: string | undefined;
95}
96
97export type AddressMap = Resource<
98 TypeId,
99 AddressMapProps,
100 AddressMapAttributes,
101 never,
102 Providers
103>;
104
105/**
106 * A Cloudflare Address Map — assigns account-owned or Cloudflare-assigned
107 * static IPs to zones (BYOIP / Enterprise static IPs).
108 *
109 * Requires the BYOIP add-on or Cloudflare-assigned static IPs on the
110 * account; without the entitlement every mutating call fails with the typed
111 * `FeatureNotEnabled` error (`address_maps_not_enabled_on_account`).
112 * ### Creating an Address Map
113 * **Example:** Disabled map with a description
114 * ```typescript
115 * const map = yield* Cloudflare.Addressing.AddressMap("static-ips", {
116 * description: "static ingress IPs",
117 * enabled: false,
118 * });
119 * ```
120 *
121 * **Example:** Map with IPs and zone memberships
122 * ```typescript
123 * const map = yield* Cloudflare.Addressing.AddressMap("ingress", {
124 * description: "ingress",
125 * enabled: true,
126 * ips: ["192.0.2.1"],
127 * memberships: [{ identifier: zone.zoneId, kind: "zone" }],
128 * });
129 * ```
130 *
131 * ### Legacy TLS clients
132 * **Example:** Default SNI for clients without SNI
133 * ```typescript
134 * const map = yield* Cloudflare.Addressing.AddressMap("legacy", {
135 * enabled: true,
136 * defaultSni: "example.com",
137 * });
138 * ```
139 *
140 * @see https://developers.cloudflare.com/byoip/address-maps/
141 *
142 * @resource
143 * @product Addressing
144 * @category Network
145 */
146export const AddressMap = Resource<AddressMap>(TypeId);
147
148/**
149 * Returns true if the given value is an AddressMap resource.
150 */
151export const isAddressMap = (value: unknown): value is AddressMap =>
152 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
153
154export const AddressMapProvider = () =>
155 Provider.succeed(AddressMap, {
156 stables: [
157 "addressMapId",
158 "accountId",
159 "canDelete",
160 "canModifyIps",
161 "createdAt",
162 ],
163
164 list: Effect.fn(function* () {
165 const { accountId } = yield* yield* CloudflareEnvironment;
166 // The account-scoped list endpoint omits `ips` and `memberships`,
167 // so enumerate ids exhaustively, then hydrate each into the exact
168 // `read` shape via `getAddressMap` (typed per-item not-found).
169 const ids = yield* addressing.listAddressMaps.pages({ accountId }).pipe(
170 Stream.runCollect,
171 Effect.map((chunk) =>
172 Array.from(chunk).flatMap((page) =>
173 (page.result ?? []).flatMap((m) => (m.id ? [m.id] : [])),
174 ),
175 ),
176 );
177 const rows = yield* Effect.forEach(
178 ids,
179 (addressMapId) =>
180 getMap(accountId, addressMapId).pipe(
181 Effect.map((observed) =>
182 observed ? toAttributes(observed, accountId) : undefined,
183 ),
184 ),
185 { concurrency: 10 },
186 );
187 return rows.filter(
188 (row): row is AddressMapAttributes => row !== undefined,
189 );
190 }),
191
192 read: Effect.fn(function* ({ output }) {
193 // Address Maps have no name field to match on, so there is no
194 // reliable cold-lookup — lost state is treated as gone and the
195 // engine recreates.
196 if (!output?.addressMapId) return undefined;
197 const observed = yield* getMap(output.accountId, output.addressMapId);
198 return observed ? toAttributes(observed, output.accountId) : undefined;
199 }),
200
201 reconcile: Effect.fn(function* ({ news, output }) {
202 const { accountId } = yield* yield* CloudflareEnvironment;
203 const acct = output?.accountId ?? accountId;
204
205 // 1. Observe — the map id cached on `output` is a hint, not a
206 // guarantee: a missing map falls through to create.
207 let observed = output?.addressMapId
208 ? yield* getMap(acct, output.addressMapId)
209 : undefined;
210
211 // 2. Ensure — create with the full desired body (ips + memberships
212 // are accepted inline on create). `defaultSni` is patch-only and
213 // handled by the sync step below.
214 if (!observed) {
215 const created = yield* addressing.createAddressMap({
216 accountId,
217 description: news.description,
218 enabled: news.enabled ?? false,
219 ips: desiredIps(news),
220 memberships: desiredMemberships(news),
221 });
222 if (!created.id) {
223 return yield* Effect.fail(
224 new Error("Cloudflare did not return an Address Map id"),
225 );
226 }
227 // Re-observe so every sync step diffs against authoritative state.
228 observed = yield* getMap(accountId, created.id);
229 if (!observed) {
230 return yield* Effect.fail(
231 new Error("Address Map disappeared right after creation"),
232 );
233 }
234 }
235
236 const addressMapId = observed.id!;
237
238 // 3a. Sync scalar fields (description / enabled / defaultSni) —
239 // skip the patch entirely on a no-op.
240 const scalarDirty =
241 (news.description ?? "") !== (observed.description ?? "") ||
242 (news.enabled ?? false) !== (observed.enabled ?? false) ||
243 (news.defaultSni !== undefined &&
244 news.defaultSni !== (observed.defaultSni ?? undefined));
245 if (scalarDirty) {
246 yield* addressing.patchAddressMap({
247 accountId: acct,
248 addressMapId,
249 description: news.description ?? "",
250 enabled: news.enabled ?? false,
251 defaultSni: news.defaultSni,
252 });
253 }
254
255 // 3b. Sync IPs — diff observed cloud IPs against desired, apply
256 // only the per-IP delta.
257 const observedIps = (observed.ips ?? []).flatMap((ip) =>
258 ip.ip ? [ip.ip] : [],
259 );
260 const wantedIps = desiredIps(news);
261 for (const ip of wantedIps) {
262 if (!observedIps.includes(ip)) {
263 yield* addressing.putAddressMapIp({
264 accountId: acct,
265 addressMapId,
266 ipAddress: ip,
267 });
268 }
269 }
270 for (const ip of observedIps) {
271 if (!wantedIps.includes(ip)) {
272 yield* addressing.deleteAddressMapIp({
273 accountId: acct,
274 addressMapId,
275 ipAddress: ip,
276 });
277 }
278 }
279
280 // 3c. Sync memberships — same observed-vs-desired delta, routed to
281 // the zone/account variant per membership kind.
282 const observedMemberships = narrowMemberships(observed.memberships);
283 const wantedMemberships = desiredMemberships(news);
284 for (const m of wantedMemberships) {
285 const present = observedMemberships.some(
286 (o) => o.identifier === m.identifier && o.kind === m.kind,
287 );
288 if (!present) {
289 yield* m.kind === "zone"
290 ? addressing.putAddressMapZone({
291 accountId: acct,
292 addressMapId,
293 zoneId: m.identifier,
294 })
295 : addressing.putAddressMapAccount({
296 accountId: acct,
297 addressMapId,
298 memberAccountId: m.identifier,
299 });
300 }
301 }
302 for (const m of observedMemberships) {
303 const wanted = wantedMemberships.some(
304 (w) => w.identifier === m.identifier && w.kind === m.kind,
305 );
306 if (!wanted) {
307 yield* m.kind === "zone"
308 ? addressing.deleteAddressMapZone({
309 accountId: acct,
310 addressMapId,
311 zoneId: m.identifier,
312 })
313 : addressing.deleteAddressMapAccount({
314 accountId: acct,
315 addressMapId,
316 memberAccountId: m.identifier,
317 });
318 }
319 }
320
321 // 4. Return — re-read so the persisted attributes reflect the final
322 // converged cloud state.
323 const final = yield* getMap(acct, addressMapId);
324 if (!final) {
325 return yield* Effect.fail(
326 new Error("Address Map disappeared during reconcile"),
327 );
328 }
329 return toAttributes(final, acct);
330 }),
331
332 delete: Effect.fn(function* ({ output }) {
333 // Cloudflare-managed maps cannot (and must not) be deleted.
334 if (output.canDelete === false) return;
335 yield* addressing
336 .deleteAddressMap({
337 accountId: output.accountId,
338 addressMapId: output.addressMapId,
339 })
340 .pipe(Effect.catchTag("AddressMapNotFound", () => Effect.void));
341 }),
342 });
343
344/**
345 * Read an Address Map by id, mapping "gone" (`AddressMapNotFound`,
346 * Cloudflare error code 1000 `not_found`) to `undefined`.
347 */
348const getMap = (accountId: string, addressMapId: string) =>
349 addressing
350 .getAddressMap({ accountId, addressMapId })
351 .pipe(
352 Effect.catchTag("AddressMapNotFound", () => Effect.succeed(undefined)),
353 );
354
355/** Desired IPs — inputs have been resolved to concrete strings by Plan. */
356const desiredIps = (news: AddressMapProps): string[] =>
357 (news.ips ?? []).map((ip) => ip as string);
358
359/** Desired memberships with inputs resolved to concrete strings. */
360const desiredMemberships = (
361 news: AddressMapProps,
362): { identifier: string; kind: "zone" | "account" }[] =>
363 (news.memberships ?? []).map((m) => ({
364 identifier: m.identifier as string,
365 kind: m.kind,
366 }));
367
368/**
369 * Narrow distilled memberships (whose `kind` is widened to an open string
370 * union) down to the closed `"zone" | "account"` literals, dropping any
371 * membership without an identifier or with an unknown kind.
372 */
373const narrowMemberships = (
374 memberships:
375 | readonly {
376 identifier?: string | null;
377 kind?: string | null;
378 }[]
379 | null
380 | undefined,
381): { identifier: string; kind: "zone" | "account" }[] =>
382 (memberships ?? []).flatMap(
383 (m): { identifier: string; kind: "zone" | "account" }[] => {
384 if (!m.identifier) return [];
385 if (m.kind === "zone") {
386 return [{ identifier: m.identifier, kind: "zone" }];
387 }
388 if (m.kind === "account") {
389 return [{ identifier: m.identifier, kind: "account" }];
390 }
391 return [];
392 },
393 );
394
395const toAttributes = (
396 map: addressing.GetAddressMapResponse,
397 accountId: string,
398): AddressMapAttributes => ({
399 addressMapId: map.id ?? "",
400 accountId,
401 canDelete: map.canDelete ?? true,
402 canModifyIps: map.canModifyIps ?? true,
403 description: map.description ?? undefined,
404 enabled: map.enabled ?? false,
405 defaultSni: map.defaultSni ?? undefined,
406 ips: (map.ips ?? []).flatMap((ip) => (ip.ip ? [ip.ip] : [])),
407 memberships: narrowMemberships(map.memberships),
408 createdAt: map.createdAt ?? undefined,
409 modifiedAt: map.modifiedAt ?? undefined,
410});