1import * as dns from "@distilled.cloud/cloudflare/dns";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Redacted from "effect/Redacted";
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 { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13
14const TypeId = "Cloudflare.DNS.ZoneTransferTsig" as const;
15type TypeId = typeof TypeId;
16
17export interface ZoneTransferTsigProps {
18 /**
19 * TSIG key name. If omitted, a unique name is generated from the app,
20 * stage, and logical ID.
21 *
22 * Mutable — updated in place (PUT).
23 * @default ${app}-${stage}-${id}
24 */
25 name?: string;
26 /**
27 * TSIG algorithm (e.g. `hmac-sha512.`).
28 *
29 * Mutable — updated in place (PUT).
30 */
31 algo: string;
32 /**
33 * TSIG secret (base64-encoded key material). Kept redacted — it is
34 * never stored in the resource's attributes.
35 *
36 * Mutable — updated in place (PUT).
37 */
38 secret: Redacted.Redacted<string>;
39}
40
41export interface ZoneTransferTsigAttributes {
42 /** Identifier of the TSIG. */
43 tsigId: string;
44 /** The Cloudflare account the TSIG belongs to. */
45 accountId: string;
46 /** TSIG key name. */
47 name: string;
48 /** TSIG algorithm. */
49 algo: string;
50}
51
52export type ZoneTransferTsig = Resource<
53 TypeId,
54 ZoneTransferTsigProps,
55 ZoneTransferTsigAttributes,
56 never,
57 Providers
58>;
59
60/**
61 * A Secondary DNS TSIG key
62 * (`/accounts/{account_id}/secondary_dns/tsigs`) — shared-secret
63 * authentication for zone transfers between Cloudflare and external
64 * nameservers. Reference it from a {@link ZoneTransferPeer} via
65 * `tsigId`.
66 *
67 * Requires the Secondary DNS (zone transfer) entitlement on the
68 * account. All fields are mutable in place; the secret is redacted and
69 * never persisted in attributes.
70 * ### Creating a TSIG
71 * **Example:** HMAC-SHA512 key
72 * ```typescript
73 * const tsig = yield* Cloudflare.DNS.ZoneTransferTsig("TransferKey", {
74 * algo: "hmac-sha512.",
75 * secret: Redacted.make(process.env.TSIG_SECRET!),
76 * });
77 * ```
78 *
79 * ### Using with a Peer
80 * **Example:** Authenticate transfers from a primary nameserver
81 * ```typescript
82 * const peer = yield* Cloudflare.DNS.ZoneTransferPeer("Primary", {
83 * ip: "192.0.2.53",
84 * port: 53,
85 * tsigId: tsig.tsigId,
86 * });
87 * ```
88 *
89 * @see https://developers.cloudflare.com/dns/zone-setups/zone-transfers/
90 *
91 * @resource
92 * @product DNS
93 * @category Domains & DNS
94 */
95export const ZoneTransferTsig = Resource<ZoneTransferTsig>(TypeId, {
96 aliases: ["Cloudflare.Dns.ZoneTransferTsig"],
97});
98
99/**
100 * Returns true if the given value is a ZoneTransferTsig resource.
101 */
102export const isZoneTransferTsig = (value: unknown): value is ZoneTransferTsig =>
103 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
104
105export const ZoneTransferTsigProvider = () =>
106 Provider.succeed(ZoneTransferTsig, {
107 stables: ["tsigId", "accountId"],
108
109 diff: Effect.fn(function* ({ output }) {
110 const { accountId } = yield* yield* CloudflareEnvironment;
111 if (output !== undefined && output.accountId !== accountId) {
112 return { action: "replace" } as const;
113 }
114 return undefined;
115 }),
116
117 read: Effect.fn(function* ({ id, output, olds }) {
118 const { accountId } = yield* yield* CloudflareEnvironment;
119 const acct = output?.accountId ?? accountId;
120
121 if (output?.tsigId) {
122 const observed = yield* getTsig(acct, output.tsigId);
123 return observed ? toAttributes(observed, acct) : undefined;
124 }
125 // Cold read — recover from lost state by matching the
126 // deterministic physical name. TSIGs carry no ownership markers,
127 // so gate takeover behind adoption.
128 const name = yield* createTsigName(id, olds?.name);
129 const match = yield* findByName(acct, name);
130 return match ? Unowned(toAttributes(match, acct)) : undefined;
131 }),
132
133 reconcile: Effect.fn(function* ({ id, news, output }) {
134 const { accountId } = yield* yield* CloudflareEnvironment;
135 const name = yield* createTsigName(id, news.name);
136 const secret = Redacted.value(news.secret);
137
138 // Observe — the id cached on `output` is a hint, not a guarantee.
139 const observed = output?.tsigId
140 ? yield* getTsig(output.accountId ?? accountId, output.tsigId)
141 : undefined;
142
143 if (!observed) {
144 // Ensure — greenfield (or out-of-band delete). Names are not
145 // unique, so there is no AlreadyExists race to tolerate.
146 const created = yield* dns.createZoneTransferTsig({
147 accountId,
148 name,
149 algo: news.algo,
150 secret,
151 });
152 return toAttributes(created, accountId);
153 }
154
155 // Sync — PUT with the full body; skip the call on no delta. The
156 // observed secret comes back from the GET, so the comparison is
157 // exact (not a guess from `olds`).
158 if (
159 observed.name === name &&
160 observed.algo === news.algo &&
161 observed.secret === secret
162 ) {
163 return toAttributes(observed, output?.accountId ?? accountId);
164 }
165 const updated = yield* dns.updateZoneTransferTsig({
166 accountId: output?.accountId ?? accountId,
167 tsigId: observed.id,
168 name,
169 algo: news.algo,
170 secret,
171 });
172 return toAttributes(updated, output?.accountId ?? accountId);
173 }),
174
175 delete: Effect.fn(function* ({ output }) {
176 yield* dns
177 .deleteZoneTransferTsig({
178 accountId: output.accountId,
179 tsigId: output.tsigId,
180 })
181 .pipe(Effect.catchTag("TsigNotFound", () => Effect.void));
182 }),
183
184 // Account collection — enumerate every TSIG in the account via the
185 // account-scoped list API, paginate exhaustively, and hydrate each into
186 // the same Attributes shape `read` returns (the secret is write-only and
187 // never persisted).
188 list: Effect.fn(function* () {
189 const { accountId } = yield* yield* CloudflareEnvironment;
190 return yield* dns.listZoneTransferTsigs.pages({ accountId }).pipe(
191 Stream.runCollect,
192 Effect.map((chunk) =>
193 Array.from(chunk).flatMap((page) =>
194 (page.result ?? []).map((tsig) => toAttributes(tsig, accountId)),
195 ),
196 ),
197 );
198 }),
199 });
200
201type ObservedTsig =
202 | dns.GetZoneTransferTsigResponse
203 | dns.CreateZoneTransferTsigResponse
204 | dns.UpdateZoneTransferTsigResponse;
205
206/** Read a TSIG by id, mapping "gone" (404) to `undefined`. */
207const getTsig = (accountId: string, tsigId: string) =>
208 dns
209 .getZoneTransferTsig({ accountId, tsigId })
210 .pipe(Effect.catchTag("TsigNotFound", () => Effect.succeed(undefined)));
211
212/**
213 * Find a TSIG by exact name. Names are not unique on Cloudflare's side;
214 * pick the lexicographically-first id for determinism.
215 */
216const findByName = (accountId: string, name: string) =>
217 dns.listZoneTransferTsigs.items({ accountId }).pipe(
218 Stream.filter((t) => t.name === name),
219 Stream.runCollect,
220 Effect.map((chunk) =>
221 Array.from(chunk)
222 .sort((a, b) => a.id.localeCompare(b.id))
223 .at(0),
224 ),
225 );
226
227const createTsigName = (id: string, name: string | undefined) =>
228 Effect.gen(function* () {
229 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
230 });
231
232const toAttributes = (
233 tsig: Pick<ObservedTsig, "id" | "name" | "algo">,
234 accountId: string,
235): ZoneTransferTsigAttributes => ({
236 tsigId: tsig.id,
237 accountId,
238 name: tsig.name,
239 algo: tsig.algo,
240});