1import * as certificateAuthorities from "@distilled.cloud/cloudflare/certificate-authorities";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { Unowned } from "../../AdoptPolicy.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Providers } from "../Providers.ts";
10import { listAllZones } from "../Zone/lookup.ts";
11
12const TypeId = "Cloudflare.CertificateAuthorities.HostnameAssociation" as const;
13type TypeId = typeof TypeId;
14
15export type HostnameAssociationProps = {
16 /**
17 * Zone whose hostnames should enforce mTLS. Stable — the zone is part of
18 * the association's identity, so changing it triggers a replacement (the
19 * old zone's association is cleared).
20 */
21 zoneId: string;
22 /**
23 * UUID of an uploaded CA certificate from the account-level mTLS
24 * Certificate Management store (`Cloudflare.MtlsCertificate.MtlsCertificate` with
25 * `ca: true`). When omitted, the hostnames are associated with the zone's
26 * active Cloudflare Managed CA instead.
27 *
28 * Stable — the certificate keys the association, so changing it triggers
29 * a replacement (the old certificate's hostname list is cleared).
30 *
31 * @default the active Cloudflare Managed CA
32 */
33 mtlsCertificateId?: string;
34 /**
35 * Fully-qualified hostnames in the zone that enforce mTLS for the keyed
36 * certificate authority. Mutable — the desired list replaces the current
37 * one in full on update.
38 */
39 hostnames: Array<string>;
40};
41
42export type HostnameAssociationAttributes = {
43 /** Zone the association belongs to. */
44 zoneId: string;
45 /**
46 * The mTLS CA certificate the hostnames are associated with, or
47 * `undefined` when they are associated with the Cloudflare Managed CA.
48 */
49 mtlsCertificateId: string | undefined;
50 /** Hostnames currently enforcing mTLS for the keyed CA. */
51 hostnames: Array<string>;
52};
53
54export type HostnameAssociation = Resource<
55 TypeId,
56 HostnameAssociationProps,
57 HostnameAssociationAttributes,
58 never,
59 Providers
60>;
61
62/**
63 * The set of hostnames in a Cloudflare zone that enforce mTLS, optionally
64 * keyed by an uploaded mTLS CA certificate.
65 *
66 * Cloudflare models this as a settings singleton per
67 * `(zone, mtls_certificate_id)` pair — a pure GET/PUT API over
68 * `/zones/{zone_id}/certificate_authorities/hostname_associations`. With no
69 * `mtlsCertificateId`, the hostnames are associated with the zone's active
70 * Cloudflare Managed CA; with one, they are associated with that uploaded CA
71 * certificate. Destroying the resource clears the association (PUT of an
72 * empty hostname list).
73 *
74 * Safety: when there is no prior state but the keyed association already has
75 * hostnames, `read` reports the existing list as `Unowned` — the engine
76 * refuses to take it over (and would otherwise clobber a hand-managed list)
77 * unless `--adopt` or `adopt(true)` is set.
78 *
79 * Note: an mTLS CA certificate cannot be deleted while hostname associations
80 * still reference it. Pass the certificate id through
81 * `cert.mtlsCertificateId` so the engine destroys the association before the
82 * certificate.
83 * ### Cloudflare Managed CA
84 * **Example:** Enforce mTLS on a hostname with the Managed CA
85 * ```typescript
86 * yield* Cloudflare.CertificateAuthorities.HostnameAssociation("MtlsHosts", {
87 * zoneId: zone.zoneId,
88 * hostnames: ["api.example.com"],
89 * });
90 * ```
91 *
92 * ### Uploaded CA certificate
93 * **Example:** Associate hostnames with an uploaded CA
94 * ```typescript
95 * const ca = yield* Cloudflare.MtlsCertificate.MtlsCertificate("ClientCa", {
96 * ca: true,
97 * certificates: caPem,
98 * });
99 *
100 * yield* Cloudflare.CertificateAuthorities.HostnameAssociation("ClientCaHosts", {
101 * zoneId: zone.zoneId,
102 * mtlsCertificateId: ca.mtlsCertificateId,
103 * hostnames: ["api.example.com", "admin.example.com"],
104 * });
105 * ```
106 *
107 * @see https://developers.cloudflare.com/api/resources/certificate_authorities/subresources/hostname_associations/
108 *
109 * @resource
110 * @product Certificate Authorities
111 * @category SSL/TLS & Certificates
112 */
113export const HostnameAssociation = Resource<HostnameAssociation>(TypeId);
114
115/**
116 * Returns true if the given value is a HostnameAssociation resource.
117 */
118export const isHostnameAssociation = (
119 value: unknown,
120): value is HostnameAssociation =>
121 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
122
123export const HostnameAssociationProvider = () =>
124 Provider.succeed(HostnameAssociation, {
125 stables: ["zoneId", "mtlsCertificateId"],
126
127 list: Effect.fn(function* () {
128 const { accountId } = yield* yield* CloudflareEnvironment;
129 // The association is a per-zone settings singleton with no
130 // account-wide enumeration API. The only enumerable key is the
131 // zone's active Cloudflare Managed CA (no `mtlsCertificateId`):
132 // associations keyed by an uploaded CA can't be enumerated because
133 // there is no API to list the certificate ids in play. Fan out over
134 // every zone and read its Managed-CA association.
135 const zones = yield* listAllZones(accountId);
136 const rows = yield* Effect.forEach(
137 zones,
138 (zone) =>
139 certificateAuthorities
140 .getHostnameAssociation({ zoneId: zone.id })
141 .pipe(
142 Effect.map(
143 (observed): HostnameAssociationAttributes | undefined => {
144 const hostnames = [...(observed.hostnames ?? [])];
145 // An empty list is the singleton's "unconfigured"
146 // state — nothing exists to enumerate (matches `read`).
147 if (hostnames.length === 0) return undefined;
148 return {
149 zoneId: zone.id,
150 mtlsCertificateId: undefined,
151 hostnames,
152 };
153 },
154 ),
155 // Zones without the mTLS entitlement reject the route
156 // (Forbidden) or aren't routable (InvalidRoute); skip them.
157 Effect.catchTag(["Forbidden", "InvalidRoute"], () =>
158 Effect.succeed(undefined),
159 ),
160 ),
161 { concurrency: 10 },
162 );
163 return rows.filter(
164 (row): row is HostnameAssociationAttributes => row !== undefined,
165 );
166 }),
167
168 diff: Effect.fn(function* ({ olds = {}, news, output }) {
169 const o = olds as HostnameAssociationProps;
170 const n = news as HostnameAssociationProps;
171 // zoneId is Input<string>; compare only once both sides are concrete.
172 const oldZoneId =
173 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
174 if (
175 oldZoneId !== undefined &&
176 typeof n.zoneId === "string" &&
177 oldZoneId !== n.zoneId
178 ) {
179 return { action: "replace" } as const;
180 }
181 // mtlsCertificateId keys the association — `undefined` (Managed CA)
182 // is itself an identity value.
183 const oldCertId =
184 output !== undefined
185 ? output.mtlsCertificateId
186 : typeof o.mtlsCertificateId === "string"
187 ? o.mtlsCertificateId
188 : undefined;
189 // A presence flip (Managed CA <-> uploaded CA) is decidable even when
190 // the new side is an unresolved Input reference — the reference itself
191 // proves a certificate now keys (or no longer keys) the association.
192 if ((oldCertId === undefined) !== (n.mtlsCertificateId === undefined)) {
193 return { action: "replace" } as const;
194 }
195 // Both sides keyed by a certificate: compare only once the new side is
196 // a concrete string (an unresolved Input may still resolve to the same
197 // id, so we leave that case to the engine's default update).
198 if (
199 typeof n.mtlsCertificateId === "string" &&
200 oldCertId !== n.mtlsCertificateId
201 ) {
202 return { action: "replace" } as const;
203 }
204 return undefined;
205 }),
206
207 read: Effect.fn(function* ({ output, olds }) {
208 const zoneId =
209 output?.zoneId ??
210 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
211 if (!zoneId) return undefined;
212 const mtlsCertificateId =
213 output !== undefined
214 ? output.mtlsCertificateId
215 : typeof olds?.mtlsCertificateId === "string"
216 ? olds.mtlsCertificateId
217 : undefined;
218 const observed = yield* certificateAuthorities.getHostnameAssociation({
219 zoneId,
220 mtlsCertificateId,
221 });
222 const hostnames = [...(observed.hostnames ?? [])];
223 // An empty list is the singleton's "unconfigured" state — nothing
224 // exists to refresh or adopt.
225 if (hostnames.length === 0) return undefined;
226 const attrs: HostnameAssociationAttributes = {
227 zoneId,
228 mtlsCertificateId,
229 hostnames,
230 };
231 // Cold read with a non-empty pre-existing list: the association
232 // carries no ownership markers, so we cannot prove we created it —
233 // gate the takeover behind the adopt policy.
234 return output !== undefined ? attrs : Unowned(attrs);
235 }),
236
237 reconcile: Effect.fn(function* ({ news }) {
238 // Inputs have been resolved to concrete strings by Plan.
239 const zoneId = news.zoneId as string;
240 const mtlsCertificateId = news.mtlsCertificateId as string | undefined;
241 const desired = (news.hostnames ?? []) as Array<string>;
242
243 // 1. Observe — the association always "exists" (possibly empty);
244 // read the live hostname list.
245 const observed = yield* certificateAuthorities.getHostnameAssociation({
246 zoneId,
247 mtlsCertificateId,
248 });
249 const current = [...(observed.hostnames ?? [])];
250
251 // 2. Sync — full PUT of the desired list; skip when the observed
252 // set already matches (order is not significant).
253 if (hostnameSetEquals(current, desired)) {
254 return { zoneId, mtlsCertificateId, hostnames: current };
255 }
256 const updated = yield* certificateAuthorities.putHostnameAssociation({
257 zoneId,
258 mtlsCertificateId,
259 hostnames: desired,
260 });
261 return {
262 zoneId,
263 mtlsCertificateId,
264 hostnames: [...(updated.hostnames ?? desired)],
265 };
266 }),
267
268 delete: Effect.fn(function* ({ output }) {
269 // The association is a settings singleton — "delete" means clearing
270 // the hostname list. PUT of an empty list is naturally idempotent.
271 yield* certificateAuthorities.putHostnameAssociation({
272 zoneId: output.zoneId,
273 mtlsCertificateId: output.mtlsCertificateId,
274 hostnames: [],
275 });
276 }),
277 });
278
279/** Order-insensitive equality of two hostname lists (exact duplicates kept). */
280const hostnameSetEquals = (
281 a: ReadonlyArray<string>,
282 b: ReadonlyArray<string>,
283): boolean => {
284 if (a.length !== b.length) return false;
285 const sortedA = [...a].sort();
286 const sortedB = [...b].sort();
287 return sortedA.every((v, i) => v === sortedB[i]);
288};