1import { Region as AwsRegion } from "@distilled.cloud/aws/Region";
2import * as acm from "@distilled.cloud/aws/acm";
3import * as route53 from "@distilled.cloud/aws/route-53";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { deepEqual, isResolved } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource, type ResourceBinding } from "../../Resource.ts";
11import {
12 createInternalTags,
13 createTagsList,
14 diffTags,
15 hasAlchemyTags,
16} from "../../Tags.ts";
17import type { Providers } from "../Providers.ts";
18import { findPublicHostedZoneId } from "../Route53/HostedZoneLookup.ts";
19
20export interface CertificateProps {
21 /**
22 * Primary domain name for the certificate.
23 */
24 domainName: string;
25 /**
26 * Additional domain names to include on the certificate.
27 */
28 subjectAlternativeNames?: string[];
29 /**
30 * Validation method for the certificate request.
31 * @default "DNS"
32 */
33 validationMethod?: acm.ValidationMethod;
34 /**
35 * Route 53 hosted zone used to auto-create DNS validation records.
36 *
37 * With `validationMethod: "DNS"` the certificate provider upserts the
38 * validation records into this zone and waits for issuance. When
39 * omitted, the most specific PUBLIC hosted zone in the account
40 * containing `domainName` is inferred; if none matches, validation is
41 * left to the caller (external DNS) and the certificate is returned
42 * pending.
43 */
44 hostedZoneId?: string;
45 /**
46 * Requested key algorithm.
47 */
48 keyAlgorithm?: acm.KeyAlgorithm;
49 /**
50 * Certificate transparency logging preference.
51 *
52 * Updated in place via `UpdateCertificateOptions`. Note that AWS no longer
53 * allows opting new public certificates out of CT logging.
54 */
55 certificateTransparencyLoggingPreference?: "ENABLED" | "DISABLED" | undefined;
56 /**
57 * Whether the certificate's private key can be exported with
58 * `acm:ExportCertificate` (see the `ExportCertificate` binding).
59 * Exportable public certificates carry an additional charge.
60 *
61 * Exportability can only be chosen when the certificate is requested —
62 * ACM rejects `UpdateCertificateOptions` for it ("Export option for
63 * certificates cannot be updated") — so changing this on an existing
64 * certificate forces a replacement.
65 */
66 export?: "ENABLED" | "DISABLED" | undefined;
67 /**
68 * AWS region to request the certificate in.
69 *
70 * Defaults to `us-east-1` (the region CloudFront viewer certificates must
71 * live in). Set this to the region of a regional consumer — e.g. an ALB
72 * HTTPS listener requires the certificate in the load balancer's own
73 * region. Changing the region replaces the certificate.
74 * @default "us-east-1"
75 */
76 region?: string;
77 /**
78 * User-defined tags to apply to the certificate.
79 */
80 tags?: Record<string, string>;
81}
82
83/**
84 * Binding contract of {@link Certificate}: composites contribute additional
85 * subject alternative names without a circular input prop (e.g. a site
86 * attached to an `AWS.Website.Router` binds its hostnames onto the Router's
87 * certificate). ACM certificates are immutable — a change in the bound SAN
88 * set plans a REPLACEMENT (new certificate requested and validated first,
89 * consumers re-pointed, old certificate deleted last).
90 */
91export type CertificateBinding = {
92 /**
93 * Additional subject alternative names merged into the certificate's SAN
94 * set at reconcile time.
95 */
96 subjectAlternativeNames?: string[];
97};
98
99export interface Certificate extends Resource<
100 "AWS.ACM.Certificate",
101 CertificateProps,
102 {
103 /**
104 * ARN of the certificate.
105 */
106 certificateArn: string;
107 /**
108 * Primary domain name of the certificate.
109 */
110 domainName: string;
111 /**
112 * Additional subject alternative names on the certificate.
113 */
114 subjectAlternativeNames: string[];
115 /**
116 * Current ACM certificate status.
117 */
118 status: acm.CertificateStatus | undefined;
119 /**
120 * ACM-managed domain validation details, including DNS validation records.
121 */
122 domainValidationOptions: acm.DomainValidation[];
123 /**
124 * Requested validation method.
125 */
126 validationMethod: acm.ValidationMethod | undefined;
127 /**
128 * Requested key algorithm.
129 */
130 keyAlgorithm: acm.KeyAlgorithm | undefined;
131 /**
132 * Route 53 hosted zone used for automatic DNS validation, when configured.
133 */
134 hostedZoneId: string | undefined;
135 /**
136 * Certificate transparency logging preference currently on the certificate.
137 */
138 certificateTransparencyLoggingPreference:
139 | acm.CertificateTransparencyLoggingPreference
140 | undefined;
141 /**
142 * Whether the certificate's private key is exportable.
143 */
144 export: acm.CertificateExport | undefined;
145 /**
146 * Current tags on the certificate.
147 */
148 tags: Record<string, string>;
149 /**
150 * Certificate issue timestamp, when issued.
151 */
152 issuedAt: Date | undefined;
153 /**
154 * Certificate expiration timestamp, when issued.
155 */
156 notAfter: Date | undefined;
157 },
158 CertificateBinding,
159 Providers
160> {}
161
162/**
163 * Effective SAN set: declared props plus bound SANs (see
164 * {@link CertificateBinding}), deduped. Tolerates both `{ sid, data }` rows
165 * (provider lifecycle) and bare binding payloads.
166 * @internal
167 */
168const resolveEffectiveSans = (
169 declared: string[] | undefined,
170 bindings:
171 | ReadonlyArray<CertificateBinding | ResourceBinding<CertificateBinding>>
172 | undefined,
173): string[] | undefined => {
174 const bound = (bindings ?? []).flatMap((binding) =>
175 "data" in binding && binding.data !== undefined
176 ? ((binding as ResourceBinding<CertificateBinding>).data
177 .subjectAlternativeNames ?? [])
178 : ((binding as CertificateBinding).subjectAlternativeNames ?? []),
179 );
180 if (bound.length === 0) {
181 return declared;
182 }
183 return [...new Set([...(declared ?? []), ...bound])];
184};
185
186/**
187 * An ACM certificate for CloudFront and other AWS endpoints.
188 *
189 * `Certificate` requests an ACM certificate in `us-east-1`, which is the
190 * region required for CloudFront viewer certificates. When `hostedZoneId` is
191 * provided for DNS validation, the provider creates or updates the Route 53
192 * validation records and waits for the certificate to be issued.
193 * ### Requesting Certificates
194 * **Example:** DNS-Validated Certificate
195 * ```typescript
196 * const cert = yield* Certificate("WebsiteCertificate", {
197 * domainName: "www.example.com",
198 * hostedZoneId: "Z1234567890",
199 * });
200 * ```
201 *
202 * **Example:** Certificate With SANs
203 * ```typescript
204 * const cert = yield* Certificate("WebsiteCertificate", {
205 * domainName: "example.com",
206 * subjectAlternativeNames: ["www.example.com"],
207 * hostedZoneId: "Z1234567890",
208 * });
209 * ```
210 *
211 * **Example:** Exportable Certificate
212 * ```typescript
213 * // `export: "ENABLED"` lets the ExportCertificate binding retrieve the
214 * // certificate together with its (encrypted) private key at runtime.
215 * const cert = yield* Certificate("ExportableCertificate", {
216 * domainName: "www.example.com",
217 * hostedZoneId: "Z1234567890",
218 * export: "ENABLED",
219 * });
220 * ```
221 *
222 * ### Certificate Expiry Events
223 * **Example:** React to Approaching Expiration
224 * ```typescript
225 * // ACM emits "ACM Certificate Approaching Expiration" events through
226 * // EventBridge — consume them with the ACM expiry event source, scoped
227 * // to this certificate.
228 * yield* AWS.ACM.consumeExpiryEvents(
229 * { certificateArns: [cert.certificateArn] },
230 * (events) =>
231 * Stream.runForEach(events, (event) =>
232 * Effect.log(
233 * `${event.detail.CommonName} expires in ${event.detail.DaysToExpiry} days`,
234 * ),
235 * ),
236 * );
237 * ```
238 *
239 * @resource
240 */
241export const Certificate = Resource<Certificate>("AWS.ACM.Certificate");
242
243export const CertificateProvider = () =>
244 Provider.effect(
245 Certificate,
246 Effect.gen(function* () {
247 const describeCertificate = Effect.fn(function* (certificateArn: string) {
248 return yield* acm
249 .describeCertificate({ CertificateArn: certificateArn })
250 .pipe(
251 Effect.map((response) => response.Certificate),
252 Effect.catchTag("ResourceNotFoundException", () =>
253 Effect.succeed(undefined),
254 ),
255 withCertRegion(regionOfCertificateArn(certificateArn)),
256 );
257 });
258
259 const listCertificateTags = Effect.fn(function* (certificateArn: string) {
260 return yield* acm
261 .listTagsForCertificate({ CertificateArn: certificateArn })
262 .pipe(
263 Effect.map((response) => toTagRecord(response.Tags)),
264 Effect.catchTag("ResourceNotFoundException", () =>
265 Effect.succeed({}),
266 ),
267 withCertRegion(regionOfCertificateArn(certificateArn)),
268 );
269 });
270
271 const findManagedCertificate = Effect.fn(function* (
272 id: string,
273 props: CertificateProps,
274 ) {
275 // Describe candidates lazily as pages stream in and stop at the first
276 // match, so pagination terminates early instead of draining every page.
277 return yield* withCertRegion(props.region)(
278 acm.listCertificates
279 .items({
280 Includes: {
281 keyTypes: props.keyAlgorithm ? [props.keyAlgorithm] : undefined,
282 },
283 } as any)
284 .pipe(
285 Stream.filter(
286 (summary) => summary.DomainName === props.domainName,
287 ),
288 Stream.mapEffect((summary) =>
289 Effect.gen(function* () {
290 if (!summary.CertificateArn) {
291 return undefined;
292 }
293 const detail = yield* describeCertificate(
294 summary.CertificateArn,
295 );
296 if (!detail?.CertificateArn) {
297 return undefined;
298 }
299 if (
300 detail.DomainName !== props.domainName ||
301 JSON.stringify(
302 normalizeSanList(detail.SubjectAlternativeNames),
303 ) !==
304 JSON.stringify(
305 normalizeSanList(props.subjectAlternativeNames),
306 )
307 ) {
308 return undefined;
309 }
310 // Exportability is fixed at request time, so a certificate
311 // with a different export option can never converge to these
312 // props — it is the doomed half of a replacement, not a
313 // match.
314 if (
315 (props.export ?? "DISABLED") !==
316 (detail.Options?.Export ?? "DISABLED")
317 ) {
318 return undefined;
319 }
320 const tags = yield* listCertificateTags(
321 detail.CertificateArn,
322 );
323 return (yield* hasAlchemyTags(id, tags)) ? detail : undefined;
324 }),
325 ),
326 Stream.filter((detail) => detail !== undefined),
327 Stream.runHead,
328 Effect.map(Option.getOrUndefined),
329 ),
330 );
331 });
332
333 const waitForValidationRecords = Effect.fn(function* (
334 certificateArn: string,
335 ) {
336 return yield* describeCertificate(certificateArn).pipe(
337 Effect.flatMap((detail) => {
338 const validations = detail?.DomainValidationOptions ?? [];
339 if (
340 validations.length === 0 ||
341 validations.some((option) => option.ResourceRecord === undefined)
342 ) {
343 return Effect.fail(
344 new Error("CertificateValidationRecordPending"),
345 );
346 }
347 return Effect.succeed(detail!);
348 }),
349 Effect.retry({
350 while: (error) =>
351 error instanceof Error &&
352 error.message === "CertificateValidationRecordPending",
353 schedule: Schedule.max([
354 Schedule.fixed("2 seconds"),
355 Schedule.recurs(60),
356 ]),
357 }),
358 );
359 });
360
361 const waitForIssued = Effect.fn(function* (certificateArn: string) {
362 return yield* describeCertificate(certificateArn).pipe(
363 Effect.flatMap((detail) => {
364 if (!detail?.CertificateArn) {
365 return Effect.fail(new Error("CertificateNotFound"));
366 }
367 if (detail.Status === "ISSUED") {
368 return Effect.succeed(detail);
369 }
370 if (isTerminalFailure(detail.Status)) {
371 return Effect.fail(
372 new Error(
373 `Certificate issuance failed with status ${detail.Status}${detail.FailureReason ? ` (${detail.FailureReason})` : ""}`,
374 ),
375 );
376 }
377 return Effect.fail(new Error("CertificatePendingValidation"));
378 }),
379 Effect.retry({
380 while: (error) =>
381 error instanceof Error &&
382 error.message === "CertificatePendingValidation",
383 schedule: Schedule.max([
384 Schedule.fixed("10 seconds"),
385 Schedule.recurs(60),
386 ]),
387 }),
388 );
389 });
390
391 const upsertValidationRecords = Effect.fn(function* (
392 hostedZoneId: string,
393 certificate: acm.CertificateDetail,
394 ) {
395 const changes = (certificate.DomainValidationOptions ?? [])
396 .flatMap((option) =>
397 option.ResourceRecord ? [option.ResourceRecord] : [],
398 )
399 .map((record) => ({
400 Action: "UPSERT" as const,
401 ResourceRecordSet: {
402 Name: record.Name,
403 Type: record.Type,
404 TTL: 60,
405 ResourceRecords: [{ Value: record.Value }],
406 },
407 }));
408
409 if (changes.length === 0) {
410 return;
411 }
412
413 const response = yield* route53.changeResourceRecordSets({
414 HostedZoneId: normalizeHostedZoneId(hostedZoneId),
415 ChangeBatch: {
416 Comment: "Alchemy ACM DNS validation",
417 Changes: changes,
418 },
419 });
420
421 yield* waitForRoute53Change(response.ChangeInfo.Id);
422 });
423
424 return {
425 stables: ["certificateArn"],
426 list: () =>
427 Effect.gen(function* () {
428 // ACM certificates default to us-east-1 (CloudFront), but a
429 // `region` prop can place them in the ambient region (e.g. for
430 // ALB listeners) — enumerate both and dedupe by ARN, then
431 // hydrate each to the full Attributes shape via describe + tags.
432 const listPage = acm.listCertificates.pages({}).pipe(
433 Stream.runCollect,
434 Effect.map((chunk) =>
435 Array.from(chunk).flatMap(
436 (page) => page.CertificateSummaryList ?? [],
437 ),
438 ),
439 );
440 const summaries = [
441 ...new Map(
442 [...(yield* withAcmRegion(listPage)), ...(yield* listPage)].map(
443 (summary) => [summary.CertificateArn, summary] as const,
444 ),
445 ).values(),
446 ];
447 const rows = yield* Effect.forEach(
448 summaries,
449 (summary) =>
450 Effect.gen(function* () {
451 if (!summary.CertificateArn) {
452 return undefined;
453 }
454 const detail = yield* describeCertificate(
455 summary.CertificateArn,
456 );
457 if (!detail?.CertificateArn) {
458 return undefined;
459 }
460 const tags = yield* listCertificateTags(
461 detail.CertificateArn,
462 );
463 return toAttrs(
464 {
465 domainName: detail.DomainName ?? "",
466 validationMethod:
467 detail.DomainValidationOptions?.[0]?.ValidationMethod,
468 },
469 detail,
470 tags,
471 );
472 }),
473 { concurrency: 10 },
474 );
475 return rows.filter(
476 (row): row is ReturnType<typeof toAttrs> => row !== undefined,
477 );
478 }),
479 diff: Effect.fn(function* ({
480 olds,
481 news: _news,
482 oldBindings,
483 newBindings: _newBindings,
484 }) {
485 if (!isResolved(_news) || !isResolved(_newBindings)) {
486 return undefined;
487 }
488 const news = _news as typeof olds;
489 const newBindings =
490 _newBindings as ResourceBinding<CertificateBinding>[];
491 if (
492 olds.domainName !== news.domainName ||
493 // ACM certificates are immutable: the SAN set — declared props
494 // plus SANs contributed through the binding contract — cannot
495 // change in place, so any delta plans a replacement.
496 !deepEqual(
497 normalizeSanList(
498 resolveEffectiveSans(olds.subjectAlternativeNames, oldBindings),
499 ),
500 normalizeSanList(
501 resolveEffectiveSans(news.subjectAlternativeNames, newBindings),
502 ),
503 ) ||
504 (olds.validationMethod ?? defaultValidationMethod) !==
505 (news.validationMethod ?? defaultValidationMethod) ||
506 // An undefined side means "inferred" — only two explicit,
507 // differing zones are a replacement.
508 (olds.hostedZoneId !== undefined &&
509 news.hostedZoneId !== undefined &&
510 olds.hostedZoneId !== news.hostedZoneId) ||
511 olds.keyAlgorithm !== news.keyAlgorithm ||
512 // Certificates cannot move regions — a region change replaces.
513 (olds.region ?? ACM_REGION) !== (news.region ?? ACM_REGION) ||
514 // Exportability is fixed at request time — ACM rejects
515 // `UpdateCertificateOptions` for it ("Export option for
516 // certificates cannot be updated").
517 (olds.export ?? "DISABLED") !== (news.export ?? "DISABLED")
518 ) {
519 return { action: "replace" } as const;
520 }
521 // `certificateTransparencyLoggingPreference` is intentionally NOT
522 // a replacement trigger — it is updated in place via
523 // `UpdateCertificateOptions` in `reconcile`.
524 }),
525 read: Effect.fn(function* ({ id, olds, output }) {
526 // `olds.domainName` may be `undefined` when a `creating` row was
527 // persisted before upstream Outputs resolved — without a domain
528 // there is nothing to search for, so report "not found" and let
529 // the engine re-drive the create (reconcile finds any managed
530 // certificate by tags before requesting a new one).
531 const certificate = output?.certificateArn
532 ? yield* describeCertificate(output.certificateArn)
533 : olds?.domainName !== undefined
534 ? yield* findManagedCertificate(id, olds)
535 : undefined;
536
537 if (!certificate?.CertificateArn) {
538 return undefined;
539 }
540
541 const tags = yield* listCertificateTags(certificate.CertificateArn);
542 return toAttrs(
543 olds ?? { domainName: certificate.DomainName! },
544 certificate,
545 tags,
546 );
547 }),
548 reconcile: Effect.fn(function* ({
549 id,
550 instanceId,
551 news: _news,
552 output,
553 session,
554 bindings,
555 }) {
556 // Fold bound SANs (see `CertificateBinding`) into the desired
557 // props up front so every downstream step — managed-certificate
558 // lookup, request, attrs — sees the effective SAN set.
559 const news: typeof _news = {
560 ..._news,
561 subjectAlternativeNames: resolveEffectiveSans(
562 _news.subjectAlternativeNames,
563 bindings,
564 ),
565 };
566 const internalTags = yield* createInternalTags(id);
567 const desiredTags = { ...internalTags, ...news.tags };
568
569 // Observe — find the live certificate. Domain + SAN combo plus
570 // alchemy-owned tags identify a certificate uniquely; if we have
571 // a cached ARN, prefer that as the fast path. ACM certificates
572 // can't be renamed, and most fields trigger replace via diff,
573 // so the only real ensure path is "request if no managed
574 // certificate exists".
575 let certificate = output?.certificateArn
576 ? yield* describeCertificate(output.certificateArn)
577 : undefined;
578 if (!certificate?.CertificateArn) {
579 certificate = yield* findManagedCertificate(id, news);
580 }
581
582 // Ensure — request a new certificate if none exists. The
583 // `IdempotencyToken` (derived from `instanceId`) makes the
584 // request safe to retry.
585 if (!certificate?.CertificateArn) {
586 certificate = yield* withCertRegion(news.region)(
587 acm
588 .requestCertificate({
589 DomainName: news.domainName,
590 SubjectAlternativeNames: news.subjectAlternativeNames,
591 ValidationMethod:
592 news.validationMethod ?? defaultValidationMethod,
593 KeyAlgorithm: news.keyAlgorithm,
594 Options:
595 news.certificateTransparencyLoggingPreference || news.export
596 ? {
597 CertificateTransparencyLoggingPreference:
598 news.certificateTransparencyLoggingPreference,
599 Export: news.export,
600 }
601 : undefined,
602 IdempotencyToken: instanceId
603 .replaceAll(/[^a-zA-Z0-9]/g, "")
604 .slice(0, 32),
605 Tags: createTagsList(desiredTags),
606 })
607 .pipe(
608 Effect.flatMap((response) =>
609 response.CertificateArn
610 ? describeCertificate(response.CertificateArn).pipe(
611 Effect.map((detail) => detail!),
612 )
613 : Effect.fail(
614 new Error(
615 "requestCertificate returned no certificate ARN",
616 ),
617 ),
618 ),
619 ),
620 );
621 }
622
623 if (!certificate?.CertificateArn) {
624 return yield* Effect.fail(
625 new Error("Failed to obtain ACM certificate"),
626 );
627 }
628
629 const certificateArn = certificate.CertificateArn;
630 yield* session.note(certificateArn);
631
632 // Sync DNS validation: ensure validation records are upserted and
633 // the cert reaches `ISSUED`. The zone is the explicit
634 // `hostedZoneId` when given; otherwise the most specific public
635 // zone containing `domainName` is inferred. When neither yields a
636 // zone, validation is left to the caller (external DNS) and the
637 // certificate is returned pending — the pre-inference behavior.
638 // For an already-issued cert this is a fast-path: we only wait
639 // for validation records when the cert isn't already issued.
640 if (
641 (news.validationMethod ?? defaultValidationMethod) === "DNS" &&
642 certificate.Status !== "ISSUED"
643 ) {
644 const validationZoneId =
645 news.hostedZoneId ??
646 (yield* findPublicHostedZoneId(news.domainName));
647 if (validationZoneId !== undefined) {
648 const withRecords =
649 yield* waitForValidationRecords(certificateArn);
650 yield* upsertValidationRecords(validationZoneId, withRecords);
651 certificate = yield* waitForIssued(certificateArn);
652 }
653 }
654
655 // Sync options — only the CT logging preference is mutable in
656 // place via UpdateCertificateOptions (ACM rejects updating the
657 // export option; diff treats an `export` change as replacement).
658 // Diff OBSERVED options (adoption may hand us a certificate with
659 // foreign options); only call the API when an explicitly-desired
660 // value differs, and omit `Export` so the fixed option is left
661 // untouched.
662 const observedOptions = certificate.Options ?? {};
663 const wantsCtChange =
664 news.certificateTransparencyLoggingPreference !== undefined &&
665 news.certificateTransparencyLoggingPreference !==
666 observedOptions.CertificateTransparencyLoggingPreference;
667 if (wantsCtChange) {
668 yield* withCertRegion(regionOfCertificateArn(certificateArn))(
669 acm.updateCertificateOptions({
670 CertificateArn: certificateArn,
671 Options: {
672 CertificateTransparencyLoggingPreference:
673 news.certificateTransparencyLoggingPreference,
674 },
675 }),
676 );
677 certificate = (yield* describeCertificate(certificateArn))!;
678 }
679
680 // Sync tags — diff observed cloud tags against desired so
681 // adoption rewrites ownership tags correctly.
682 const observedTags = yield* listCertificateTags(certificateArn);
683 const { removed, upsert } = diffTags(observedTags, desiredTags);
684
685 if (upsert.length > 0) {
686 yield* withCertRegion(regionOfCertificateArn(certificateArn))(
687 acm.addTagsToCertificate({
688 CertificateArn: certificateArn,
689 Tags: upsert,
690 }),
691 );
692 }
693 if (removed.length > 0) {
694 yield* withCertRegion(regionOfCertificateArn(certificateArn))(
695 acm.removeTagsFromCertificate({
696 CertificateArn: certificateArn,
697 Tags: removed.map((Key) => ({ Key })),
698 }),
699 );
700 }
701
702 // Re-read so the returned attributes reflect any tag mutation.
703 const finalTags = yield* listCertificateTags(certificateArn);
704 return toAttrs(news, certificate, finalTags);
705 }),
706 delete: Effect.fn(function* ({ output }) {
707 yield* withCertRegion(regionOfCertificateArn(output.certificateArn))(
708 acm
709 .deleteCertificate({
710 CertificateArn: output.certificateArn,
711 })
712 .pipe(
713 // `ResourceInUseException` covers the certificate-swap path:
714 // when a SAN change replaces the certificate, CloudFront can
715 // keep reporting the detached old certificate as in-use for a
716 // few minutes after the distribution update deploys — ride
717 // that out with a bounded wait instead of failing the delete.
718 Effect.retry({
719 while: (e): boolean =>
720 e._tag === "ConflictException" ||
721 e._tag === "ResourceInUseException",
722 schedule: Schedule.max([
723 Schedule.fixed("10 seconds"),
724 Schedule.recurs(30),
725 ]),
726 }),
727 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
728 ),
729 );
730 }),
731 };
732 }),
733 );
734
735/** @internal */
736export const waitForRoute53Change = Effect.fn(function* (changeId: string) {
737 return yield* route53
738 .getChange({
739 Id: changeId.replace(/^\/change\//, ""),
740 })
741 .pipe(
742 Effect.map((response) => response.ChangeInfo),
743 Effect.flatMap((changeInfo) =>
744 changeInfo.Status === "INSYNC"
745 ? Effect.succeed(changeInfo)
746 : Effect.fail(new Error("Route53ChangePending")),
747 ),
748 Effect.retry({
749 while: (error) =>
750 error instanceof Error && error.message === "Route53ChangePending",
751 schedule: Schedule.max([
752 Schedule.fixed("2 seconds"),
753 Schedule.recurs(60),
754 ]),
755 }),
756 );
757});
758
759const ACM_REGION = "us-east-1" as const;
760const defaultValidationMethod = "DNS" as const;
761
762/**
763 * Region an existing certificate lives in, parsed from its ARN
764 * (`arn:aws:acm:{region}:{account}:certificate/...`).
765 */
766const regionOfCertificateArn = (certificateArn: string) =>
767 certificateArn.split(":")[3] || ACM_REGION;
768
769const withAcmRegion = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
770 // `AwsRegion`'s service value is an `Effect<RegionName>` (see
771 // `@distilled.cloud/aws/Region`), so it must be provided as an effect, not a
772 // bare string — providing a raw string yields a primitive into the run loop.
773 effect.pipe(Effect.provideService(AwsRegion, Effect.succeed(ACM_REGION)));
774
775/**
776 * Pin ACM calls to the certificate's region: the props-requested region for
777 * new certificates (default `us-east-1`), or the region parsed from an
778 * existing certificate's ARN.
779 */
780const withCertRegion =
781 (region: string | undefined) =>
782 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
783 effect.pipe(
784 Effect.provideService(
785 AwsRegion,
786 Effect.succeed((region ?? ACM_REGION) as typeof ACM_REGION),
787 ),
788 );
789
790const normalizeHostedZoneId = (hostedZoneId: string) =>
791 hostedZoneId.replace(/^\/hostedzone\//, "");
792
793const normalizeSanList = (names: string[] | undefined) =>
794 [...(names ?? [])].sort((a, b) => a.localeCompare(b));
795
796const toTagRecord = (tags: acm.Tag[] | undefined) =>
797 Object.fromEntries(
798 (tags ?? [])
799 .filter(
800 (tag): tag is { Key: string; Value: string } =>
801 typeof tag.Key === "string" && typeof tag.Value === "string",
802 )
803 .map((tag) => [tag.Key, tag.Value]),
804 );
805
806const toAttrs = (
807 props: CertificateProps,
808 detail: acm.CertificateDetail,
809 tags: Record<string, string>,
810) => ({
811 certificateArn: detail.CertificateArn!,
812 domainName: detail.DomainName ?? props.domainName,
813 subjectAlternativeNames: detail.SubjectAlternativeNames ?? [],
814 status: detail.Status,
815 domainValidationOptions: detail.DomainValidationOptions ?? [],
816 validationMethod: props.validationMethod ?? defaultValidationMethod,
817 keyAlgorithm: detail.KeyAlgorithm ?? props.keyAlgorithm,
818 hostedZoneId: props.hostedZoneId
819 ? normalizeHostedZoneId(props.hostedZoneId)
820 : undefined,
821 certificateTransparencyLoggingPreference:
822 detail.Options?.CertificateTransparencyLoggingPreference,
823 export: detail.Options?.Export,
824 tags,
825 issuedAt: detail.IssuedAt,
826 notAfter: detail.NotAfter,
827});
828
829const isTerminalFailure = (status: acm.CertificateStatus | undefined) =>
830 status === "FAILED" || status === "VALIDATION_TIMED_OUT";