1import * as customHostnames from "@distilled.cloud/cloudflare/custom-hostnames";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { arrayEqualsUnordered } from "../../Util/equal.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12import { listAllZones } from "../Zone/lookup.ts";
13
14/**
15 * Domain control validation (DCV) method used to prove control over the
16 * custom hostname before a certificate is issued.
17 */
18export type DcvMethod = "http" | "txt" | "email";
19
20/**
21 * Certificate authority that issues the managed certificate.
22 */
23export type CertificateAuthority =
24 | "digicert"
25 | "google"
26 | "lets_encrypt"
27 | "ssl_com";
28
29/**
30 * Per-hostname TLS settings applied to the edge certificate.
31 */
32export type SslSettings = {
33 /** Allowed cipher suites. */
34 ciphers?: string[];
35 /** Whether Early Hints (HTTP 103) is enabled. */
36 earlyHints?: "on" | "off";
37 /** Whether HTTP/2 is enabled. */
38 http2?: "on" | "off";
39 /** Minimum TLS version served for this hostname. */
40 minTlsVersion?: "1.0" | "1.1" | "1.2" | "1.3";
41 /** Whether TLS 1.3 is enabled. */
42 tls_1_3?: "on" | "off";
43};
44
45/**
46 * SSL configuration for a custom hostname's managed certificate.
47 */
48export type Ssl = {
49 /**
50 * Domain control validation method.
51 * @default "txt"
52 */
53 method?: DcvMethod;
54 /**
55 * Level of validation for the certificate. Only domain validation
56 * (`dv`) is supported.
57 * @default "dv"
58 */
59 type?: "dv";
60 /**
61 * Certificate authority that issues the certificate. Omit to let
62 * Cloudflare choose.
63 */
64 certificateAuthority?: CertificateAuthority;
65 /**
66 * How the intermediate chain is bundled with the leaf certificate.
67 */
68 bundleMethod?: "ubiquitous" | "optimal" | "force";
69 /**
70 * Whether to add Cloudflare branding to the certificate (adds
71 * `sni.cloudflaressl.com` as the certificate common name).
72 */
73 cloudflareBranding?: boolean;
74 /**
75 * Whether the certificate also covers `*.hostname`. Toggling can
76 * trigger certificate reissuance, but is still an in-place update.
77 */
78 wildcard?: boolean;
79 /**
80 * Bring-your-own leaf certificate (PEM). Requires `customKey`.
81 */
82 customCertificate?: string;
83 /**
84 * Private key (PEM) for `customCertificate`.
85 */
86 customKey?: string;
87 /**
88 * Identifier of a previously generated custom CSR.
89 */
90 customCsrId?: string;
91 /**
92 * Per-hostname TLS settings.
93 */
94 settings?: SslSettings;
95};
96
97export interface Props {
98 /**
99 * Zone the custom hostname is onboarded onto (the SaaS zone). Stable —
100 * changing the zone triggers replacement.
101 */
102 zoneId: string;
103 /**
104 * The customer-owned hostname that will point at your zone via CNAME
105 * (e.g. `app.customer.com`).
106 *
107 * Stable — the hostname is the resource's identity and is not
108 * patchable; a rename is a delete + create. Declared as plain `string`
109 * (not `string`) so it is statically knowable inside `diff`.
110 */
111 hostname: string;
112 /**
113 * SSL configuration for the managed certificate. Mutable — patched in
114 * place (note that changing `ssl` can trigger certificate
115 * reissuance).
116 *
117 * @default { method: "txt", type: "dv" }
118 */
119 ssl?: Ssl;
120 /**
121 * Unique key/value metadata for this hostname, available to Workers
122 * via the request. Requires the Enterprise Cloudflare for SaaS
123 * entitlement — the API rejects it otherwise.
124 */
125 customMetadata?: Record<string, unknown>;
126 /**
127 * Origin server to route traffic for this hostname to, overriding the
128 * zone's fallback origin. Must be a DNS record within the zone.
129 * Requires the Enterprise Cloudflare for SaaS entitlement.
130 */
131 customOriginServer?: string;
132 /**
133 * SNI value sent to `customOriginServer` during the TLS handshake, or
134 * the literal `:request_host_header:`. Requires the Enterprise
135 * Cloudflare for SaaS entitlement.
136 */
137 customOriginSni?: string;
138}
139
140/**
141 * TXT record the customer must create to prove ownership of the
142 * hostname (pre-validation).
143 */
144export interface OwnershipVerification {
145 /** TXT record name. */
146 name: string | undefined;
147 /** Record type (always `txt`). */
148 type: string | undefined;
149 /** TXT record value. */
150 value: string | undefined;
151}
152
153/**
154 * HTTP token alternative for ownership verification.
155 */
156export interface OwnershipVerificationHttp {
157 /** URL the token must be served from. */
158 httpUrl: string | undefined;
159 /** Token body to serve. */
160 httpBody: string | undefined;
161}
162
163/**
164 * A DCV record the customer must create/serve for certificate
165 * validation.
166 */
167export interface ValidationRecord {
168 /** CNAME validation record name. */
169 cname: string | undefined;
170 /** CNAME validation record target. */
171 cnameTarget: string | undefined;
172 /** Email addresses validation mail is sent to. */
173 emails: string[] | undefined;
174 /** HTTP validation token body. */
175 httpBody: string | undefined;
176 /** HTTP validation token URL. */
177 httpUrl: string | undefined;
178 /** Validation record status. */
179 status: string | undefined;
180 /** TXT validation record name. */
181 txtName: string | undefined;
182 /** TXT validation record value. */
183 txtValue: string | undefined;
184}
185
186export interface Attributes {
187 /** Cloudflare-assigned custom hostname UUID. */
188 customHostnameId: string;
189 /** Zone that owns this custom hostname. */
190 zoneId: string;
191 /** The customer-owned hostname. */
192 hostname: string;
193 /**
194 * Activation status of the hostname (`pending`, `active`, …). A
195 * hostname for a domain whose DNS the customer has not pointed yet
196 * stays `pending` — activation is asynchronous and not blocked on.
197 */
198 status: string | undefined;
199 /** Certificate status (`initializing`, `pending_validation`, `active`, …). */
200 sslStatus: string | undefined;
201 /** TXT record the customer must create to verify ownership. */
202 ownershipVerification: OwnershipVerification | undefined;
203 /** HTTP token alternative for ownership verification. */
204 ownershipVerificationHttp: OwnershipVerificationHttp | undefined;
205 /** DCV records the customer must satisfy for certificate issuance. */
206 validationRecords: ValidationRecord[] | undefined;
207}
208
209export type CustomHostname = Resource<
210 "Cloudflare.CustomHostname.CustomHostname",
211 Props,
212 Attributes,
213 never,
214 Providers
215>;
216
217/**
218 * A Cloudflare for SaaS custom hostname.
219 *
220 * Onboards a customer-owned hostname onto your zone with a managed TLS
221 * certificate. The customer points their DNS (CNAME) at your zone;
222 * Cloudflare validates ownership and issues a certificate
223 * asynchronously. The first 100 custom hostnames are free on any plan.
224 *
225 * Traffic for custom hostnames is routed to the zone's
226 * {@link FallbackOrigin} (or `customOriginServer` with the Enterprise
227 * entitlement), so a fallback origin should usually be deployed
228 * alongside.
229 *
230 * Safety: when there is no prior state, `read` scans the zone for an
231 * existing hostname match. Custom hostnames carry no ownership markers,
232 * so an existing match is reported as `Unowned` and the engine refuses
233 * to take it over unless `--adopt` (or `adopt(true)`) is set.
234 * ### Creating a Custom Hostname
235 * **Example:** Basic custom hostname with TXT validation
236 * ```typescript
237 * const hostname = yield* Cloudflare.CustomHostname.CustomHostname("CustomerApp", {
238 * zoneId: zone.zoneId,
239 * hostname: "app.customer.com",
240 * });
241 * // Hand these to the customer so they can verify ownership:
242 * // hostname.ownershipVerification?.name / .value
243 * ```
244 *
245 * **Example:** HTTP validation with a specific certificate authority
246 * ```typescript
247 * yield* Cloudflare.CustomHostname.CustomHostname("CustomerApp", {
248 * zoneId: zone.zoneId,
249 * hostname: "app.customer.com",
250 * ssl: {
251 * method: "http",
252 * type: "dv",
253 * certificateAuthority: "google",
254 * },
255 * });
256 * ```
257 *
258 * ### Pairing with a Fallback Origin
259 * **Example:** Route custom hostname traffic to your origin
260 * ```typescript
261 * const record = yield* Cloudflare.DNS.Record("Origin", {
262 * zoneId: zone.zoneId,
263 * name: "origin.my-saas.com",
264 * type: "A",
265 * content: "203.0.113.1",
266 * proxied: true,
267 * });
268 * yield* Cloudflare.CustomHostname.FallbackOrigin("Fallback", {
269 * zoneId: zone.zoneId,
270 * origin: record.name,
271 * });
272 * yield* Cloudflare.CustomHostname.CustomHostname("CustomerApp", {
273 * zoneId: zone.zoneId,
274 * hostname: "app.customer.com",
275 * });
276 * ```
277 *
278 * @resource
279 * @product Custom Hostnames
280 * @category Domains & DNS
281 */
282export const CustomHostname = Resource<CustomHostname>(
283 "Cloudflare.CustomHostname.CustomHostname",
284 { aliases: ["Cloudflare.CustomHostname"] },
285);
286
287export const isCustomHostname = (value: unknown): value is CustomHostname =>
288 Predicate.hasProperty(value, "Type") &&
289 value.Type === "Cloudflare.CustomHostname.CustomHostname";
290
291export const CustomHostnameProvider = () =>
292 Provider.succeed(CustomHostname, {
293 stables: ["customHostnameId", "zoneId", "hostname"],
294
295 // Custom hostnames are a zone-scoped Cloudflare for SaaS feature. Fan out
296 // over every zone in the account, exhaustively paginate each zone's custom
297 // hostnames, and hydrate each into the same Attributes shape `read`
298 // produces. Zones without the SaaS entitlement reject with the typed
299 // `SaasQuotaNotAllocated`/`Forbidden` errors — skip them.
300 list: Effect.fn(function* () {
301 const { accountId } = yield* yield* CloudflareEnvironment;
302 const zones = yield* listAllZones(accountId);
303 const rows = yield* Effect.forEach(
304 zones,
305 (zone) =>
306 customHostnames.listCustomHostnames.pages({ zoneId: zone.id }).pipe(
307 Stream.runCollect,
308 Effect.map((chunk) =>
309 Array.from(chunk).flatMap((page) =>
310 (page.result ?? []).map((raw): Attributes =>
311 toAttributes(narrowHostname(raw), zone.id),
312 ),
313 ),
314 ),
315 Effect.catchTag("SaasQuotaNotAllocated", () => Effect.succeed([])),
316 Effect.catchTag("Forbidden", () => Effect.succeed([])),
317 ),
318 { concurrency: 10 },
319 );
320 return rows.flat();
321 }),
322
323 diff: Effect.fn(function* ({ olds = {}, news }) {
324 const o = olds as Props;
325 const n = news as Props;
326 if (o.hostname !== undefined && o.hostname !== n.hostname) {
327 return { action: "replace" } as const;
328 }
329 // zoneId is Input<string>; compare only once both sides are
330 // concrete strings.
331 if (
332 typeof o.zoneId === "string" &&
333 typeof n.zoneId === "string" &&
334 o.zoneId !== n.zoneId
335 ) {
336 return { action: "replace" } as const;
337 }
338 }),
339
340 reconcile: Effect.fn(function* ({ news, output }) {
341 // Inputs have been resolved to concrete strings by Plan.
342 const zoneId = news.zoneId as string;
343 const customOriginServer = news.customOriginServer as string | undefined;
344 const desiredSsl = buildSslBody(news.ssl);
345
346 // 1. Observe by cached id first.
347 let observed: ObservedHostname | undefined;
348 if (output?.customHostnameId) {
349 observed = yield* observeById(zoneId, output.customHostnameId);
350 }
351
352 // 2. Fall back to scanning the zone for the hostname (unique per
353 // zone). Ownership has already been verified upstream — `read`
354 // reports existing hostnames as `Unowned` and the engine gates
355 // takeover behind the adopt policy before reconcile runs.
356 if (!observed) {
357 observed = yield* findByHostname(zoneId, news.hostname);
358 }
359
360 // 3. Ensure. A create that races another writer (hostname already
361 // exists) is converged by re-observing; if the hostname still
362 // cannot be found the original error is re-raised.
363 let justCreated = false;
364 if (!observed) {
365 observed = yield* customHostnames
366 .createCustomHostname({
367 zoneId,
368 hostname: news.hostname,
369 ssl: desiredSsl,
370 customMetadata: news.customMetadata,
371 })
372 .pipe(
373 Effect.map(narrowHostname),
374 Effect.catch((originalError) =>
375 Effect.gen(function* () {
376 const existing = yield* findByHostname(
377 zoneId,
378 news.hostname,
379 ).pipe(Effect.catch(() => Effect.succeed(undefined)));
380 if (!existing) return yield* Effect.fail(originalError);
381 return existing;
382 }),
383 ),
384 );
385 justCreated = true;
386 }
387
388 // 4. Sync — diff each mutable aspect against observed cloud state
389 // and PATCH only the delta. `ssl` is only sent when it actually
390 // differs because patching it can trigger certificate
391 // reissuance.
392 const patch: {
393 ssl?: ReturnType<typeof buildSslBody>;
394 customMetadata?: Record<string, unknown>;
395 customOriginServer?: string;
396 customOriginSni?: string;
397 } = {};
398 if (
399 !justCreated &&
400 news.ssl !== undefined &&
401 !sslEqualsObserved(news.ssl, observed.ssl)
402 ) {
403 patch.ssl = desiredSsl;
404 }
405 if (
406 news.customMetadata !== undefined &&
407 !metadataEquals(news.customMetadata, observed.customMetadata)
408 ) {
409 patch.customMetadata = news.customMetadata;
410 }
411 if (
412 customOriginServer !== undefined &&
413 customOriginServer !== observed.customOriginServer
414 ) {
415 patch.customOriginServer = customOriginServer;
416 }
417 if (
418 news.customOriginSni !== undefined &&
419 news.customOriginSni !== observed.customOriginSni
420 ) {
421 patch.customOriginSni = news.customOriginSni;
422 }
423 if (Object.keys(patch).length > 0) {
424 observed = narrowHostname(
425 yield* customHostnames.patchCustomHostname({
426 zoneId,
427 customHostnameId: observed.id,
428 ...patch,
429 }),
430 );
431 }
432
433 // 5. Return fresh attributes.
434 return toAttributes(observed, zoneId);
435 }),
436
437 delete: Effect.fn(function* ({ output }) {
438 yield* customHostnames
439 .deleteCustomHostname({
440 zoneId: output.zoneId,
441 customHostnameId: output.customHostnameId,
442 })
443 .pipe(Effect.catchTag("CustomHostnameNotFound", () => Effect.void));
444 }),
445
446 read: Effect.fn(function* ({ output, olds }) {
447 // Owned path: we have persisted state (our own id) — refresh it.
448 if (output?.customHostnameId) {
449 const observed = yield* observeById(
450 output.zoneId,
451 output.customHostnameId,
452 );
453 if (observed) return toAttributes(observed, output.zoneId);
454 }
455 // Adoption path: no state of our own, but the hostname may already
456 // exist on the zone. Custom hostnames carry no ownership markers we
457 // can inspect, so brand any match `Unowned` — the engine refuses to
458 // take over unless `adopt` is set.
459 const zoneId =
460 output?.zoneId ??
461 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
462 const hostname = output?.hostname ?? olds?.hostname;
463 if (zoneId && hostname) {
464 const observed = yield* findByHostname(zoneId, hostname);
465 if (observed) return Unowned(toAttributes(observed, zoneId));
466 }
467 return undefined;
468 }),
469 });
470
471// ---------------------------------------------------------------------------
472// Observation
473// ---------------------------------------------------------------------------
474
475// A missing hostname is reported by the custom-hostnames API with code
476// 1436 ("Custom hostname not found") on a 404 envelope — typed in the
477// distilled union as `CustomHostnameNotFound`.
478const observeById = (zoneId: string, customHostnameId: string) =>
479 customHostnames.getCustomHostname({ zoneId, customHostnameId }).pipe(
480 Effect.map(narrowHostname),
481 Effect.catchTag("CustomHostnameNotFound", () => Effect.succeed(undefined)),
482 );
483
484// Locate an existing custom hostname by exact hostname. The API only
485// offers a `contain` filter, so an exact client-side match is applied on
486// top.
487const findByHostname = (zoneId: string, hostname: string) =>
488 customHostnames.listCustomHostnames
489 .items({ zoneId, hostname: { contain: hostname } })
490 .pipe(
491 Stream.runCollect,
492 Effect.map((chunk) =>
493 Array.from(chunk).find((h) => h.hostname === hostname),
494 ),
495 Effect.map((found) =>
496 found === undefined ? undefined : narrowHostname(found),
497 ),
498 );
499
500interface ObservedSsl {
501 readonly status?: string;
502 readonly method?: string;
503 readonly type?: string;
504 readonly wildcard?: boolean;
505 readonly bundleMethod?: string;
506 readonly certificateAuthority?: string;
507 readonly settings?: {
508 readonly ciphers?: ReadonlyArray<string>;
509 readonly earlyHints?: string;
510 readonly http2?: string;
511 readonly minTlsVersion?: string;
512 readonly tls_1_3?: string;
513 };
514 readonly validationRecords?: ReadonlyArray<{
515 readonly cname?: string;
516 readonly cnameTarget?: string;
517 readonly emails?: string[];
518 readonly httpBody?: string;
519 readonly httpUrl?: string;
520 readonly status?: string;
521 readonly txtName?: string;
522 readonly txtValue?: string;
523 }>;
524}
525
526interface ObservedHostname {
527 readonly id: string;
528 readonly hostname: string;
529 readonly status?: string;
530 readonly customMetadata?: Record<string, unknown>;
531 readonly customOriginServer?: string;
532 readonly customOriginSni?: string;
533 readonly ownershipVerification?: {
534 readonly name?: string;
535 readonly type?: string;
536 readonly value?: string;
537 };
538 readonly ownershipVerificationHttp?: {
539 readonly httpBody?: string;
540 readonly httpUrl?: string;
541 };
542 readonly ssl?: ObservedSsl;
543}
544
545const undef = <T>(v: T | null | undefined): T | undefined =>
546 v == null ? undefined : v;
547
548type RawHostname =
549 | customHostnames.GetCustomHostnameResponse
550 | customHostnames.CreateCustomHostnameResponse
551 | customHostnames.PatchCustomHostnameResponse
552 | customHostnames.ListCustomHostnamesResponse["result"][number];
553
554const narrowHostname = (raw: RawHostname): ObservedHostname => ({
555 id: raw.id,
556 hostname: raw.hostname,
557 status: undef(raw.status),
558 customMetadata: undef(raw.customMetadata),
559 customOriginServer: undef(raw.customOriginServer),
560 customOriginSni: undef(raw.customOriginSni),
561 ownershipVerification:
562 raw.ownershipVerification == null
563 ? undefined
564 : {
565 name: undef(raw.ownershipVerification.name),
566 type: undef(raw.ownershipVerification.type),
567 value: undef(raw.ownershipVerification.value),
568 },
569 ownershipVerificationHttp:
570 raw.ownershipVerificationHttp == null
571 ? undefined
572 : {
573 httpBody: undef(raw.ownershipVerificationHttp.httpBody),
574 httpUrl: undef(raw.ownershipVerificationHttp.httpUrl),
575 },
576 ssl:
577 raw.ssl == null
578 ? undefined
579 : {
580 status: undef(raw.ssl.status),
581 method: undef(raw.ssl.method),
582 type: undef(raw.ssl.type),
583 wildcard: undef(raw.ssl.wildcard),
584 bundleMethod: undef(raw.ssl.bundleMethod),
585 certificateAuthority: undef(raw.ssl.certificateAuthority),
586 settings:
587 raw.ssl.settings == null
588 ? undefined
589 : {
590 ciphers: undef(raw.ssl.settings.ciphers),
591 earlyHints: undef(raw.ssl.settings.earlyHints),
592 http2: undef(raw.ssl.settings.http2),
593 minTlsVersion: undef(raw.ssl.settings.minTlsVersion),
594 tls_1_3: undef(raw.ssl.settings.tls_1_3),
595 },
596 validationRecords:
597 raw.ssl.validationRecords == null
598 ? undefined
599 : raw.ssl.validationRecords.map((r) => ({
600 cname: undef(r.cname),
601 cnameTarget: undef(r.cnameTarget),
602 emails: undef(r.emails),
603 httpBody: undef(r.httpBody),
604 httpUrl: undef(r.httpUrl),
605 status: undef(r.status),
606 txtName: undef(r.txtName),
607 txtValue: undef(r.txtValue),
608 })),
609 },
610});
611
612const toAttributes = (
613 observed: ObservedHostname,
614 zoneId: string,
615): Attributes => ({
616 customHostnameId: observed.id,
617 zoneId,
618 hostname: observed.hostname,
619 status: observed.status,
620 sslStatus: observed.ssl?.status,
621 ownershipVerification:
622 observed.ownershipVerification === undefined
623 ? undefined
624 : {
625 name: observed.ownershipVerification.name,
626 type: observed.ownershipVerification.type,
627 value: observed.ownershipVerification.value,
628 },
629 ownershipVerificationHttp:
630 observed.ownershipVerificationHttp === undefined
631 ? undefined
632 : {
633 httpUrl: observed.ownershipVerificationHttp.httpUrl,
634 httpBody: observed.ownershipVerificationHttp.httpBody,
635 },
636 validationRecords:
637 observed.ssl?.validationRecords === undefined
638 ? undefined
639 : observed.ssl.validationRecords.map((r) => ({
640 cname: r.cname,
641 cnameTarget: r.cnameTarget,
642 emails: r.emails,
643 httpBody: r.httpBody,
644 httpUrl: r.httpUrl,
645 status: r.status,
646 txtName: r.txtName,
647 txtValue: r.txtValue,
648 })),
649});
650
651// ---------------------------------------------------------------------------
652// Body construction + drift detection
653// ---------------------------------------------------------------------------
654
655const buildSslBody = (ssl: Ssl | undefined) => ({
656 method: ssl?.method ?? "txt",
657 type: ssl?.type ?? ("dv" as const),
658 certificateAuthority: ssl?.certificateAuthority,
659 bundleMethod: ssl?.bundleMethod,
660 cloudflareBranding: ssl?.cloudflareBranding,
661 wildcard: ssl?.wildcard,
662 customCertificate: ssl?.customCertificate,
663 customKey: ssl?.customKey,
664 customCsrId: ssl?.customCsrId,
665 settings: ssl?.settings,
666});
667
668/**
669 * Compare desired SSL config against observed cloud state, considering
670 * only fields the user actually set (Cloudflare echoes more fields than
671 * we manage, with defaults we should not fight). Returns `true` when no
672 * patch is needed.
673 */
674const sslEqualsObserved = (
675 desired: Ssl,
676 observed: ObservedSsl | undefined,
677): boolean => {
678 if (observed === undefined) return false;
679 if (desired.method !== undefined && desired.method !== observed.method) {
680 return false;
681 }
682 if (desired.type !== undefined && desired.type !== observed.type) {
683 return false;
684 }
685 if (
686 desired.wildcard !== undefined &&
687 desired.wildcard !== (observed.wildcard ?? false)
688 ) {
689 return false;
690 }
691 if (
692 desired.bundleMethod !== undefined &&
693 desired.bundleMethod !== observed.bundleMethod
694 ) {
695 return false;
696 }
697 if (
698 desired.certificateAuthority !== undefined &&
699 desired.certificateAuthority !== observed.certificateAuthority
700 ) {
701 return false;
702 }
703 const s = desired.settings;
704 if (s !== undefined) {
705 const o = observed.settings;
706 if (s.ciphers !== undefined && !arrayEqualsUnordered(s.ciphers, o?.ciphers))
707 return false;
708 if (s.earlyHints !== undefined && s.earlyHints !== o?.earlyHints)
709 return false;
710 if (s.http2 !== undefined && s.http2 !== o?.http2) return false;
711 if (s.minTlsVersion !== undefined && s.minTlsVersion !== o?.minTlsVersion)
712 return false;
713 if (s.tls_1_3 !== undefined && s.tls_1_3 !== o?.tls_1_3) return false;
714 }
715 return true;
716};
717
718const metadataEquals = (
719 desired: Record<string, unknown>,
720 observed: Record<string, unknown> | undefined,
721): boolean => {
722 const o = observed ?? {};
723 const dKeys = Object.keys(desired);
724 const oKeys = Object.keys(o);
725 if (dKeys.length !== oKeys.length) return false;
726 for (const k of dKeys) {
727 if (JSON.stringify(desired[k]) !== JSON.stringify(o[k])) return false;
728 }
729 return true;
730};