1import * as dns from "@distilled.cloud/cloudflare/dns";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.DNS.ZoneTransferAcl" as const;
14type TypeId = typeof TypeId;
15
16export interface ZoneTransferAclProps {
17 /**
18 * Human-readable name of the ACL. If omitted, a unique name is
19 * generated from the app, stage, and logical ID.
20 *
21 * Mutable — updated in place (PUT).
22 * @default ${app}-${stage}-${id}
23 */
24 name?: string;
25 /**
26 * Allowed IPv4/IPv6 address range of primary or secondary
27 * nameservers, applied account-wide. Used to allow additional NOTIFY
28 * IPs for secondary zones and IPs Cloudflare allows AXFR/IXFR
29 * requests from for outgoing transfers. CIDRs are limited to a
30 * maximum of /24 for IPv4 and /64 for IPv6. Note that Cloudflare
31 * normalizes the range to its network address (e.g. `192.0.2.53/28`
32 * becomes `192.0.2.48/28`).
33 *
34 * Mutable — updated in place (PUT).
35 */
36 ipRange: string;
37}
38
39export interface ZoneTransferAclAttributes {
40 /** Identifier of the ACL. */
41 aclId: string;
42 /** The Cloudflare account the ACL belongs to. */
43 accountId: string;
44 /** Human-readable name of the ACL. */
45 name: string;
46 /** Allowed IP range, as normalized by Cloudflare. */
47 ipRange: string;
48}
49
50export type ZoneTransferAcl = Resource<
51 TypeId,
52 ZoneTransferAclProps,
53 ZoneTransferAclAttributes,
54 never,
55 Providers
56>;
57
58/**
59 * A Secondary DNS zone-transfer ACL
60 * (`/accounts/{account_id}/secondary_dns/acls`) — an account-wide
61 * IPv4/IPv6 range that may receive NOTIFYs for secondary zones and from
62 * which Cloudflare accepts AXFR/IXFR requests for outgoing transfers.
63 *
64 * Requires the Secondary DNS (zone transfer) entitlement on the
65 * account. Both `name` and `ipRange` are mutable in place.
66 * ### Creating an ACL
67 * **Example:** Allow a primary nameserver range
68 * ```typescript
69 * const acl = yield* Cloudflare.DNS.ZoneTransferAcl("PrimaryNs", {
70 * ipRange: "192.0.2.48/28",
71 * });
72 * ```
73 *
74 * **Example:** ACL with an explicit name
75 * ```typescript
76 * const acl = yield* Cloudflare.DNS.ZoneTransferAcl("PrimaryNs", {
77 * name: "primary-nameservers",
78 * ipRange: "2001:db8::/64",
79 * });
80 * ```
81 *
82 * @see https://developers.cloudflare.com/dns/zone-setups/zone-transfers/
83 *
84 * @resource
85 * @product DNS
86 * @category Domains & DNS
87 */
88export const ZoneTransferAcl = Resource<ZoneTransferAcl>(TypeId, {
89 aliases: ["Cloudflare.Dns.ZoneTransferAcl"],
90});
91
92/**
93 * Returns true if the given value is a ZoneTransferAcl resource.
94 */
95export const isZoneTransferAcl = (value: unknown): value is ZoneTransferAcl =>
96 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
97
98export const ZoneTransferAclProvider = () =>
99 Provider.succeed(ZoneTransferAcl, {
100 stables: ["aclId", "accountId"],
101
102 // Account-scoped collection: enumerate every ACL in the ambient
103 // account, paginating exhaustively, and hydrate into the exact `read`
104 // Attributes shape.
105 list: Effect.fn(function* () {
106 const { accountId } = yield* yield* CloudflareEnvironment;
107 return yield* dns.listZoneTransferAcls.pages({ accountId }).pipe(
108 Stream.runCollect,
109 Effect.map((chunk) =>
110 Array.from(chunk).flatMap((page) =>
111 (page.result ?? []).map((acl): ZoneTransferAclAttributes => ({
112 aclId: acl.id,
113 accountId,
114 name: acl.name,
115 ipRange: acl.ipRange,
116 })),
117 ),
118 ),
119 );
120 }),
121
122 diff: Effect.fn(function* ({ output }) {
123 const { accountId } = yield* yield* CloudflareEnvironment;
124 if (output !== undefined && output.accountId !== accountId) {
125 return { action: "replace" } as const;
126 }
127 return undefined;
128 }),
129
130 read: Effect.fn(function* ({ id, output, olds }) {
131 const { accountId } = yield* yield* CloudflareEnvironment;
132 const acct = output?.accountId ?? accountId;
133
134 if (output?.aclId) {
135 const observed = yield* getAcl(acct, output.aclId);
136 return observed ? toAttributes(observed, acct) : undefined;
137 }
138 // Cold read — recover from lost state by matching the
139 // deterministic physical name. ACLs carry no ownership markers,
140 // so gate takeover behind adoption.
141 const name = yield* createAclName(id, olds?.name);
142 const match = yield* findByName(acct, name);
143 return match ? Unowned(toAttributes(match, acct)) : undefined;
144 }),
145
146 reconcile: Effect.fn(function* ({ id, news, output }) {
147 const { accountId } = yield* yield* CloudflareEnvironment;
148 const name = yield* createAclName(id, news.name);
149 // Inputs are resolved to concrete values by Plan.
150 const ipRange = news.ipRange as string;
151
152 // Observe — the id cached on `output` is a hint, not a guarantee.
153 const observed = output?.aclId
154 ? yield* getAcl(output.accountId ?? accountId, output.aclId)
155 : undefined;
156
157 if (!observed) {
158 // Ensure — greenfield (or out-of-band delete). Names are not
159 // unique on Cloudflare's side, so there is no AlreadyExists
160 // race to tolerate.
161 const created = yield* dns.createZoneTransferAcl({
162 accountId,
163 name,
164 ipRange,
165 });
166 return toAttributes(created, accountId);
167 }
168
169 // Sync — the update API is a PUT with the full body; skip the
170 // call entirely when nothing differs. Cloudflare normalizes
171 // `ipRange` to the network address, so compare against the
172 // observed (normalized) value.
173 if (observed.name === name && observed.ipRange === ipRange) {
174 return toAttributes(observed, output?.accountId ?? accountId);
175 }
176 const updated = yield* dns.updateZoneTransferAcl({
177 accountId: output?.accountId ?? accountId,
178 aclId: observed.id,
179 name,
180 ipRange,
181 });
182 return toAttributes(updated, output?.accountId ?? accountId);
183 }),
184
185 delete: Effect.fn(function* ({ output }) {
186 yield* dns
187 .deleteZoneTransferAcl({
188 accountId: output.accountId,
189 aclId: output.aclId,
190 })
191 .pipe(Effect.catchTag("AclNotFound", () => Effect.void));
192 }),
193 });
194
195type ObservedAcl =
196 | dns.GetZoneTransferAclResponse
197 | dns.CreateZoneTransferAclResponse
198 | dns.UpdateZoneTransferAclResponse;
199
200/** Read an ACL by id, mapping "gone" (404) to `undefined`. */
201const getAcl = (accountId: string, aclId: string) =>
202 dns
203 .getZoneTransferAcl({ accountId, aclId })
204 .pipe(Effect.catchTag("AclNotFound", () => Effect.succeed(undefined)));
205
206/**
207 * Find an ACL by exact name. Names are not unique on Cloudflare's side;
208 * pick the lexicographically-first id for determinism.
209 */
210const findByName = (accountId: string, name: string) =>
211 dns.listZoneTransferAcls.items({ accountId }).pipe(
212 Stream.filter((a) => a.name === name),
213 Stream.runCollect,
214 Effect.map((chunk) =>
215 Array.from(chunk)
216 .sort((a, b) => a.id.localeCompare(b.id))
217 .at(0),
218 ),
219 );
220
221const createAclName = (id: string, name: string | undefined) =>
222 Effect.gen(function* () {
223 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
224 });
225
226const toAttributes = (
227 acl: ObservedAcl,
228 accountId: string,
229): ZoneTransferAclAttributes => ({
230 aclId: acl.id,
231 accountId,
232 name: acl.name,
233 ipRange: acl.ipRange,
234});