1import * as originTls from "@distilled.cloud/cloudflare/origin-tls-client-auth";
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 type { Providers } from "../Providers.ts";
10
11const TypeId = "Cloudflare.OriginTlsClientAuth.HostnameAssociation" as const;
12type TypeId = typeof TypeId;
13
14/**
15 * Deployment status of the association or its pinned certificate. Changes
16 * propagate asynchronously (`pending_deployment` → `active`), typically
17 * settling within seconds.
18 */
19export type HostnameAssociationStatus =
20 | "initializing"
21 | "pending_deployment"
22 | "pending_deletion"
23 | "active"
24 | "deleted"
25 | "deployment_timed_out"
26 | "deletion_timed_out"
27 // Keep the union open so new Cloudflare statuses aren't blocked by stale types.
28 | (string & {});
29
30export type HostnameAssociationProps = {
31 /**
32 * Zone the hostname belongs to. Cannot be changed — updating this property
33 * triggers a replacement.
34 */
35 zoneId: string;
36 /**
37 * The hostname on the origin for which the client certificate will be
38 * presented. Must be a hostname of the zone. This is the association's
39 * identity — updating it triggers a replacement (the old hostname's
40 * association is voided).
41 */
42 hostname: string;
43 /**
44 * Identifier of the hostname client certificate
45 * ({@link HostnameCertificate}) presented to the origin
46 * for this hostname. Required by Cloudflare for every hostname AOP
47 * configuration. Mutable — updated in place.
48 */
49 certId: string;
50 /**
51 * Whether hostname-level Authenticated Origin Pulls is enabled for this
52 * hostname. Mutable — updated in place. On destroy the association is
53 * voided (Cloudflare's `enabled: null`), restoring the hostname to
54 * zone-level behavior.
55 */
56 enabled: boolean;
57};
58
59export type HostnameAssociationAttributes = {
60 /** Zone the hostname belongs to. */
61 zoneId: string;
62 /** The hostname the association applies to. */
63 hostname: string;
64 /** Identifier of the pinned hostname client certificate. */
65 certId: string;
66 /** Whether hostname-level Authenticated Origin Pulls is enabled. */
67 enabled: boolean;
68 /** Deployment status of the association. */
69 status: HostnameAssociationStatus | undefined;
70 /** Deployment status of the pinned certificate. */
71 certStatus: HostnameAssociationStatus | undefined;
72};
73
74export type HostnameAssociation = Resource<
75 TypeId,
76 HostnameAssociationProps,
77 HostnameAssociationAttributes,
78 never,
79 Providers
80>;
81
82/**
83 * A per-hostname Authenticated Origin Pulls (AOP) association
84 * (`/zones/{zone_id}/origin_tls_client_auth/hostnames`).
85 *
86 * Pins a hostname client certificate
87 * ({@link HostnameCertificate}) to a hostname and toggles
88 * hostname-level AOP for it. Cloudflare's API is a bulk upsert keyed by
89 * hostname; this resource manages exactly one hostname per instance, so
90 * separate instances for different hostnames are safe to deploy
91 * concurrently. On destroy the association is voided (`enabled: null`),
92 * which restores the hostname to zone-level AOP behavior.
93 * ### Enabling AOP for a hostname
94 * **Example:** Associate a hostname with a client certificate
95 * ```typescript
96 * const cert = yield* Cloudflare.OriginTlsClientAuth.HostnameCertificate("AopHostCert", {
97 * zoneId: zone.zoneId,
98 * certificate: clientCertPem,
99 * privateKey: yield* Config.Redacted("AOP_CLIENT_KEY"),
100 * });
101 *
102 * yield* Cloudflare.OriginTlsClientAuth.HostnameAssociation("AopHost", {
103 * zoneId: zone.zoneId,
104 * hostname: "api.example.com",
105 * certId: cert.certificateId,
106 * enabled: true,
107 * });
108 * ```
109 *
110 * **Example:** Keep the certificate pinned but disable enforcement
111 * ```typescript
112 * yield* Cloudflare.OriginTlsClientAuth.HostnameAssociation("AopHost", {
113 * zoneId: zone.zoneId,
114 * hostname: "api.example.com",
115 * certId: cert.certificateId,
116 * enabled: false,
117 * });
118 * ```
119 *
120 * @see https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/set-up/per-hostname/
121 *
122 * @resource
123 * @product Origin TLS Client Auth
124 * @category SSL/TLS & Certificates
125 */
126export const HostnameAssociation = Resource<HostnameAssociation>(TypeId);
127
128/**
129 * Returns true if the given value is an HostnameAssociation
130 * resource.
131 */
132export const isHostnameAssociation = (
133 value: unknown,
134): value is HostnameAssociation =>
135 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
136
137export const HostnameAssociationProvider = () =>
138 Provider.succeed(HostnameAssociation, {
139 stables: ["zoneId", "hostname"],
140
141 // Non-listable: an association is keyed entirely by {zoneId, hostname}
142 // and the only read op is `getHostname({ zoneId, hostname })`. Cloudflare
143 // exposes no endpoint that enumerates which hostnames in a zone have an
144 // AOP association (`listHostnameCertificates` lists certificates, which
145 // carry no hostname), so there is provably no way to discover the
146 // hostnames to read. Return [] rather than throwing.
147 list: () => Effect.succeed<HostnameAssociationAttributes[]>([]),
148
149 diff: Effect.fn(function* ({ olds, news }) {
150 if (!isResolved(news)) return undefined;
151 // zoneId is Input<string>; compare only once both sides are concrete.
152 if (
153 typeof olds.zoneId === "string" &&
154 typeof news.zoneId === "string" &&
155 olds.zoneId !== news.zoneId
156 ) {
157 return { action: "replace" } as const;
158 }
159 // The hostname is the association's identity in Cloudflare's bulk
160 // upsert — changing it must void the old hostname's entry, so it is a
161 // replacement.
162 if (olds.hostname !== news.hostname) {
163 return { action: "replace" } as const;
164 }
165 return undefined;
166 }),
167
168 read: Effect.fn(function* ({ output, olds }) {
169 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
170 const hostname = output?.hostname ?? olds?.hostname;
171 if (!zoneId || !hostname) return undefined;
172 const observed = yield* observeAssociation(zoneId, hostname);
173 if (!observed) return undefined;
174 const attrs = toAttributes(observed, zoneId, hostname);
175 // Cold read (no prior output): an association for this hostname exists
176 // but carries no ownership markers, so we cannot prove we created it —
177 // brand it `Unowned` so the engine refuses to take it over unless
178 // `adopt` is set.
179 return output === undefined ? Unowned(attrs) : attrs;
180 }),
181
182 reconcile: Effect.fn(function* ({ news }) {
183 // Inputs have been resolved to concrete strings by Plan.
184 const zoneId = news.zoneId as string;
185 const certId = news.certId as string;
186 const hostname = news.hostname;
187
188 // 1. Observe — the hostname is the deterministic identity; a voided
189 // (`enabled: null`) or missing entry reads as absent.
190 const observed = yield* observeAssociation(zoneId, hostname);
191
192 // 2. Sync — the PUT is a true upsert; skip it when the observed entry
193 // already matches the desired certificate and enablement.
194 if (
195 observed &&
196 observed.certId === certId &&
197 observed.enabled === news.enabled
198 ) {
199 return toAttributes(observed, zoneId, hostname);
200 }
201 const put = yield* originTls.putHostname({
202 zoneId,
203 config: [{ hostname, certId, enabled: news.enabled }],
204 });
205 const result = put.result.find((r) => r.hostname === hostname);
206
207 // 3. Return — propagation is asynchronous (`pending_deployment` →
208 // `active`); we do not block on activation.
209 return toAttributes(
210 result ?? { certId, enabled: news.enabled },
211 zoneId,
212 hostname,
213 );
214 }),
215
216 delete: Effect.fn(function* ({ output }) {
217 const { zoneId, hostname } = output;
218 // Observe first — voiding an already-voided / never-created hostname
219 // is a no-op, and the PUT needs the certificate id currently pinned to
220 // the association.
221 const observed = yield* observeAssociation(zoneId, hostname);
222 if (!observed) return;
223 yield* originTls
224 .putHostname({
225 zoneId,
226 // `enabled: null` voids the association; Cloudflare still requires
227 // the pinned certificate id in the payload.
228 config: [
229 {
230 hostname,
231 certId: observed.certId ?? output.certId,
232 enabled: null,
233 },
234 ],
235 })
236 .pipe(
237 // Code 1415: the pinned certificate was already deleted out from
238 // under the association, which Cloudflare rejects even for a void.
239 // The association is inert without its certificate — treat as done.
240 Effect.catchTag("InvalidHostnameConfig", () => Effect.void),
241 );
242 }),
243 });
244
245// Observe a hostname's association, mapping "never configured" (HTTP 404 /
246// code 1553) and "voided" (`enabled: null`) to `undefined`.
247const observeAssociation = (zoneId: string, hostname: string) =>
248 originTls.getHostname({ zoneId, hostname }).pipe(
249 Effect.map((assoc) =>
250 assoc.enabled === null || assoc.enabled === undefined ? undefined : assoc,
251 ),
252 Effect.catchTag("HostnameAssociationNotFound", () =>
253 Effect.succeed(undefined),
254 ),
255 );
256
257type AssociationShape = {
258 certId?: string | null;
259 enabled?: boolean | null;
260 status?: string | null;
261 certStatus?: string | null;
262};
263
264const toAttributes = (
265 assoc: AssociationShape,
266 zoneId: string,
267 hostname: string,
268): HostnameAssociationAttributes => ({
269 zoneId,
270 hostname,
271 certId: assoc.certId!,
272 enabled: assoc.enabled ?? false,
273 status: assoc.status ?? undefined,
274 certStatus: assoc.certStatus ?? undefined,
275});