From b7ba2da0cd05478b5edce77ea1c52ecfeffdb665 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Thu, 11 Jun 2026 17:31:30 -0400 Subject: [PATCH] SMOODEV-1791: SmooaiNextEdge auto-validates the us-east-1 viewer cert via the DNS adapter (0.1.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfooding Stage C surfaced a gap: the construct created the ACM viewer cert (validationMethod: DNS) but never published the validation records or gated on an aws.acm.CertificateValidation. On a Cloudflare-DNS app the cert stays PENDING_VALIDATION and CloudFront refuses to attach it — the deploy fails. Mirror SST's DnsValidatedCertificate: when the DNS adapter exposes createRecord, publish the ACM DNS-validation CNAMEs (de-duped) + a CAA(amazonaws.com) for non-Route53 zones, then gate the distribution's viewerCertificate on a CertificateValidation (us-east-1 provider). Validation CNAMEs are never proxied (SST's cloudflare adapter only proxies alias records), as ACM requires. No createRecord on the adapter → unchanged behavior (raw PENDING arn, manual validation). Extend DnsAdapter with optional provider/createRecord/createCaa. Co-Authored-By: Claude Opus 4.8 --- sst/package.json | 4 +- sst/src/components/smooai-next-edge.ts | 66 +++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/sst/package.json b/sst/package.json index dadb3d6..b1f0ac1 100644 --- a/sst/package.json +++ b/sst/package.json @@ -1,7 +1,7 @@ { "name": "@smooai/deploy", - "version": "0.1.3", - "description": "Shared SmooAI deploy primitives \u2014 reusable SST v4 constructs (API Gateway WebSocket + Rust Lambda + DynamoDB single-table + S3 blob bucket + S3 Vectors placeholder). Consumed by smooth-operator and dogfooded by smooai.", + "version": "0.1.4", + "description": "Shared SmooAI deploy primitives — reusable SST v4 constructs (API Gateway WebSocket + Rust Lambda + DynamoDB single-table + S3 blob bucket + S3 Vectors placeholder). Consumed by smooth-operator and dogfooded by smooai.", "license": "MIT", "type": "module", "main": "src/index.ts", diff --git a/sst/src/components/smooai-next-edge.ts b/sst/src/components/smooai-next-edge.ts index d73733f..0059af1 100644 --- a/sst/src/components/smooai-next-edge.ts +++ b/sst/src/components/smooai-next-edge.ts @@ -57,11 +57,36 @@ const ONE_YEAR_SECONDS = 31_536_000; * coupling to an un-exposed platform type. */ export interface DnsAdapter { + /** + * DNS provider id (`'cloudflare'` | `'aws'` | `'vercel'`). Used to decide + * whether ACM needs CAA records: a non-Route53 zone must publish a + * `CAA … issue "amazonaws.com"` record or DNS validation can be refused. + */ + provider?: string; createAlias( namePrefix: string, record: { name: $util.Input; aliasName: $util.Input; aliasZone: $util.Input }, opts: Record, ): unknown; + /** + * Create a generic DNS record. Used here to publish the ACM DNS-validation + * CNAMEs so the us-east-1 viewer cert issues automatically. SST's + * `sst.cloudflare.dns()` / `sst.aws.dns()` adapters provide this. When + * absent, the construct leaves cert validation to the consumer (manual) and + * references the raw (PENDING) cert ARN — only safe if you validate it + * out-of-band before the distribution is created. + */ + createRecord?( + namePrefix: string, + record: { type: $util.Input; name: $util.Input; value: $util.Input }, + opts: $util.ComponentResourceOptions, + ): $util.Output<$util.Resource>; + /** + * Create the CAA records authorizing ACM (`amazonaws.com`) to issue for the + * domain — needed when the zone isn't on Route 53. Returns the created + * records so the validation records can depend on them. + */ + createCaa?(namePrefix: string, recordName: $util.Input, opts: $util.ComponentResourceOptions): $util.Resource[] | $util.Output<$util.Resource>[] | undefined; } /** Tunables for {@link SmooaiNextEdge}. */ @@ -323,6 +348,45 @@ export class SmooaiNextEdge { { provider: usEast1 }, ); + // Auto-issue the viewer cert when the DNS adapter can create records: + // publish the ACM DNS-validation CNAMEs (+ a CAA for non-Route53 zones) + // and gate on an `aws.acm.CertificateValidation` so the cert is ISSUED + // before CloudFront tries to attach it. Without this gate the deploy + // fails — CloudFront rejects a PENDING_VALIDATION cert. The validation + // CNAMEs must NOT be proxied; SST's cloudflare adapter only proxies + // alias records, so `createRecord` here stays grey-cloud as ACM needs. + // No `createRecord` on the adapter (or no adapter) → manual validation: + // reference the raw cert ARN (the consumer must validate out-of-band). + let viewerCertificateArn: $util.Output = certificate.arn; + if (args.dns?.createRecord) { + const dns = args.dns; + const validationRecords = $util.all([certificate.domainValidationOptions]).apply(([options]) => { + // De-dup: a domain + its SANs frequently share one CNAME. + const seen: string[] = []; + const unique = options.filter((option) => { + const key = option.resourceRecordType + option.resourceRecordName; + if (seen.includes(key)) return false; + seen.push(key); + return true; + }); + const caaRecords = dns.provider !== 'aws' && dns.createCaa ? dns.createCaa(`${name}Cert`, domain, {}) : undefined; + return unique.map((option) => + dns.createRecord!( + `${name}Cert`, + { type: option.resourceRecordType, name: option.resourceRecordName, value: option.resourceRecordValue }, + { dependsOn: caaRecords ? [...caaRecords] : [] }, + ), + ); + }); + + const certificateValidation = new aws.acm.CertificateValidation( + `${name}CertValidation`, + { certificateArn: certificate.arn }, + { provider: usEast1, dependsOn: validationRecords }, + ); + viewerCertificateArn = certificateValidation.certificateArn; + } + // ── Cache + origin-request policies ──────────────────────────────── // Immutable assets: cache hard, forward nothing (no cookies/headers/qs). const immutablePolicy = new aws.cloudfront.CachePolicy(`${name}ImmutablePolicy`, { @@ -448,7 +512,7 @@ export class SmooaiNextEdge { geoRestriction: { restrictionType: 'none' }, }, viewerCertificate: { - acmCertificateArn: certificate.arn, + acmCertificateArn: viewerCertificateArn, sslSupportMethod: 'sni-only', minimumProtocolVersion: 'TLSv1.2_2021', },