1import * as zones from "@distilled.cloud/cloudflare/zones";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11import { listAllZones } from "./lookup.ts";
12
13const TypeId = "Cloudflare.Zone.Hold" as const;
14type TypeId = typeof TypeId;
15
16export type HoldProps = {
17 /**
18 * Zone to place the hold on. Stable — changing the zone triggers a
19 * replacement (the hold is removed from the old zone and placed on the
20 * new one).
21 */
22 zoneId: string;
23 /**
24 * Extend the hold to block any subdomain of the zone, as well as
25 * SSL4SaaS Custom Hostnames. For example, a hold on `example.com` with
26 * `includeSubdomains: true` also blocks `staging.example.com` from
27 * being added to another account.
28 *
29 * Mutable — patched in place.
30 * @default false
31 */
32 includeSubdomains?: boolean;
33};
34
35export type HoldAttributes = {
36 /** Zone the hold is placed on. */
37 zoneId: string;
38 /** Whether the hold is currently active. */
39 hold: boolean;
40 /**
41 * If present and future-dated, the hold is temporarily disabled and
42 * will automatically re-enable at this RFC3339 timestamp.
43 */
44 holdAfter: string | undefined;
45 /** Whether the hold also blocks subdomains and SSL4SaaS Custom Hostnames. */
46 includeSubdomains: boolean;
47};
48
49export type Hold = Resource<
50 TypeId,
51 HoldProps,
52 HoldAttributes,
53 never,
54 Providers
55>;
56
57/**
58 * A Cloudflare zone hold (`/zones/{zone_id}/hold`) — prevents the zone's
59 * hostname (and optionally its subdomains) from being added to another
60 * Cloudflare account while the hold is active.
61 *
62 * Zone holds are only available on **Enterprise** zones. On other plans
63 * every create/patch fails with Cloudflare code 1005, surfaced as the typed
64 * `ZoneHoldsRequireEnterprise` error.
65 *
66 * Destroying the resource removes the hold. The delete is idempotent —
67 * removing a hold that is already gone (or whose zone was deleted
68 * out-of-band) succeeds.
69 * ### Holding a zone
70 * **Example:** Place a hold on a zone
71 * ```typescript
72 * const hold = yield* Cloudflare.Zone.Hold("MyHold", {
73 * zoneId: zone.zoneId,
74 * });
75 * ```
76 *
77 * **Example:** Hold the zone and all of its subdomains
78 * ```typescript
79 * yield* Cloudflare.Zone.Hold("MyHold", {
80 * zoneId: zone.zoneId,
81 * includeSubdomains: true,
82 * });
83 * ```
84 *
85 * ### Adopting an existing hold
86 * **Example:** Take over a hold that was placed outside Alchemy
87 * ```typescript
88 * import { adopt } from "alchemy/AdoptPolicy";
89 * // A hold carries no ownership markers, so the engine refuses to take
90 * // over a pre-existing hold unless you opt in with `adopt(true)`.
91 * const hold = yield* Cloudflare.Zone.Hold("MyHold", {
92 * zoneId: zone.zoneId,
93 * }).pipe(adopt(true));
94 * ```
95 *
96 * @see https://developers.cloudflare.com/fundamentals/account/account-security/zone-holds/
97 *
98 * @resource
99 * @product Zones
100 * @category Domains & DNS
101 */
102export const Hold = Resource<Hold>(TypeId);
103
104/**
105 * Returns true if the given value is a Hold resource.
106 */
107export const isHold = (value: unknown): value is Hold =>
108 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
109
110// Cloudflare can transiently fail to authenticate a valid token, surfacing as
111// `Forbidden`. Retry with exponential backoff capped at 5s, bounded to ~8
112// attempts so a persistently-unauthorized call still fails fast.
113const forbiddenRetrySchedule = Schedule.max([
114 Schedule.min([
115 Schedule.exponential("500 millis"),
116 Schedule.spaced("5 seconds"),
117 ]),
118 Schedule.recurs(8),
119]);
120
121export const HoldProvider = () =>
122 Provider.succeed(Hold, {
123 stables: ["zoneId"],
124
125 list: Effect.fn(function* () {
126 const { accountId } = yield* yield* CloudflareEnvironment;
127 // A hold is a per-zone singleton — `getHold` always returns a record
128 // for every zone (default `hold: false`), and there is no account-wide
129 // enumeration API. Enumerate every zone and read its hold state.
130 const allZones = yield* listAllZones(accountId);
131 const rows = yield* Effect.forEach(
132 allZones.map((zone) => zone.id),
133 (zoneId) =>
134 zones.getHold({ zoneId }).pipe(
135 // Cloudflare intermittently rejects a *valid* token with
136 // `Forbidden` (a transient edge auth failure). It is retryable —
137 // back off and try again rather than dropping the zone, so a
138 // genuinely accessible zone (e.g. the standing test zone) never
139 // silently falls out of the enumeration on a blip.
140 Effect.retry({
141 while: (e) => e._tag === "Forbidden",
142 schedule: forbiddenRetrySchedule,
143 }),
144 Effect.map((observed) => toAttributes(zoneId, observed)),
145 // Zone deleted out-of-band mid-enumeration — drop it.
146 Effect.catchTag("InvalidZoneIdentifier", () =>
147 Effect.succeed(undefined),
148 ),
149 ),
150 { concurrency: 10 },
151 );
152 return rows.filter((row): row is HoldAttributes => row !== undefined);
153 }),
154
155 diff: Effect.fn(function* ({ olds = {}, news, output }) {
156 const o = olds as HoldProps;
157 const n = news as HoldProps;
158 // zoneId is the hold's identity (one hold per zone). It is
159 // Input<string>; compare only once both sides are concrete.
160 const oldZoneId =
161 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
162 if (
163 oldZoneId !== undefined &&
164 typeof n.zoneId === "string" &&
165 oldZoneId !== n.zoneId
166 ) {
167 return { action: "replace" } as const;
168 }
169 return undefined;
170 }),
171
172 read: Effect.fn(function* ({ output, olds }) {
173 const zoneId =
174 output?.zoneId ??
175 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
176 if (!zoneId) return undefined;
177 const observed = yield* zones.getHold({ zoneId }).pipe(
178 // Zone deleted out-of-band — the hold is gone with it.
179 Effect.catchTag("InvalidZoneIdentifier", () =>
180 Effect.succeed(undefined),
181 ),
182 );
183 if (observed === undefined || observed.hold !== true) return undefined;
184 const attrs = toAttributes(zoneId, observed);
185 // Cold read: a hold exists but we have no state for it. Holds carry
186 // no ownership markers, so gate the takeover behind `--adopt`.
187 return output === undefined ? Unowned(attrs) : attrs;
188 }),
189
190 reconcile: Effect.fn(function* ({ news }) {
191 // Inputs have been resolved to concrete strings by Plan.
192 const zoneId = news.zoneId as string;
193 const desiredIncludeSubdomains = news.includeSubdomains ?? false;
194
195 // 1. Observe — is the zone currently held?
196 const observed = yield* zones.getHold({ zoneId });
197
198 // 2. Ensure — place the hold if the zone is not held.
199 if (observed.hold !== true) {
200 const created = yield* zones.createHold({
201 zoneId,
202 includeSubdomains: desiredIncludeSubdomains,
203 });
204 return toAttributes(zoneId, created);
205 }
206
207 // 3. Sync — patch includeSubdomains when the observed value differs.
208 if (
209 normalizeIncludeSubdomains(observed.includeSubdomains) !==
210 desiredIncludeSubdomains
211 ) {
212 const patched = yield* zones
213 .patchHold({
214 zoneId,
215 includeSubdomains: desiredIncludeSubdomains,
216 })
217 .pipe(
218 // The hold vanished between observe and patch (out-of-band
219 // removal race) — place it fresh with the desired settings.
220 Effect.catchTag("ZoneHoldNotFound", () =>
221 zones.createHold({
222 zoneId,
223 includeSubdomains: desiredIncludeSubdomains,
224 }),
225 ),
226 );
227 return toAttributes(zoneId, patched);
228 }
229
230 return toAttributes(zoneId, observed);
231 }),
232
233 delete: Effect.fn(function* ({ output }) {
234 // Removing a hold is naturally idempotent — Cloudflare returns the
235 // (un-held) hold state even when no hold exists. Tolerate the zone
236 // itself having been deleted out-of-band.
237 yield* zones
238 .deleteHold({ zoneId: output.zoneId })
239 .pipe(Effect.catchTag("InvalidZoneIdentifier", () => Effect.void));
240 }),
241 });
242
243/**
244 * Normalize Cloudflare's `include_subdomains` response value. The live API
245 * returns a boolean, while Cloudflare's published schema declares a string
246 * (`"true"`/`"false"`) — accept both.
247 */
248const normalizeIncludeSubdomains = (value: unknown): boolean =>
249 value === true || value === "true";
250
251const toAttributes = (
252 zoneId: string,
253 hold:
254 | zones.GetHoldResponse
255 | zones.CreateHoldResponse
256 | zones.PatchHoldResponse,
257): HoldAttributes => ({
258 zoneId,
259 hold: hold.hold === true,
260 holdAfter: hold.holdAfter ?? undefined,
261 includeSubdomains: normalizeIncludeSubdomains(hold.includeSubdomains),
262});