1import * as hostnames from "@distilled.cloud/cloudflare/hostnames";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.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 "../Zone/lookup.ts";
12
13const TypeId = "Cloudflare.HostnameTlsSetting.HostnameTlsSetting" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * Which per-hostname TLS setting to override:
18 *
19 * - `ciphers` — allowed cipher suites (BoringSSL names) for the hostname
20 * - `min_tls_version` — minimum TLS protocol version for the hostname
21 * - `http2` — whether HTTP/2 is offered to clients connecting to the hostname
22 */
23export type Id = "ciphers" | "min_tls_version" | "http2";
24
25/**
26 * The value of a per-hostname TLS setting. The shape depends on
27 * {@link Id}:
28 *
29 * - `ciphers` → `string[]` of BoringSSL cipher suite names
30 * - `min_tls_version` → `"1.0" | "1.1" | "1.2" | "1.3"`
31 * - `http2` → `"on" | "off"`
32 */
33export type Value = "1.0" | "1.1" | "1.2" | "1.3" | "on" | "off" | string[];
34
35export interface Props {
36 /**
37 * Zone the hostname belongs to. Stable — moving the override to another
38 * zone triggers a replacement.
39 */
40 zoneId: string;
41 /**
42 * Which TLS setting to override (`ciphers`, `min_tls_version`, or
43 * `http2`). Part of the override's identity — changing it triggers a
44 * replacement.
45 */
46 settingId: Id;
47 /**
48 * The hostname the override applies to. Part of the override's identity —
49 * changing it triggers a replacement.
50 *
51 * Per-hostname TLS settings require the hostname to be covered by
52 * Cloudflare for SaaS custom hostnames or an Advanced Certificate
53 * Manager certificate on the zone; without that entitlement the API
54 * rejects writes with `AdvancedCertificateManagerRequired`.
55 */
56 hostname: string;
57 /**
58 * Desired value of the setting. The shape depends on `settingId`:
59 * `ciphers` takes a `string[]` of BoringSSL cipher names,
60 * `min_tls_version` takes `"1.0" | "1.1" | "1.2" | "1.3"`, and `http2`
61 * takes `"on" | "off"`.
62 *
63 * Mutable — upserted in place via PUT.
64 */
65 value: Value;
66}
67
68export interface Attributes {
69 /** Zone the hostname belongs to. */
70 zoneId: string;
71 /** The overridden TLS setting's identifier. */
72 settingId: string;
73 /** The hostname the override applies to. */
74 hostname: string;
75 /** Current value of the override. */
76 value: Value;
77 /**
78 * Deployment status of the override (e.g. `pending_deployment`,
79 * `active`). Propagation to the edge is asynchronous.
80 */
81 status: string | undefined;
82 /** When the override was first created, if Cloudflare reports it. */
83 createdAt: string | undefined;
84 /** When the override was last updated, if Cloudflare reports it. */
85 updatedAt: string | undefined;
86}
87
88export type HostnameTlsSetting = Resource<
89 TypeId,
90 Props,
91 Attributes,
92 never,
93 Providers
94>;
95
96/**
97 * A per-hostname TLS setting override
98 * (`/zones/{zone_id}/hostnames/settings/{settingId}/{hostname}`) — pins
99 * `ciphers`, `min_tls_version`, or `http2` for a single hostname instead of
100 * the whole zone.
101 *
102 * Each `(settingId, hostname)` pair is an independent override with PUT
103 * (upsert) / DELETE semantics; deleting the override reverts the hostname to
104 * the zone-wide default. Overrides are mostly useful with Cloudflare for
105 * SaaS custom hostnames or Advanced Certificate Manager — on zones without
106 * that entitlement, writes fail with the typed
107 * `AdvancedCertificateManagerRequired` error (Cloudflare code 1450).
108 *
109 * Safety: overrides carry no ownership markers. When there is no prior
110 * state, `read` scans the setting's hostname list and reports an existing
111 * override as `Unowned`, so the engine refuses to take it over unless
112 * `--adopt` (or `adopt(true)`) is set.
113 * ### Minimum TLS version
114 * **Example:** Require TLS 1.2 for a single hostname
115 * ```typescript
116 * yield* Cloudflare.HostnameTlsSetting.HostnameTlsSetting("ApiMinTls", {
117 * zoneId: zone.zoneId,
118 * settingId: "min_tls_version",
119 * hostname: "api.example.com",
120 * value: "1.2",
121 * });
122 * ```
123 *
124 * ### HTTP/2
125 * **Example:** Disable HTTP/2 for a legacy hostname
126 * ```typescript
127 * yield* Cloudflare.HostnameTlsSetting.HostnameTlsSetting("LegacyHttp2", {
128 * zoneId: zone.zoneId,
129 * settingId: "http2",
130 * hostname: "legacy.example.com",
131 * value: "off",
132 * });
133 * ```
134 *
135 * ### Cipher suites
136 * **Example:** Restrict a hostname to modern ciphers
137 * ```typescript
138 * yield* Cloudflare.HostnameTlsSetting.HostnameTlsSetting("StrictCiphers", {
139 * zoneId: zone.zoneId,
140 * settingId: "ciphers",
141 * hostname: "secure.example.com",
142 * value: ["ECDHE-RSA-AES128-GCM-SHA256", "AES128-GCM-SHA256"],
143 * });
144 * ```
145 *
146 * @see https://developers.cloudflare.com/ssl/edge-certificates/additional-options/custom-metadata/
147 * @see https://developers.cloudflare.com/api/resources/hostnames/subresources/settings/subresources/tls/
148 *
149 * @resource
150 * @product Hostname TLS Settings
151 * @category SSL/TLS & Certificates
152 */
153export const HostnameTlsSetting = Resource<HostnameTlsSetting>(TypeId, {
154 aliases: ["Cloudflare.HostnameTlsSetting"],
155});
156
157/**
158 * Returns true if the given value is a HostnameTlsSetting resource.
159 */
160export const isHostnameTlsSetting = (
161 value: unknown,
162): value is HostnameTlsSetting =>
163 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
164
165export const HostnameTlsSettingProvider = () =>
166 Provider.succeed(HostnameTlsSetting, {
167 stables: ["zoneId", "settingId", "hostname", "createdAt"],
168
169 list: Effect.fn(function* () {
170 // Enumerate every override, including subdomains, through the zone's
171 // collection endpoint. The per-hostname GET returns 405 on live zones.
172 const { accountId } = yield* yield* CloudflareEnvironment;
173 const zones = yield* listAllZones(accountId);
174 const settingIds: Id[] = ["ciphers", "min_tls_version", "http2"];
175 const rows = yield* Effect.forEach(
176 zones,
177 (zone) =>
178 Effect.forEach(
179 settingIds,
180 (settingId) =>
181 hostnames
182 .listSettingsTls({ zoneId: zone.id, settingId })
183 .pipe(
184 Effect.map((settings) =>
185 settings.flatMap((setting) =>
186 setting.hostname == null
187 ? []
188 : [
189 toAttributes(
190 zone.id,
191 settingId,
192 setting.hostname,
193 setting,
194 ),
195 ],
196 ),
197 ),
198 ),
199 { concurrency: "unbounded" },
200 ).pipe(Effect.map((perSetting) => perSetting.flat())),
201 { concurrency: 10 },
202 );
203 return rows.flat();
204 }),
205
206 diff: Effect.fn(function* ({ olds, news, output }) {
207 // `news` may still carry unresolved plan-time expressions — defer to
208 // the engine's default update logic until everything is concrete.
209 if (!isResolved(news)) return undefined;
210 // (settingId, hostname) is the override's identity.
211 const oldSettingId = output?.settingId ?? olds?.settingId;
212 if (oldSettingId !== undefined && oldSettingId !== news.settingId) {
213 return { action: "replace" } as const;
214 }
215 const oldHostname = output?.hostname ?? olds?.hostname;
216 if (oldHostname !== undefined && oldHostname !== news.hostname) {
217 return { action: "replace" } as const;
218 }
219 // zoneId is Input<string>; compare only once both sides are concrete.
220 const oldZoneId =
221 output?.zoneId ??
222 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
223 if (
224 oldZoneId !== undefined &&
225 typeof news.zoneId === "string" &&
226 oldZoneId !== news.zoneId
227 ) {
228 return { action: "replace" } as const;
229 }
230 return undefined;
231 }),
232
233 read: Effect.fn(function* ({ output, olds }) {
234 const zoneId =
235 output?.zoneId ??
236 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
237 const settingId = output?.settingId ?? olds?.settingId;
238 const hostname = output?.hostname ?? olds?.hostname;
239 if (!zoneId || !settingId || !hostname) return undefined;
240
241 const observed = yield* findSetting(zoneId, settingId, hostname);
242 if (observed === undefined) return undefined;
243
244 const attrs = toAttributes(zoneId, settingId, hostname, observed);
245 // Overrides carry no ownership markers — on a cold read (no prior
246 // state) we cannot prove we created it, so brand it `Unowned` and
247 // let the engine gate takeover behind the adopt policy.
248 return output !== undefined ? attrs : Unowned(attrs);
249 }),
250
251 reconcile: Effect.fn(function* ({ news }) {
252 // Inputs have been resolved to concrete strings by Plan.
253 const zoneId = news.zoneId as string;
254 const { settingId, hostname } = news;
255
256 // 1. Observe — list the setting's overrides and match on hostname.
257 const observed = yield* findSetting(zoneId, settingId, hostname);
258
259 // 2. Sync — PUT is a true upsert, so a single call covers both the
260 // missing and the drifted case; skip it entirely on a no-op.
261 if (observed !== undefined && valueEquals(observed.value, news.value)) {
262 return toAttributes(zoneId, settingId, hostname, observed);
263 }
264 const updated = yield* hostnames.putSettingTls({
265 zoneId,
266 settingId,
267 hostname,
268 value: news.value,
269 });
270 // The list can briefly echo a stale value after the PUT (edge
271 // deployment is async) — trust the PUT response, not a re-read.
272 return toAttributes(zoneId, settingId, hostname, updated);
273 }),
274
275 delete: Effect.fn(function* ({ output }) {
276 const { zoneId, settingId, hostname } = output;
277 // Observe first — deleting an already-removed override is not an
278 // error (idempotent re-delete after a crashed run).
279 const observed = yield* findSetting(zoneId, settingId, hostname);
280 if (observed === undefined) return;
281 yield* hostnames.deleteSettingTls({ zoneId, settingId, hostname }).pipe(
282 // Lost a race with an out-of-band delete — already converged.
283 Effect.catchTag("HostnameTlsSettingNotFound", () => Effect.void),
284 );
285 }),
286 });
287
288// ---------------------------------------------------------------------------
289// API helpers
290// ---------------------------------------------------------------------------
291
292type ObservedSetting = hostnames.ListSettingsTlsResponse[number];
293
294const findSetting = (zoneId: string, settingId: string, hostname: string) =>
295 hostnames
296 .listSettingsTls({ zoneId, settingId })
297 .pipe(
298 Effect.map((settings) =>
299 settings.find((setting) => setting.hostname === hostname),
300 ),
301 );
302
303/**
304 * Structural equality for setting values — scalar versions/toggles compare
305 * by identity, cipher lists element-wise (order matters: the list is the
306 * client-facing preference order).
307 */
308const valueEquals = (a: ObservedSetting["value"], b: Value): boolean => {
309 if (Array.isArray(a) || Array.isArray(b)) {
310 if (!Array.isArray(a) || !Array.isArray(b)) return false;
311 return a.length === b.length && a.every((v, i) => v === b[i]);
312 }
313 return a === b;
314};
315
316const toAttributes = (
317 zoneId: string,
318 settingId: string,
319 hostname: string,
320 setting: ObservedSetting | hostnames.PutSettingTlsResponse,
321): Attributes => ({
322 zoneId,
323 settingId,
324 hostname,
325 value: normalizeValue(setting.value),
326 status: setting.status ?? undefined,
327 createdAt: setting.createdAt ?? undefined,
328 updatedAt: setting.updatedAt ?? undefined,
329});
330
331/**
332 * Distilled types the echoed value as nullable and cipher arrays as
333 * `readonly string[]` — normalize to the props-facing shape. A persisted
334 * override always carries a value; fall back to `"off"` purely to satisfy
335 * the type.
336 */
337const normalizeValue = (value: ObservedSetting["value"]): Value =>
338 value === null || value === undefined
339 ? "off"
340 : Array.isArray(value)
341 ? [...value]
342 : value;